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'}
+
+
+ {: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