+
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}
+
+
+
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. -->
{/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
+ })
}
}
}