refactor: show an agent run as what it did, not as a conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-14 09:10:51 +02:00
co-authored by Claude Opus 5
parent 3009339e2a
commit eac3129943
6 changed files with 187 additions and 223 deletions
@@ -0,0 +1,134 @@
<script lang="ts">
import { ExternalLink, Globe } from 'lucide-svelte'
import { JobService, type Job } from '$lib/gen'
import { base } from '$lib/base'
import { workspaceStore } from '$lib/stores'
import { msToReadableTimeShort } from '$lib/utils'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte'
import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import { buildAgentActions, type AgentActionEntry } from './agentActions'
import type { AgentMessage } from './aiAgentResult'
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
interface Props {
messages: AgentMessage[]
workspaceId?: string
}
let { messages, workspaceId }: Props = $props()
const entries = $derived(buildAgentActions(messages))
let expanded = new SvelteSet<number>()
// A tool's own job holds what the envelope does not: its logs, how long it
// took, and whether it succeeded. Fetched when a row is opened rather than
// upfront, so a run with twenty calls does not issue twenty requests to draw
// a list of names.
let jobs = new SvelteMap<string, Job | 'loading' | 'failed'>()
async function loadJob(jobId: string) {
if (jobs.has(jobId)) {
return
}
jobs.set(jobId, 'loading')
try {
jobs.set(
jobId,
await JobService.getJob({ id: jobId, workspace: workspaceId ?? $workspaceStore! })
)
} catch {
// A tool job can be gone (retention) or unreadable. The row still has its
// arguments and result from the envelope, so only the extras are lost.
jobs.set(jobId, 'failed')
}
}
function toggle(index: number, entry: AgentActionEntry) {
if (expanded.delete(index)) {
return
}
expanded.add(index)
if (entry.kind === 'tool' && entry.jobId) {
loadJob(entry.jobId)
}
}
function jobOf(jobId: string | undefined): Job | undefined {
if (!jobId) return undefined
const job = jobs.get(jobId)
return typeof job === 'object' ? job : undefined
}
function toolLabel(entry: Extract<AgentActionEntry, { kind: 'tool' }>): string {
const job = jobOf(entry.jobId)
const duration = job?.['duration_ms']
return duration === undefined
? entry.name
: `${entry.name} · ${msToReadableTimeShort(duration)}`
}
</script>
<div class="flex flex-col w-full min-w-0 gap-1">
{#each entries as entry, index (index)}
{#if entry.kind === 'assistant'}
<div class="min-w-0">
<GfmMarkdown md={entry.content} noPadding />
{#if entry.sources}
<div class="mt-2">
<WebSearchSourcesDisplay sources={entry.sources} />
</div>
{/if}
</div>
{:else if entry.kind === 'search'}
<ChatCollapsibleCard
label="Web search"
expanded={expanded.has(index)}
onToggle={() => toggle(index, entry)}
>
{#if entry.sources}
<WebSearchSourcesDisplay sources={entry.sources} />
{:else}
<ToolContentDisplay title="Result" content={entry.content} />
{/if}
</ChatCollapsibleCard>
{:else}
{@const job = jobOf(entry.jobId)}
<ChatCollapsibleCard
label={toolLabel(entry)}
expanded={expanded.has(index)}
onToggle={() => toggle(index, entry)}
contentClass="space-y-3"
>
{#if entry.args}
<ToolContentDisplay title="Parameters" content={entry.args} toolName={entry.name} />
{/if}
{#if job?.logs}
<ToolContentDisplay title="Logs" content={job.logs} />
{/if}
<ToolContentDisplay
title="Result"
content={entry.result}
error={job?.type === 'CompletedJob' && !job.success ? entry.result : undefined}
/>
{#if entry.resourcePath}
<div class="text-2xs text-hint flex items-center gap-1">
<Globe size={11} />
{entry.resourcePath}
</div>
{:else if entry.jobId}
<a
class="text-2xs text-accent inline-flex items-center gap-1 w-fit hover:underline"
href="{base}/run/{entry.jobId}?workspace={workspaceId ?? $workspaceStore}"
target="_blank"
rel="noreferrer"
>
<ExternalLink size={11} />
Open job
</a>
{/if}
</ChatCollapsibleCard>
{/if}
{/each}
</div>
@@ -2,13 +2,13 @@
import type { Snippet } from 'svelte'
import { Badge } from '$lib/components/common'
import GfmMarkdown from './GfmMarkdown.svelte'
import AgentTranscript from './AgentTranscript.svelte'
import AgentActions from './AgentActions.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'
/** The answer, or what the agent did to get there; JSON is the viewer's own toggle. */
view: 'answer' | 'actions'
workspaceId?: string
/**
* How to render an answer that is not text. An `output_schema` makes `output`
@@ -27,8 +27,8 @@
</script>
<div class="flex flex-col gap-2 w-full pt-1">
{#if view === 'transcript'}
<AgentTranscript messages={result.messages} {workspaceId} />
{#if view === 'actions'}
<AgentActions messages={result.messages} {workspaceId} />
{:else if textOutput !== undefined}
{#if textOutput === ''}
<span class="text-tertiary text-xs">The agent returned no answer</span>
@@ -1,164 +0,0 @@
<script lang="ts">
import { ExternalLink, Globe } from 'lucide-svelte'
import { JobService, type Job } from '$lib/gen'
import { base } from '$lib/base'
import { workspaceStore } from '$lib/stores'
import { msToReadableTimeShort } from '$lib/utils'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte'
import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import { buildTranscript, type TranscriptEntry } from './agentTranscript'
import type { AgentMessage } from './aiAgentResult'
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
interface Props {
messages: AgentMessage[]
workspaceId?: string
}
let { messages, workspaceId }: Props = $props()
const all = $derived(buildTranscript(messages))
// The prompt is the run's configuration, not its opening line: it is the same
// on every run of the step, and reading the conversation from it buries what
// the user actually asked. Kept reachable, at the end.
const entries = $derived(all.filter((entry) => entry.kind !== 'system'))
const systemPrompt = $derived(all.find((entry) => entry.kind === 'system'))
// Out of the entry index space, since the prompt is rendered outside the list.
const SYSTEM_PROMPT_KEY = -1
let expanded = new SvelteSet<number>()
// A tool's own job holds what the envelope does not: its logs, how long it
// took, and whether it succeeded. Fetched when a row is opened rather than
// upfront, so a run with twenty calls does not issue twenty requests to draw
// a list of names.
let jobs = new SvelteMap<string, Job | 'loading' | 'failed'>()
async function loadJob(jobId: string) {
if (jobs.has(jobId)) {
return
}
jobs.set(jobId, 'loading')
try {
jobs.set(
jobId,
await JobService.getJob({ id: jobId, workspace: workspaceId ?? $workspaceStore! })
)
} catch {
// A tool job can be gone (retention) or unreadable. The row still has its
// arguments and result from the envelope, so only the extras are lost.
jobs.set(jobId, 'failed')
}
}
function toggle(index: number, entry: TranscriptEntry) {
if (expanded.delete(index)) {
return
}
expanded.add(index)
if (entry.kind === 'tool' && entry.jobId) {
loadJob(entry.jobId)
}
}
function jobOf(jobId: string | undefined): Job | undefined {
if (!jobId) return undefined
const job = jobs.get(jobId)
return typeof job === 'object' ? job : undefined
}
function toolLabel(entry: Extract<TranscriptEntry, { kind: 'tool' }>): string {
const job = jobOf(entry.jobId)
const duration = job?.['duration_ms']
return duration === undefined ? entry.name : `${entry.name} · ${msToReadableTimeShort(duration)}`
}
</script>
<div class="flex flex-col w-full min-w-0">
{#each entries as entry, index (index)}
{#if entry.kind === 'user'}
<!-- The session chat's user bubble, so a run reads the same here as it does
in a chat. Read-only: there is nothing to edit on a finished run. -->
<div class="py-1 px-2 mt-4 mb-6 first:mt-0">
<div
class="text-xs px-3 py-2 w-fit max-w-[min(32rem,100%)] bg-surface-accent-selected text-accent rounded-lg whitespace-pre-wrap break-words"
>
{entry.content}
</div>
</div>
{:else if entry.kind === 'assistant'}
<div class="py-1 px-2 min-w-0">
<GfmMarkdown md={entry.content} noPadding />
{#if entry.sources}
<div class="mt-2">
<WebSearchSourcesDisplay sources={entry.sources} />
</div>
{/if}
</div>
{:else if entry.kind === 'search'}
<div class="px-2 mb-1">
<ChatCollapsibleCard
label="Web search"
expanded={expanded.has(index)}
onToggle={() => toggle(index, entry)}
>
{#if entry.sources}
<WebSearchSourcesDisplay sources={entry.sources} />
{:else}
<ToolContentDisplay title="Result" content={entry.content} />
{/if}
</ChatCollapsibleCard>
</div>
{:else}
{@const job = jobOf(entry.jobId)}
<div class="px-2 mb-1">
<ChatCollapsibleCard
label={toolLabel(entry)}
expanded={expanded.has(index)}
onToggle={() => toggle(index, entry)}
contentClass="space-y-3"
>
{#if entry.args}
<ToolContentDisplay title="Parameters" content={entry.args} toolName={entry.name} />
{/if}
{#if job?.logs}
<ToolContentDisplay title="Logs" content={job.logs} />
{/if}
<ToolContentDisplay
title="Result"
content={entry.result}
error={job?.type === 'CompletedJob' && !job.success ? entry.result : undefined}
/>
{#if entry.resourcePath}
<div class="text-2xs text-hint flex items-center gap-1">
<Globe size={11} />
{entry.resourcePath}
</div>
{:else if entry.jobId}
<a
class="text-2xs text-accent inline-flex items-center gap-1 w-fit hover:underline"
href="{base}/run/{entry.jobId}?workspace={workspaceId ?? $workspaceStore}"
target="_blank"
rel="noreferrer"
>
<ExternalLink size={11} />
Open job
</a>
{/if}
</ChatCollapsibleCard>
</div>
{/if}
{/each}
{#if systemPrompt}
<div class="px-2 pt-2">
<ChatCollapsibleCard
label="System prompt"
expanded={expanded.has(SYSTEM_PROMPT_KEY)}
onToggle={() => toggle(SYSTEM_PROMPT_KEY, systemPrompt)}
>
<div class="whitespace-pre-wrap text-2xs text-primary">{systemPrompt.content}</div>
</ChatCollapsibleCard>
</div>
{/if}
</div>
@@ -20,7 +20,7 @@
Highlighter,
ArrowDownFromLine,
Bot,
MessagesSquare,
ListTree,
Database,
Loader2
} from 'lucide-svelte'
@@ -58,7 +58,7 @@
import type { MarkupTrust } from './apps/markupTrust'
import AgentResultDisplay from './AgentResultDisplay.svelte'
import AgentStreamDisplay from './AgentStreamDisplay.svelte'
import AgentTranscript from './AgentTranscript.svelte'
import AgentActions from './AgentActions.svelte'
import { isAgentStream, parseAgentErrorMessages, parseAgentResult } from './aiAgentResult'
const TABLE_MAX_SIZE = 5000000
@@ -165,7 +165,7 @@
}: 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')
let agentView: 'answer' | 'actions' = $state('answer')
/** The partial conversation a max-iterations failure carries, if this is one. */
let agentErrorMessages = $derived(parseAgentErrorMessages(result))
@@ -308,7 +308,7 @@
}
// Classified before the size caps below: an agent's answer stays small
// however long its conversation grows, so a run with a big transcript
// however long its conversation grows, so a run with many actions
// must not fall back to the JSON tree that hides the answer inside it.
// `largeObject` is still set honestly, so switching to JSON gets the
// same too-big handling as any other oversized result.
@@ -818,15 +818,15 @@
{#if !hideAsJson && !['json', 's3object'].includes(resultKind ?? '') && typeof result === 'object'}<ToggleButtonGroup
selected={forceJson
? 'json'
: agentView === 'transcript' && resultKind === 'aiagent'
? 'transcript'
: agentView === 'actions' && resultKind === 'aiagent'
? 'actions'
: resultKind?.startsWith('table-')
? 'table'
: 'pretty'}
on:selected={(ev) => {
forceJson = ev.detail === 'json'
if (ev.detail === 'transcript' || ev.detail === 'pretty') {
agentView = ev.detail === 'transcript' ? 'transcript' : 'answer'
if (ev.detail === 'actions' || ev.detail === 'pretty') {
agentView = ev.detail === 'actions' ? 'actions' : 'answer'
}
}}
>
@@ -835,13 +835,7 @@
<ToggleButton size="sm" value="table" label="Table" icon={Table2} {item} />
{:else if resultKind === 'aiagent'}
<ToggleButton size="sm" value="pretty" label="Answer" icon={Bot} {item} />
<ToggleButton
size="sm"
value="transcript"
label="Transcript"
icon={MessagesSquare}
{item}
/>
<ToggleButton size="sm" value="actions" label="Actions" icon={ListTree} {item} />
{:else}
<ToggleButton size="sm" value="pretty" label="Pretty" icon={Highlighter} {item} />
{/if}
@@ -1024,12 +1018,12 @@
</div>
{#if agentErrorMessages}
<!-- A run stopped by max_iterations fails, so the error above is what it
returned. The conversation it got through rides inside that error and
is the whole reason to look at such a run, so it is added under the
error rather than replacing it. -->
returned. What it managed to do rides inside that error and is the
whole reason to look at such a run, so it is added under the error
rather than replacing it. -->
<div class="flex flex-col gap-1 pt-4 w-full min-w-0">
<span class="text-emphasis text-xs font-semibold">Transcript</span>
<AgentTranscript messages={agentErrorMessages} {workspaceId} />
<span class="text-emphasis text-xs font-semibold">Actions</span>
<AgentActions messages={agentErrorMessages} {workspaceId} />
</div>
{/if}
{#if !isTest && language === 'bun'}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { buildTranscript } from './agentTranscript'
import { buildAgentActions } from './agentActions'
import type { AgentMessage } from './aiAgentResult'
// The worker splits one tool call across two messages: the assistant message
@@ -34,11 +34,9 @@ const messages: AgentMessage[] = [
{ role: 'assistant', content: 'eu-central-1 is down.', agent_action: { type: 'message' } }
]
describe('buildTranscript', () => {
describe('buildAgentActions', () => {
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?' },
expect(buildAgentActions(messages)).toEqual([
{
kind: 'tool',
name: 'query_metrics',
@@ -51,7 +49,7 @@ describe('buildTranscript', () => {
})
it('keeps an MCP call, whose arguments live on the action itself', () => {
const entries = buildTranscript([
const entries = buildAgentActions([
{
role: 'tool',
content: 'sunny',
@@ -76,7 +74,7 @@ describe('buildTranscript', () => {
})
it('carries web search citations onto the entry', () => {
const entries = buildTranscript([
const entries = buildAgentActions([
{
role: 'assistant',
content: 'Postgres 17 changed the default.',
@@ -93,13 +91,17 @@ describe('buildTranscript', () => {
])
})
// 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', () => {
// The prompt and the question are the step's inputs, shown as inputs. A replayed
// turn comes back from memory without its tag, and crediting this run with an
// answer a previous one gave would be a lie about what happened.
it('keeps only what this run did', () => {
expect(
buildTranscript([
buildAgentActions([
{ role: 'system', content: 'You are an SRE assistant.' },
{ role: 'user', content: 'Which region is broken?' },
{ role: 'assistant', content: 'Answered in an earlier turn, replayed from memory.' },
{ role: 'assistant', tool_calls: [{ id: 'c1', function: { name: 'x', arguments: '{}' } }] },
{ role: 'assistant', content: '' }
{ role: 'assistant', content: '', agent_action: { type: 'message' } }
])
).toEqual([])
})
@@ -2,15 +2,17 @@ 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.
* One thing an agent did. 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 the list renders without waiting on any
* request. A tool's child job is enrichment (logs, duration, whether it
* succeeded), not what makes the row.
*
* The prompt and the user's question are deliberately absent. They are inputs to
* the step, shown as inputs, and a run is not a conversation the viewer is part
* of it is a record of what the agent did with them.
*/
export type TranscriptEntry =
| { kind: 'system'; content: string }
| { kind: 'user'; content: string }
export type AgentActionEntry =
| { kind: 'assistant'; content: string; sources?: WebSearchSource[] }
| { kind: 'search'; content: string; sources?: WebSearchSource[] }
| {
@@ -52,7 +54,7 @@ function sourcesOf(message: AgentMessage): WebSearchSource[] | undefined {
return sources.length > 0 ? sources : undefined
}
export function buildTranscript(messages: AgentMessage[]): TranscriptEntry[] {
export function buildAgentActions(messages: AgentMessage[]): AgentActionEntry[] {
// 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`.
@@ -65,7 +67,7 @@ export function buildTranscript(messages: AgentMessage[]): TranscriptEntry[] {
}
}
const entries: TranscriptEntry[] = []
const entries: AgentActionEntry[] = []
for (const message of messages) {
const action = message.agent_action
if (action?.type === 'tool_call') {
@@ -98,23 +100,19 @@ export function buildTranscript(messages: AgentMessage[]): TranscriptEntry[] {
})
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 === '') {
// Every message this run produced is tagged, including the agent narrating
// its next move and its final answer. Untagged ones are the prompt, the
// question, or a previous turn replayed out of memory — history loses its
// tags on the way back, and attributing it to this run would credit it with
// answers it never gave.
if (action?.type !== 'message') {
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') {
const content = contentText(message.content)
if (message.role === 'assistant' && content !== '') {
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] : []))
}