fix: keep every streamed turn instead of dropping the narration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-17 10:07:22 +02:00
co-authored by Claude Opus 5
parent 8343f92efe
commit 3029d74905
5 changed files with 90 additions and 69 deletions
@@ -41,7 +41,7 @@
})
</script>
<div class="flex flex-col w-full pt-1">
<div class="flex flex-col w-full pt-3">
{#if trace.length > 0}
<AgentTrace entries={trace} {workspaceId} />
{/if}
@@ -36,45 +36,49 @@
let anchor: HTMLElement | undefined = $state()
$effect(() => {
// Follow the text as it is written, the same way the finished run opens on
// its output: both put what you came for at the bottom.
stream.answer
// Follow the text as it is written, the same way a finished run opens on its
// output: both put what you came for at the bottom.
stream.current
stream.reasoning
stream.tools.length
stream.entries.length
scrollPaneToEnd(anchor)
})
</script>
<!-- Deliberately the same order the finished run uses — what it did, then what it
produced — so the view does not rearrange itself when the result lands. -->
<div class="flex flex-col w-full pt-1">
{#each stream.tools as tool (tool.callId)}
<ChatCollapsibleCard
label={tool.name}
expanded={false}
toggleable={false}
shimmer={tool.running}
onToggle={() => {}}
labelClass={tool.success === false ? 'text-red-500' : ''}
/>
<!-- The same order a finished run uses — what it did, then what it is saying — so
nothing moves when the result lands. The text at the bottom is deliberately
unlabelled: a turn that goes on to call a tool was narration, and only the end
of the run settles which this one is. -->
<div class="flex flex-col w-full pt-3">
{#each stream.entries as entry, index (entry.kind === 'tool' ? entry.callId : index)}
{#if entry.kind === 'tool'}
<ChatCollapsibleCard
label={entry.name}
expanded={false}
toggleable={false}
shimmer={entry.running}
onToggle={() => {}}
labelClass={entry.success === false ? 'text-red-500' : ''}
/>
{:else}
<div class="mb-1">
<GfmMarkdown md={entry.content} noPadding />
</div>
{/if}
{/each}
<div class={stream.tools.length > 0 ? 'mt-4 pt-3 border-t border-border-light' : ''}>
<span class="text-2xs text-hint">Output</span>
<div class="mt-1">
<!-- Same sanitizing chain as the finished output: a partial answer is
written by the same model and is no more trusted for arriving in
pieces. -->
{#if stream.answer !== ''}
<GfmMarkdown md={stream.answer} noPadding />
{:else if stream.reasoning !== ''}
<!-- Reasoning arrives before the answer, so on its own it means the model
is still thinking rather than that this run has no answer. -->
<div class="text-secondary">
<GfmMarkdown md={stream.reasoning} prose="xs" noPadding />
</div>
{/if}
{#if stream.current !== ''}
<div class="mt-2">
<!-- Same sanitizing chain as a finished output: a partial answer is written
by the same model and is no more trusted for arriving in pieces. -->
<GfmMarkdown md={stream.current} noPadding />
</div>
</div>
{:else if stream.reasoning !== ''}
<!-- Reasoning arrives before the text, so on its own it means the model is
still thinking rather than that this run has nothing to say. -->
<div class="text-secondary mt-2">
<GfmMarkdown md={stream.reasoning} prose="xs" noPadding />
</div>
{/if}
<div bind:this={anchor}></div>
</div>
@@ -831,7 +831,7 @@
{#if ['table-col', 'table-row', 'table-row-object'].includes(resultKind ?? '')}
<ToggleButton size="sm" value="table" label="Table" icon={Table2} {item} />
{:else if resultKind === 'aiagent'}
<ToggleButton size="sm" value="pretty" label="Run" icon={Bot} {item} />
<ToggleButton size="sm" value="pretty" label="Pretty" icon={Bot} {item} />
{:else}
<ToggleButton size="sm" value="pretty" label="Pretty" icon={Highlighter} {item} />
{/if}
@@ -82,10 +82,10 @@ describe('agent stream', () => {
it('folds the token deltas into the answer so far', () => {
const { stream } = advanceAgentStream(events, emptyAgentStreamProgress())
expect(stream.answer).toBe('eu-central-1 is down')
expect(stream.current).toBe('eu-central-1 is down')
expect(stream.reasoning).toBe('checking')
expect(stream.tools).toEqual([
{ callId: 'c1', name: 'query_metrics', running: false, success: true }
expect(stream.entries).toEqual([
{ kind: 'tool', callId: 'c1', name: 'query_metrics', running: false, success: true }
])
})
@@ -95,22 +95,22 @@ describe('agent stream', () => {
const firstPoll = advanceAgentStream(lines.slice(0, 3).join('\n') + '\n', emptyAgentStreamProgress())
const secondPoll = advanceAgentStream(events, firstPoll)
expect(secondPoll.consumed).toBe(events.length)
expect(secondPoll.stream.answer).toBe('eu-central-1 is down')
expect(secondPoll.stream.current).toBe('eu-central-1 is down')
expect(secondPoll.stream.reasoning).toBe('checking')
})
it('leaves a half-written trailing line for the next poll', () => {
const partial = advanceAgentStream(`${events}{"type":"token_de`, emptyAgentStreamProgress())
expect(partial.stream.answer).toBe('eu-central-1 is down')
expect(partial.stream.current).toBe('eu-central-1 is down')
const completed = advanceAgentStream(`${events}{"type":"token_delta","content":"!"}\n`, partial)
expect(completed.stream.answer).toBe('eu-central-1 is down!')
expect(completed.stream.current).toBe('eu-central-1 is down!')
})
it('marks a tool still running, and a failed one', () => {
const started = '{"type":"tool_execution","call_id":"c1","function_name":"fetch"}\n'
const running = advanceAgentStream(started, emptyAgentStreamProgress())
expect(running.stream.tools).toEqual([
{ callId: 'c1', name: 'fetch', running: true, success: undefined }
expect(running.stream.entries).toEqual([
{ kind: 'tool', callId: 'c1', name: 'fetch', running: true, success: undefined }
])
const failed = advanceAgentStream(
started +
@@ -118,8 +118,8 @@ describe('agent stream', () => {
running
)
// One row for the call, not one per event about it.
expect(failed.stream.tools).toEqual([
{ callId: 'c1', name: 'fetch', running: false, success: false }
expect(failed.stream.entries).toEqual([
{ kind: 'tool', callId: 'c1', name: 'fetch', running: false, success: false }
])
})
})
@@ -144,20 +144,22 @@ describe('a stream that narrates before calling a tool', () => {
'{"type":"token_delta","content":"eu-central-1 is down."}',
''
].join('\n')
expect(advanceAgentStream(raw, emptyAgentStreamProgress()).stream.answer).toBe(
'eu-central-1 is down.'
)
const { stream } = advanceAgentStream(raw, emptyAgentStreamProgress())
expect(stream.current).toBe('eu-central-1 is down.')
// The narration became a row rather than disappearing.
expect(stream.entries[0]).toEqual({ kind: 'assistant', content: 'Let me check the metrics.' })
})
it('drops the narration at the boundary even across polls', () => {
const first = '{"type":"token_delta","content":"Let me check."}\n'
const afterCall = first + '{"type":"tool_call","call_id":"c1","function_name":"q"}\n'
const poll1 = advanceAgentStream(first, emptyAgentStreamProgress())
expect(poll1.stream.answer).toBe('Let me check.')
expect(poll1.stream.current).toBe('Let me check.')
const poll2 = advanceAgentStream(afterCall, poll1)
expect(poll2.stream.answer).toBe('')
expect(poll2.stream.current).toBe('')
expect(poll2.stream.entries[0]).toEqual({ kind: 'assistant', content: 'Let me check.' })
const poll3 = advanceAgentStream(afterCall + '{"type":"token_delta","content":"Done."}\n', poll2)
expect(poll3.stream.answer).toBe('Done.')
expect(poll3.stream.current).toBe('Done.')
})
// Bedrock has its own streaming implementation rather than the shared SSE
@@ -173,9 +175,10 @@ describe('a stream that narrates before calling a tool', () => {
'{"type":"token_delta","content":"eu-central-1 is down."}',
''
].join('\n')
expect(advanceAgentStream(raw, emptyAgentStreamProgress()).stream.answer).toBe(
'eu-central-1 is down.'
)
const { stream } = advanceAgentStream(raw, emptyAgentStreamProgress())
expect(stream.current).toBe('eu-central-1 is down.')
// The narration became a row rather than disappearing.
expect(stream.entries[0]).toEqual({ kind: 'assistant', content: 'Let me check the metrics.' })
})
})
+30 -16
View File
@@ -187,21 +187,30 @@ export function summarizeAgentResult(result: AgentResult): AgentResultSummary {
}
}
export type AgentStreamTool = { callId: string; name: string; running: boolean; success?: boolean }
export type AgentStreamEntry =
| { kind: 'assistant'; content: string }
| { kind: 'tool'; callId: string; name: string; running: boolean; success?: boolean }
export type AgentStream = {
answer: string
/**
* What the run has finished doing, in order the same rows the completed
* trace will show, so nothing on screen moves when the result lands.
*/
entries: AgentStreamEntry[]
/**
* The text of the turn being written. It is not yet the output: a turn that
* goes on to call a tool was narration, and only the run ending decides which
* this was. So it stays unlabelled here and becomes one or the other.
*/
current: string
reasoning: string
/** Every tool the run has touched, in order, so a stream shows the same rows
* the finished trace will. */
tools: AgentStreamTool[]
}
/** How much of the stream has been folded in, so the next poll starts there. */
export type AgentStreamProgress = { consumed: number; stream: AgentStream }
export function emptyAgentStreamProgress(): AgentStreamProgress {
return { consumed: 0, stream: { answer: '', reasoning: '', tools: [] } }
return { consumed: 0, stream: { entries: [], current: '', reasoning: '' } }
}
/**
@@ -276,7 +285,7 @@ export function advanceAgentStream(
if (complete <= previous.consumed) {
return previous
}
const stream: AgentStream = { ...previous.stream, tools: [...previous.stream.tools] }
const stream: AgentStream = { ...previous.stream, entries: [...previous.stream.entries] }
for (const line of raw.slice(previous.consumed, complete).split('\n')) {
if (line.trim() === '') {
continue
@@ -286,28 +295,33 @@ export function advanceAgentStream(
continue
}
if (event.type === 'token_delta' && typeof event.content === 'string') {
stream.answer += event.content
stream.current += event.content
} else if (event.type === 'reasoning_token_delta' && typeof event.content === 'string') {
stream.reasoning += event.content
} else if (typeof event.function_name === 'string') {
if (TOOL_TURN_STARTED.includes(event.type)) {
// A model can narrate and request a tool in the same turn, and the loop
// then runs again. That narration is not part of the answer — the
// finished result keeps the text of the last turn that produced any — so
// a starting call resets rather than appending to what came before.
stream.answer = ''
if (TOOL_TURN_STARTED.includes(event.type) && stream.current !== '') {
// A model can narrate and request a tool in the same turn. The call
// settles what that text was: narration, not the output. It becomes a
// row rather than being dropped, so nothing vanishes from the screen
// only to reappear when the result lands.
stream.entries.push({ kind: 'assistant', content: stream.current })
stream.current = ''
stream.reasoning = ''
}
// The same call is announced, then argued, then executed, then answered.
// Keyed on `call_id` so those four events are one row rather than four.
const callId = typeof event.call_id === 'string' ? event.call_id : event.function_name
const existing = stream.tools.find((t) => t.callId === callId)
const existing = stream.entries.find(
(e): e is Extract<AgentStreamEntry, { kind: 'tool' }> =>
e.kind === 'tool' && e.callId === callId
)
const settled = event.type === 'tool_result'
if (existing) {
existing.running = !settled
existing.success = settled ? event.success === true : existing.success
} else {
stream.tools.push({
stream.entries.push({
kind: 'tool',
callId,
name: event.function_name,
running: !settled,