Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions scripts/test-feed-render-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,47 @@ function writeStdinTurn(turnId: string, chars: string): SemanticLiveTurn {
}
}

function webSearchHistoryTurn(turnId: string, itemId: string, answerText: string): SemanticLiveTurn {
return {
...liveTurn(turnId),
text: answerText,
endedAt: 2,
blocks: {
0: {
blockIndex: 0,
kind: 'reasoning',
itemId: `rs_${itemId}`,
finalized: true,
text: '',
thinking: '',
reasoningSummary: '',
reasoningText: '',
},
1: {
blockIndex: 1,
kind: 'web_search_call',
itemId,
status: 'completed',
finalized: true,
webSearchAction: {
kind: 'search',
query: 'Electron window.prompt is not supported',
queries: ['Electron window.prompt is not supported'],
},
},
2: {
blockIndex: 2,
kind: 'message',
itemId: `msg_${itemId}`,
status: 'completed',
finalized: true,
text: answerText,
},
},
blockOrder: [0, 1, 2],
}
}

// WHY these tests sit below the renderer instead of inside Feed.tsx:
// the failure mode we keep reintroducing is not "React cannot map an
// array." It is "two different data planes both believe they own the
Expand Down Expand Up @@ -517,6 +558,78 @@ function writeStdinTurn(turnId: string, chars: string): SemanticLiveTurn {
assert.notEqual(model.renderedSemanticTurn, null)
}

{
const answerText =
'Yes. Electron does not support window.prompt, so the AI Workspace dialog needs a real app UI.'
const webSearchItemId = 'ws_06c0839c0d254157016a09ae03afd48191b67160fc3df8c70a'
const committedSearch = {
...assistantEntry('committed-search', ''),
uuid: '2026-05-17T12:01:20.152Z:web_search_call',
codexTurnId: '019e35ce-0fd1-7ee0-8782-e8eb1761b4d0',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: webSearchItemId,
name: 'web_search',
input: {
description: 'Search: Electron window.prompt is not supported',
},
},
],
},
} as Entry
const committedAnswer = {
...assistantEntry('committed-answer', answerText),
uuid: '2026-05-17T12:01:59.784Z:message',
codexTurnId: '019e35ce-0fd1-7ee0-8782-e8eb1761b4d0',
message: {
role: 'assistant',
content: [{ type: 'text', text: answerText }],
},
} as Entry
const committedToolUseIndex = new Map<string, ToolUseBlock>([
[
webSearchItemId,
{
type: 'tool_use',
id: webSearchItemId,
name: 'web_search',
input: { description: 'Search: Electron window.prompt is not supported' },
},
],
])

const model = deriveFeedRenderModel({
provider: 'codex',
entries: [committedSearch, committedAnswer],
semanticHistory: [
webSearchHistoryTurn(
'resp_06c0839c0d254157016a09adf8a8a88191a829817928dfd789',
webSearchItemId,
answerText,
),
],
semanticTurn: null,
streamPhase: 'idle',
streamPhasePendingToolName: null,
streamPhasePendingToolUseId: null,
committedToolUseIndex,
})

assert.equal(
model.renderedSemanticHistory.length,
0,
'committed web_search tool_use plus committed answer text must suppress the archived proxy web-search turn',
)
assert.deepEqual(
model.debugRows.map(row => row.slot),
['entry', 'entry'],
'stale web-search semantic history must not remain pinned at the bottom after rollout catch-up',
)
}

{
const model = deriveFeedRenderModel({
provider: 'codex',
Expand Down
13 changes: 10 additions & 3 deletions src/renderer/src/features/debug/saveDebugBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,13 @@ function semanticTurnDiagnostics(
.map(block => {
const text = block.text ?? ''
const normalized = normalizeCommittedAssistantText(text)
const toolId = block.toolUseId ?? block.callId ?? null
const isOutputBlock =
block.kind === 'function_call_output' ||
block.kind === 'custom_tool_call_output' ||
block.kind === 'tool_search_output'
const toolId = isOutputBlock
? block.toolUseId ?? block.callId ?? null
: block.toolUseId ?? block.callId ?? block.itemId ?? null
return {
blockIndex: block.blockIndex,
kind: block.kind,
Expand All @@ -149,9 +155,10 @@ function semanticTurnDiagnostics(
committedText.keys.has(`${turn.turnId}\u0000${text}`) ||
committedText.texts.has(text) ||
(normalized !== '' && committedText.normalizedTexts.has(normalized))
),
),
toolId,
toolOwnedByCommitted: toolId ? committedToolUseIndex.has(toolId) : false,
toolOwnedByCommitted:
toolId && !isOutputBlock ? committedToolUseIndex.has(toolId) : false,
toolResultOwnedByCommitted: toolId ? committedToolResultIndex.has(toolId) : false,
}
}),
Expand Down
31 changes: 29 additions & 2 deletions src/renderer/src/features/feed/ui/semantic/renderUnits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ export function semanticTurnHasRenderableContent(
).length > 0
}

function semanticToolCorrelationId(block: SemanticLiveTurn['blocks'][number]): string | null {
function semanticToolUseCorrelationId(block: SemanticLiveTurn['blocks'][number]): string | null {
return block.toolUseId ?? block.callId ?? block.itemId ?? null
}

function semanticToolOutputCorrelationId(block: SemanticLiveTurn['blocks'][number]): string | null {
return block.toolUseId ?? block.callId ?? null
}

Expand Down Expand Up @@ -185,6 +189,26 @@ function isInvisibleWriteStdinBlock(block: SemanticLiveTurn['blocks'][number]):
return writeStdinChars(block).length === 0
}

function isInvisibleEmptyReasoningBlock(block: SemanticLiveTurn['blocks'][number]): boolean {
if (block.kind !== 'thinking' && block.kind !== 'reasoning') return false
const text =
block.thinking ||
block.reasoningSummary ||
block.reasoningText ||
''
// WHY this belongs in the render-unit selector:
// SemanticLiveBlockRow intentionally returns null for empty
// reasoning/thinking because WorkIndicator is the real "agent is
// thinking" surface. If this selector still emits a block unit, an
// archived proxy turn with only empty finalized reasoning can keep a
// semantic-history row alive forever in debug/render ownership while
// React paints no content. That false positive was part of the
// 2026-05-17 web-search bundle: after committed rollout owned the
// visible answer, empty reasoning blocks helped keep old proxy
// history mounted at the bottom.
return text.length === 0
}

// WHY add a derived render-unit pass before painting semantic blocks:
//
// Claude Code does not render raw transcript/tool rows directly for
Expand Down Expand Up @@ -256,6 +280,7 @@ export function buildSemanticRenderUnits(
}

if (isInvisibleWriteStdinBlock(block)) continue
if (isInvisibleEmptyReasoningBlock(block)) continue

// Committed-ownership skip. Check both Claude's `toolUseId` and
// Codex's `callId` — the committed indices are keyed by the
Expand All @@ -272,7 +297,9 @@ export function buildSemanticRenderUnits(
// committed, the result was not, and the live output disappeared.
// Use blocks yield to committed tool_use ownership; output blocks
// yield only after committed tool_result ownership exists.
const toolId = semanticToolCorrelationId(block)
const toolId = isSemanticToolOutputBlock(block)
? semanticToolOutputCorrelationId(block)
: semanticToolUseCorrelationId(block)
const toolOwnedByCommitted = toolId
? isSemanticToolOutputBlock(block)
? committedToolResultIndex?.has(toolId) === true
Expand Down