fix: recognise an agent stream that opens with an empty token

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-14 09:39:27 +02:00
co-authored by Claude Opus 5
parent 9a2e10d734
commit 9a3f42d250
3 changed files with 51 additions and 9 deletions
@@ -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({
@@ -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
+35 -5
View File
@@ -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