diff --git a/frontend/src/lib/components/AgentResultDisplay.svelte b/frontend/src/lib/components/AgentResultDisplay.svelte index 7a103ee8c0..b5015b1b4a 100644 --- a/frontend/src/lib/components/AgentResultDisplay.svelte +++ b/frontend/src/lib/components/AgentResultDisplay.svelte @@ -41,7 +41,7 @@ }) -
+
{#if trace.length > 0} {/if} diff --git a/frontend/src/lib/components/AgentStreamDisplay.svelte b/frontend/src/lib/components/AgentStreamDisplay.svelte index fd3d55e644..b95cfe73f8 100644 --- a/frontend/src/lib/components/AgentStreamDisplay.svelte +++ b/frontend/src/lib/components/AgentStreamDisplay.svelte @@ -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) }) - -
- {#each stream.tools as tool (tool.callId)} - {}} - labelClass={tool.success === false ? 'text-red-500' : ''} - /> + +
+ {#each stream.entries as entry, index (entry.kind === 'tool' ? entry.callId : index)} + {#if entry.kind === 'tool'} + {}} + labelClass={entry.success === false ? 'text-red-500' : ''} + /> + {:else} +
+ +
+ {/if} {/each} -
0 ? 'mt-4 pt-3 border-t border-border-light' : ''}> - Output -
- - {#if stream.answer !== ''} - - {:else if stream.reasoning !== ''} - -
- -
- {/if} + {#if stream.current !== ''} +
+ +
-
+ {:else if stream.reasoning !== ''} + +
+ +
+ {/if}
diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index 823e5b2ed4..ef92c37103 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -831,7 +831,7 @@ {#if ['table-col', 'table-row', 'table-row-object'].includes(resultKind ?? '')} {:else if resultKind === 'aiagent'} - + {:else} {/if} diff --git a/frontend/src/lib/components/aiAgentResult.test.ts b/frontend/src/lib/components/aiAgentResult.test.ts index 5f8b9825c2..9cb4983970 100644 --- a/frontend/src/lib/components/aiAgentResult.test.ts +++ b/frontend/src/lib/components/aiAgentResult.test.ts @@ -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.' }) }) }) diff --git a/frontend/src/lib/components/aiAgentResult.ts b/frontend/src/lib/components/aiAgentResult.ts index 9d0eb504a9..64b0621d3a 100644 --- a/frontend/src/lib/components/aiAgentResult.ts +++ b/frontend/src/lib/components/aiAgentResult.ts @@ -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 => + 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,