From 7e5809acd2c148ab0dd1a4d528bdd8c9f278fc1e Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 17 Sep 2026 10:31:06 +0200 Subject: [PATCH] fix: stop a stream the fold cannot use from claiming the run pane Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/components/aiAgentResult.test.ts | 3 ++ frontend/src/lib/components/aiAgentResult.ts | 38 ++++++++++++------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/components/aiAgentResult.test.ts b/frontend/src/lib/components/aiAgentResult.test.ts index 4c6499ce73..a021acb963 100644 --- a/frontend/src/lib/components/aiAgentResult.test.ts +++ b/frontend/src/lib/components/aiAgentResult.test.ts @@ -86,6 +86,9 @@ describe('agent stream', () => { // A provider that does not filter its empty deltas opens with one, and the // run is an agent's all the same. expect(isAgentStream('{"type":"token_delta","content":""}\n')).toBe(true) + // What the fold cannot use must not claim the pane either, or the script's + // own output is replaced by a view with nothing to draw. + expect(isAgentStream('{"type":"tool_call","function_name":"q"}\n')).toBe(false) }) it('folds the token deltas into the answer so far', () => { diff --git a/frontend/src/lib/components/aiAgentResult.ts b/frontend/src/lib/components/aiAgentResult.ts index 5b314e552c..e2bf5d50b9 100644 --- a/frontend/src/lib/components/aiAgentResult.ts +++ b/frontend/src/lib/components/aiAgentResult.ts @@ -235,6 +235,24 @@ const TOOL_TURN_STARTED: AgentStreamEvent['type'][] = [ 'tool_execution' ] +/** + * Whether an event carries what its type declares: a delta its text, a tool event + * the `call_id` its row is keyed on and the name that labels it. `result_stream` is + * whatever the job wrote, so detection and the fold ask the same question — were + * they to differ, a stream would be claimed and then render nothing. + */ +function isWellFormedEvent(event: AgentStreamEvent): boolean { + if (event.type === 'token_delta' || event.type === 'reasoning_token_delta') { + return typeof event.content === 'string' + } + return ( + typeof event.call_id === 'string' && + event.call_id !== '' && + typeof event.function_name === 'string' && + event.function_name !== '' + ) +} + /** * Whether `result_stream` is an agent's event stream rather than something a * script printed. Reads only the first complete line, because it runs on every @@ -250,7 +268,7 @@ export function isAgentStream(raw: string): boolean { } const line = raw.slice(start, end) if (line.trim() !== '') { - return parseStreamEvents(line).length > 0 + return parseStreamEvents(line).some(isWellFormedEvent) } start = end + 1 } @@ -276,24 +294,16 @@ export function advanceAgentStream( return previous } const stream: AgentStream = { ...previous.stream, entries: [...previous.stream.entries] } - // `result_stream` is whatever the job wrote and the parser keeps any line whose - // `type` it knows, so an event can arrive without the fields its type promises. - // A delta with no text would append "undefined", and a tool event keyed on - // nothing would draw an unlabelled card every later nameless event joins. for (const event of parseStreamEvents(raw.slice(previous.consumed, complete))) { + if (!isWellFormedEvent(event)) { + continue + } if (event.type === 'token_delta') { - if (typeof event.content === 'string') { - stream.current += event.content - } + stream.current += event.content continue } if (event.type === 'reasoning_token_delta') { - if (typeof event.content === 'string') { - stream.reasoning += event.content - } - continue - } - if (typeof event.call_id !== 'string' || typeof event.function_name !== 'string') { + stream.reasoning += event.content continue } if (TOOL_TURN_STARTED.includes(event.type)) {