From 8343f92efe337520a4b6f3357793ef950ece8fd4 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 11 Sep 2026 17:31:41 +0200 Subject: [PATCH] feat: show an agent run as one scroll ending in its output Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/components/AgentResultDisplay.svelte | 66 ++++++++++++------ .../lib/components/AgentStreamDisplay.svelte | 67 ++++++++++++------- frontend/src/lib/components/AgentTrace.svelte | 9 +-- .../src/lib/components/DisplayResult.svelte | 23 ++----- frontend/src/lib/components/agentScroll.ts | 23 +++++++ .../src/lib/components/aiAgentResult.test.ts | 13 +++- frontend/src/lib/components/aiAgentResult.ts | 30 ++++++--- 7 files changed, 149 insertions(+), 82 deletions(-) create mode 100644 frontend/src/lib/components/agentScroll.ts diff --git a/frontend/src/lib/components/AgentResultDisplay.svelte b/frontend/src/lib/components/AgentResultDisplay.svelte index 935c32ebe9..7a103ee8c0 100644 --- a/frontend/src/lib/components/AgentResultDisplay.svelte +++ b/frontend/src/lib/components/AgentResultDisplay.svelte @@ -3,15 +3,15 @@ import { Badge } from '$lib/components/common' import GfmMarkdown from './GfmMarkdown.svelte' import AgentTrace from './AgentTrace.svelte' + import { buildAgentTrace } from './agentTrace' + import { scrollPaneToEnd } from './agentScroll' import { formatTokenCount, summarizeAgentResult, type AgentResult } from './aiAgentResult' interface Props { result: AgentResult - /** The answer, or what the agent did to get there; JSON is the viewer's own toggle. */ - view: 'answer' | 'trace' workspaceId?: string /** - * How to render an answer that is not text. An `output_schema` makes `output` + * How to render an output that is not text. An `output_schema` makes `output` * an object, and the right rendering for it is whatever the result viewer * would do with that object on its own — a table for rows, the file viewer * for an S3 object. Passed in rather than imported so this component does not @@ -20,32 +20,53 @@ structuredOutput: Snippet<[unknown]> } - let { result, view, workspaceId, structuredOutput }: Props = $props() + let { result, workspaceId, structuredOutput }: Props = $props() let summary = $derived(summarizeAgentResult(result)) let textOutput = $derived(typeof result.output === 'string' ? result.output : undefined) + + // The run's last message is what produced `output`, so it is dropped from the + // trace: the output block below is that same text, and printing it twice in + // one scroll reads as the agent having answered itself. + let trace = $derived.by(() => { + const entries = buildAgentTrace(result.messages) + return entries.at(-1)?.kind === 'assistant' ? entries.slice(0, -1) : entries + }) + + let anchor: HTMLElement | undefined = $state() + $effect(() => { + // Opening a run lands on its output rather than on how it got there. + anchor + scrollPaneToEnd(anchor) + }) -
- {#if view === 'trace'} - - {:else if textOutput !== undefined} - {#if textOutput === ''} - The agent returned no answer - {:else} - - - {/if} - {:else} - {@render structuredOutput(result.output)} +
+ {#if trace.length > 0} + {/if} - -
+
0 ? 'mt-4 pt-3 border-t border-border-light' : ''}> + Output +
+ {#if textOutput !== undefined} + {#if textOutput === ''} + The agent returned no answer + {:else} + + + {/if} + {:else} + {@render structuredOutput(result.output)} + {/if} +
+
+ + +
{#if summary.toolCalls > 0} {summary.toolCalls} @@ -65,4 +86,5 @@ {formatTokenCount(summary.cachedTokens)} cached {/if}
+
diff --git a/frontend/src/lib/components/AgentStreamDisplay.svelte b/frontend/src/lib/components/AgentStreamDisplay.svelte index 54fd1f8dbd..fd3d55e644 100644 --- a/frontend/src/lib/components/AgentStreamDisplay.svelte +++ b/frontend/src/lib/components/AgentStreamDisplay.svelte @@ -1,7 +1,8 @@ -
- {#if stream.tool} -
- {#if stream.tool.running} - - {:else if stream.tool.success === false} - + +
+ {#each stream.tools as tool (tool.callId)} + {}} + labelClass={tool.success === false ? 'text-red-500' : ''} + /> + {/each} + +
0 ? 'mt-4 pt-3 border-t border-border-light' : ''}> + Output +
+ + {#if stream.answer !== ''} + + {:else if stream.reasoning !== ''} + +
+ +
{/if} - {stream.tool.name}
- {/if} - - {#if stream.answer !== ''} - - {:else if stream.reasoning !== ''} - -
- -
- {/if} +
+
diff --git a/frontend/src/lib/components/AgentTrace.svelte b/frontend/src/lib/components/AgentTrace.svelte index 538f425824..1055d017d3 100644 --- a/frontend/src/lib/components/AgentTrace.svelte +++ b/frontend/src/lib/components/AgentTrace.svelte @@ -8,18 +8,15 @@ import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte' import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte' import GfmMarkdown from './GfmMarkdown.svelte' - import { buildAgentTrace, type AgentTraceEntry } from './agentTrace' - import type { AgentMessage } from './aiAgentResult' + import type { AgentTraceEntry } from './agentTrace' import { SvelteMap, SvelteSet } from 'svelte/reactivity' interface Props { - messages: AgentMessage[] + entries: AgentTraceEntry[] workspaceId?: string } - let { messages, workspaceId }: Props = $props() - - const entries = $derived(buildAgentTrace(messages)) + let { entries, workspaceId }: Props = $props() let expanded = new SvelteSet() // A tool's own job holds what the envelope does not: its logs, how long it diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index 42b21240ba..823e5b2ed4 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -20,7 +20,6 @@ Highlighter, ArrowDownFromLine, Bot, - ListTree, Database, Loader2 } from 'lucide-svelte' @@ -165,8 +164,6 @@ growVertical = false }: Props = $props() let s3FileDisplayRawMode = $state(false) - /** Which half of an agent result is showing; JSON is `forceJson`, as for any kind. */ - let agentView: 'answer' | 'trace' = $state('answer') /** What a max-iterations failure got through before it gave up, if this is one. * Empty for a run that failed before the worker tagged anything, and for one * that predates the tags reaching this payload at all — in which case the @@ -175,7 +172,7 @@ const messages = parseAgentErrorMessages(result) if (!messages) return undefined const entries = buildAgentTrace(messages) - return entries.length > 0 ? messages : undefined + return entries.length > 0 ? entries : undefined }) // Build the image/PDF source URL for an S3 object. When `appPath` is set @@ -825,26 +822,16 @@ bind:clientHeight={resultHeaderHeight} > {#if !hideAsJson && !['json', 's3object'].includes(resultKind ?? '') && typeof result === 'object'} { forceJson = ev.detail === 'json' - if (ev.detail === 'trace' || ev.detail === 'pretty') { - agentView = ev.detail === 'trace' ? 'trace' : 'answer' - } }} > {#snippet children({ item })} {#if ['table-col', 'table-row', 'table-row-object'].includes(resultKind ?? '')} {:else if resultKind === 'aiagent'} - - + {:else} {/if} @@ -1032,7 +1019,7 @@ rather than replacing it. -->
Trace - +
{/if} {#if !isTest && language === 'bun'} @@ -1294,7 +1281,7 @@ {:else if !forceJson && resultKind === 'aiagent'} {@const agentResult = parseAgentResult(result)} {#if agentResult} - + {#snippet structuredOutput(output)} node.clientHeight) { + node.scrollTop = node.scrollHeight + return + } + node = node.parentElement + } +} diff --git a/frontend/src/lib/components/aiAgentResult.test.ts b/frontend/src/lib/components/aiAgentResult.test.ts index a055e4d26b..5f8b9825c2 100644 --- a/frontend/src/lib/components/aiAgentResult.test.ts +++ b/frontend/src/lib/components/aiAgentResult.test.ts @@ -84,7 +84,9 @@ describe('agent stream', () => { const { stream } = advanceAgentStream(events, emptyAgentStreamProgress()) expect(stream.answer).toBe('eu-central-1 is down') expect(stream.reasoning).toBe('checking') - expect(stream.tool).toEqual({ name: 'query_metrics', running: false, success: true }) + expect(stream.tools).toEqual([ + { callId: 'c1', name: 'query_metrics', running: false, success: true } + ]) }) // The stream only grows, so each poll must fold in the new lines and re-read @@ -107,13 +109,18 @@ describe('agent stream', () => { 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.tool).toEqual({ name: 'fetch', running: true, success: undefined }) + expect(running.stream.tools).toEqual([ + { callId: 'c1', name: 'fetch', running: true, success: undefined } + ]) const failed = advanceAgentStream( started + '{"type":"tool_result","call_id":"c1","function_name":"fetch","result":"boom","success":false}\n', running ) - expect(failed.stream.tool).toEqual({ name: 'fetch', running: false, success: false }) + // One row for the call, not one per event about it. + expect(failed.stream.tools).toEqual([ + { callId: 'c1', name: 'fetch', running: false, success: false } + ]) }) }) diff --git a/frontend/src/lib/components/aiAgentResult.ts b/frontend/src/lib/components/aiAgentResult.ts index 19ea2419e0..9d0eb504a9 100644 --- a/frontend/src/lib/components/aiAgentResult.ts +++ b/frontend/src/lib/components/aiAgentResult.ts @@ -187,18 +187,21 @@ export function summarizeAgentResult(result: AgentResult): AgentResultSummary { } } +export type AgentStreamTool = { callId: string; name: string; running: boolean; success?: boolean } + export type AgentStream = { answer: string reasoning: string - /** The most recent tool the run touched, so a stream that is mid-call says so. */ - tool?: { name: string; running: boolean; success?: boolean } + /** 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: '' } } + return { consumed: 0, stream: { answer: '', reasoning: '', tools: [] } } } /** @@ -273,7 +276,7 @@ export function advanceAgentStream( if (complete <= previous.consumed) { return previous } - const stream: AgentStream = { ...previous.stream } + const stream: AgentStream = { ...previous.stream, tools: [...previous.stream.tools] } for (const line of raw.slice(previous.consumed, complete).split('\n')) { if (line.trim() === '') { continue @@ -295,10 +298,21 @@ export function advanceAgentStream( stream.answer = '' stream.reasoning = '' } - stream.tool = { - name: event.function_name, - running: event.type !== 'tool_result', - success: event.type === 'tool_result' ? event.success === true : undefined + // 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 settled = event.type === 'tool_result' + if (existing) { + existing.running = !settled + existing.success = settled ? event.success === true : existing.success + } else { + stream.tools.push({ + callId, + name: event.function_name, + running: !settled, + success: settled ? event.success === true : undefined + }) } } }