From a04ec302917b5629753d87bc17afd9000fc71289 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 11 Sep 2026 11:26:12 +0200 Subject: [PATCH] feat: add a transcript view of an agent run's conversation Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/components/AgentResultDisplay.svelte | 10 +- .../src/lib/components/AgentTranscript.svelte | 156 ++++++++++++++++++ .../src/lib/components/DisplayResult.svelte | 23 ++- .../lib/components/agentTranscript.test.ts | 106 ++++++++++++ .../src/lib/components/agentTranscript.ts | 120 ++++++++++++++ frontend/src/lib/components/aiAgentResult.ts | 19 +++ 6 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/components/AgentTranscript.svelte create mode 100644 frontend/src/lib/components/agentTranscript.test.ts create mode 100644 frontend/src/lib/components/agentTranscript.ts diff --git a/frontend/src/lib/components/AgentResultDisplay.svelte b/frontend/src/lib/components/AgentResultDisplay.svelte index 34a96762a9..237f85854a 100644 --- a/frontend/src/lib/components/AgentResultDisplay.svelte +++ b/frontend/src/lib/components/AgentResultDisplay.svelte @@ -3,10 +3,14 @@ import type { Snippet } from 'svelte' import { Badge } from '$lib/components/common' import GfmMarkdown from './GfmMarkdown.svelte' + import AgentTranscript from './AgentTranscript.svelte' import { formatTokenCount, summarizeAgentResult, type AgentResult } from './aiAgentResult' interface Props { result: AgentResult + /** Answer or the conversation behind it; JSON is the viewer's own toggle. */ + view: 'answer' | 'transcript' + workspaceId?: string /** * How to render an answer that is not text. An `output_schema` makes `output` * an object, and the right rendering for it is whatever the result viewer @@ -17,7 +21,7 @@ structuredOutput: Snippet<[unknown]> } - let { result, structuredOutput }: Props = $props() + let { result, view, workspaceId, structuredOutput }: Props = $props() let summary = $derived(summarizeAgentResult(result)) let textOutput = $derived(typeof result.output === 'string' ? result.output : undefined) @@ -48,7 +52,9 @@ {/if} - {#if textOutput !== undefined} + {#if view === 'transcript'} + + {:else if textOutput !== undefined} {#if textOutput === ''} The agent returned no answer {:else} diff --git a/frontend/src/lib/components/AgentTranscript.svelte b/frontend/src/lib/components/AgentTranscript.svelte new file mode 100644 index 0000000000..8c1b8ba02b --- /dev/null +++ b/frontend/src/lib/components/AgentTranscript.svelte @@ -0,0 +1,156 @@ + + +
+ {#each entries as entry, index (index)} + {#if entry.kind === 'user'} + +
+
+ {entry.content} +
+
+ {:else if entry.kind === 'assistant'} +
+ + {#if entry.sources} +
+ +
+ {/if} +
+ {:else if entry.kind === 'system'} +
+ toggle(index, entry)} + > +
{entry.content}
+
+
+ {:else if entry.kind === 'search'} +
+ toggle(index, entry)} + > + {#if entry.sources} + + {:else} + + {/if} + +
+ {:else} + {@const job = jobOf(entry.jobId)} +
+ toggle(index, entry)} + contentClass="space-y-3" + > + {#if entry.args} + + {/if} + {#if job?.logs} + + {/if} + + {#if entry.resourcePath} +
+ + {entry.resourcePath} +
+ {:else if entry.jobId} + + + Open job + + {/if} +
+
+ {/if} + {/each} +
diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index 6a8e450f17..038f2ed726 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -20,6 +20,7 @@ Highlighter, ArrowDownFromLine, Bot, + MessagesSquare, Database, Loader2 } from 'lucide-svelte' @@ -162,6 +163,8 @@ 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' | 'transcript' = $state('answer') // Build the image/PDF source URL for an S3 object. When `appPath` is set // (deployed app view) the read is authorized on-behalf of the app author via @@ -810,9 +813,18 @@ bind:clientHeight={resultHeaderHeight} > {#if !hideAsJson && !['json', 's3object'].includes(resultKind ?? '') && typeof result === 'object'} { forceJson = ev.detail === 'json' + if (ev.detail === 'transcript' || ev.detail === 'pretty') { + agentView = ev.detail === 'transcript' ? 'transcript' : 'answer' + } }} > {#snippet children({ item })} @@ -820,6 +832,13 @@ {:else if resultKind === 'aiagent'} + {:else} {/if} @@ -1259,7 +1278,7 @@ {:else if !forceJson && resultKind === 'aiagent'} {@const agentResult = parseAgentResult(result)} {#if agentResult} - + {#snippet structuredOutput(output)} { + it('joins a tool call to the arguments on the message that requested it', () => { + expect(buildTranscript(messages)).toEqual([ + { kind: 'system', content: 'You are an SRE assistant.' }, + { kind: 'user', content: 'Which region is broken?' }, + { + kind: 'tool', + name: 'query_metrics', + args: '{"w":"30m"}', + result: '{"eu-central-1":0.184}', + jobId: '0199-job' + }, + { kind: 'assistant', content: 'eu-central-1 is down.', sources: undefined } + ]) + }) + + it('keeps an MCP call, whose arguments live on the action itself', () => { + const entries = buildTranscript([ + { + role: 'tool', + content: 'sunny', + agent_action: { + type: 'mcp_tool_call', + call_id: 'c1', + function_name: 'get_weather', + resource_path: 'f/mcp/weather', + arguments: { city: 'Paris' } + } + } + ]) + expect(entries).toEqual([ + { + kind: 'tool', + name: 'get_weather', + args: '{\n "city": "Paris"\n}', + result: 'sunny', + resourcePath: 'f/mcp/weather' + } + ]) + }) + + it('carries web search citations onto the entry', () => { + const entries = buildTranscript([ + { + role: 'assistant', + content: 'Postgres 17 changed the default.', + annotations: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }], + agent_action: { type: 'web_search' } + } + ]) + expect(entries).toEqual([ + { + kind: 'search', + content: 'Postgres 17 changed the default.', + sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }] + } + ]) + }) + + // Memory replays messages back without their tags, and an assistant message + // that only asked for a tool has no text of its own. + it('drops messages with nothing to show', () => { + expect( + buildTranscript([ + { role: 'assistant', tool_calls: [{ id: 'c1', function: { name: 'x', arguments: '{}' } }] }, + { role: 'assistant', content: '' } + ]) + ).toEqual([]) + }) +}) diff --git a/frontend/src/lib/components/agentTranscript.ts b/frontend/src/lib/components/agentTranscript.ts new file mode 100644 index 0000000000..7ccb570b68 --- /dev/null +++ b/frontend/src/lib/components/agentTranscript.ts @@ -0,0 +1,120 @@ +import type { WebSearchSource } from './copilot/chat/shared' +import type { AgentMessage } from './aiAgentResult' + +/** + * One readable turn of an agent run. Built from the envelope alone: the tool + * arguments come from the assistant message that asked for the call, and the + * result from the `tool` message that answered it, so a transcript renders + * without waiting on any request. A tool's child job is enrichment (logs, + * duration, whether it succeeded), not what makes the row. + */ +export type TranscriptEntry = + | { kind: 'system'; content: string } + | { kind: 'user'; content: string } + | { kind: 'assistant'; content: string; sources?: WebSearchSource[] } + | { kind: 'search'; content: string; sources?: WebSearchSource[] } + | { + kind: 'tool' + name: string + args?: string + result: string + /** Present for a flow-module tool, which runs as its own job. */ + jobId?: string + /** An MCP tool runs in the worker, so it names its server instead. */ + resourcePath?: string + } + +/** `content` is a string for text messages and a part list once images are involved. */ +function contentText(content: unknown): string { + if (typeof content === 'string') { + return content + } + if (Array.isArray(content)) { + return content + .map((part) => + part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string' + ? (part as { text: string }).text + : '' + ) + .join('') + } + return '' +} + +function sourcesOf(message: AgentMessage): WebSearchSource[] | undefined { + const annotations = message.annotations + if (!annotations?.length) { + return undefined + } + const sources = annotations + .filter((a) => typeof a?.url === 'string') + .map((a) => ({ url: a.url, title: a.title })) + return sources.length > 0 ? sources : undefined +} + +export function buildTranscript(messages: AgentMessage[]): TranscriptEntry[] { + // The arguments live on the assistant message that requested the call, while + // the action tag and the result live on the `tool` message answering it, so + // the two are joined by `tool_call_id`. + const argsByCallId = new Map() + for (const message of messages) { + for (const call of message.tool_calls ?? []) { + if (call.id && typeof call.function?.arguments === 'string') { + argsByCallId.set(call.id, call.function.arguments) + } + } + } + + const entries: TranscriptEntry[] = [] + for (const message of messages) { + const action = message.agent_action + if (action?.type === 'tool_call') { + entries.push({ + kind: 'tool', + name: action.function_name, + args: message.tool_call_id ? argsByCallId.get(message.tool_call_id) : undefined, + result: contentText(message.content), + jobId: action.job_id + }) + continue + } + if (action?.type === 'mcp_tool_call') { + entries.push({ + kind: 'tool', + name: action.function_name, + // An MCP call records its arguments on the action itself: it never + // became a job, so there is nowhere else for them to live. + args: action.arguments ? JSON.stringify(action.arguments, null, 2) : undefined, + result: contentText(message.content), + resourcePath: action.resource_path + }) + continue + } + if (action?.type === 'web_search') { + entries.push({ + kind: 'search', + content: contentText(message.content), + sources: sourcesOf(message) + }) + continue + } + // Messages with no action are the conversation itself: the prompt, what the + // user asked, and anything loaded back from memory. + const content = contentText(message.content) + if (content === '') { + continue + } + if (message.role === 'system') { + entries.push({ kind: 'system', content }) + } else if (message.role === 'user') { + entries.push({ kind: 'user', content }) + } else if (message.role === 'assistant') { + entries.push({ kind: 'assistant', content, sources: sourcesOf(message) }) + } + } + return entries +} + +export function transcriptJobIds(entries: TranscriptEntry[]): string[] { + return entries.flatMap((entry) => (entry.kind === 'tool' && entry.jobId ? [entry.jobId] : [])) +} diff --git a/frontend/src/lib/components/aiAgentResult.ts b/frontend/src/lib/components/aiAgentResult.ts index 83173ae6b9..758e29c300 100644 --- a/frontend/src/lib/components/aiAgentResult.ts +++ b/frontend/src/lib/components/aiAgentResult.ts @@ -85,6 +85,25 @@ export function parseAgentResult(result: unknown): AgentResult | undefined { } } +/** + * A run stopped by `max_iterations` fails, so it returns an error rather than an + * envelope — but the worker attaches the conversation so far to it. That partial + * transcript is the whole reason to look at a run that hit the cap. + */ +export function parseAgentErrorMessages(result: unknown): AgentMessage[] | undefined { + if (!isRecord(result) || !isRecord(result.error)) { + return undefined + } + const inner = result.error.result + if (!isRecord(inner) || !Array.isArray(inner.messages)) { + return undefined + } + if (inner.messages.length === 0 || !inner.messages.every(hasRole)) { + return undefined + } + return inner.messages as AgentMessage[] +} + export type AgentResultSummary = { toolCalls: number webSearches: number