From 9a3f42d25092919864b28bdeef56271ecdfb1bb0 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 14 Sep 2026 09:39:27 +0200 Subject: [PATCH] fix: recognise an agent stream that opens with an empty token Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/components/agentTrace.test.ts | 8 ++-- .../src/lib/components/aiAgentResult.test.ts | 12 ++++++ frontend/src/lib/components/aiAgentResult.ts | 40 ++++++++++++++++--- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/agentTrace.test.ts b/frontend/src/lib/components/agentTrace.test.ts index d3a63f2111..9f33a10023 100644 --- a/frontend/src/lib/components/agentTrace.test.ts +++ b/frontend/src/lib/components/agentTrace.test.ts @@ -117,10 +117,6 @@ describe('buildAgentTrace', () => { }) }) -// A run stopped by max_iterations serializes its partial messages itself rather -// than reusing the success envelope's writer. `agent_action` is `skip_serializing` -// on `OpenAIMessage`, so if that path ever stops wrapping them the tags vanish and -// this trace silently empties — which is the one run worth reading. describe('splitFinalAnswer', () => { it('moves the answering turn out of the trace, with its citations', () => { const entries = buildAgentTrace([ @@ -165,6 +161,10 @@ describe('splitFinalAnswer', () => { }) }) +// A run stopped by max_iterations serializes its partial messages itself rather +// than reusing the success envelope's writer. `agent_action` is `skip_serializing` +// on `OpenAIMessage`, so if that path ever stops wrapping them the tags vanish and +// this trace silently empties — which is the one run worth reading. describe('the max-iterations path', () => { it('traces the partial messages the error carries', () => { const partial = parseAgentErrorMessages({ diff --git a/frontend/src/lib/components/aiAgentResult.test.ts b/frontend/src/lib/components/aiAgentResult.test.ts index bb9f87f418..4b987f36ba 100644 --- a/frontend/src/lib/components/aiAgentResult.test.ts +++ b/frontend/src/lib/components/aiAgentResult.test.ts @@ -78,6 +78,9 @@ describe('agent stream', () => { expect(isAgentStream('{"level":"info","msg":"hello"}\n')).toBe(false) // No newline yet, so the first line may still be half-written. expect(isAgentStream('{"type":"token_delta","content":"a"}')).toBe(false) + // 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) }) it('folds the token deltas into the answer so far', () => { @@ -200,6 +203,15 @@ describe('a stream that narrates before calling a tool', () => { // The narration became a row rather than disappearing. expect(stream.entries[0]).toEqual({ kind: 'assistant', content: 'Let me check the metrics.' }) }) + + // A script can write anything to `result_stream`, so a tool event is not + // guaranteed the fields its type declares. + it('ignores a tool event with nothing to key or label a row by', () => { + const raw = '{"type":"token_delta","content":"Hi."}\n{"type":"tool_call"}\n' + const { stream } = advanceAgentStream(raw, emptyAgentStreamProgress()) + expect(stream.entries).toEqual([]) + expect(stream.current).toBe('Hi.') + }) }) // A result is whatever a script returned, so a message that has a `role` still has diff --git a/frontend/src/lib/components/aiAgentResult.ts b/frontend/src/lib/components/aiAgentResult.ts index e156d5c142..5710be9282 100644 --- a/frontend/src/lib/components/aiAgentResult.ts +++ b/frontend/src/lib/components/aiAgentResult.ts @@ -224,6 +224,24 @@ export function emptyAgentStreamProgress(): AgentStreamProgress { */ const TOOL_TURN_STARTED: StreamEvent['kind'][] = ['tool_call', 'tool_arguments', 'tool_execution'] +/** + * The worker's wire names, for deciding whether a stream is an agent's at all. + * + * Membership rather than whether `parseStreamEvents` yields something: that drops + * an event carrying an empty payload, and a first `token_delta` with no content + * is what a provider opens with when it does not filter them (Bedrock does not). + * Reading the yield would answer "not an agent" for the whole run and leave a + * real one rendering as a wall of event objects. + */ +const STREAM_EVENT_TYPES = [ + 'token_delta', + 'reasoning_token_delta', + 'tool_call', + 'tool_call_arguments', + 'tool_execution', + 'tool_result' +] + /** * 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 @@ -239,15 +257,20 @@ export function isAgentStream(raw: string): boolean { } const line = raw.slice(start, end) if (line.trim() !== '') { - // The JSON check is this function's own because `parseStreamEvents` reports - // a line it cannot read, and most streams reaching here are a script's - // plain output rather than a malformed event. + let parsed: unknown + // Parsed here rather than left to `parseStreamEvents`, which reports a line + // it cannot read: most streams reaching this check are a script's plain + // output, not a malformed event. try { - JSON.parse(line) + parsed = JSON.parse(line) } catch { return false } - return parseStreamEvents(line).length > 0 + return ( + isRecord(parsed) && + typeof parsed.type === 'string' && + STREAM_EVENT_TYPES.includes(parsed.type) + ) } start = end + 1 } @@ -282,6 +305,13 @@ export function advanceAgentStream( stream.reasoning += event.content continue } + // `result_stream` is whatever the job wrote, so a tool event can arrive + // without the fields its type promises. Such a line is not a turn boundary + // and not a row: keyed on nothing, it would draw an unlabelled card that + // every later nameless event joins. + if (typeof event.callId !== 'string' || typeof event.name !== 'string') { + continue + } if (TOOL_TURN_STARTED.includes(event.kind)) { if (stream.current !== '') { // A model can narrate and request a tool in the same turn. The call