fix: show an agent run's thinking instead of falling back to raw json

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-17 10:12:01 +02:00
co-authored by Claude Opus 5
parent d4067a0e40
commit e1ab81ba72
3 changed files with 33 additions and 3 deletions
@@ -3,6 +3,7 @@
import { Badge } from '$lib/components/common'
import GfmMarkdown from './GfmMarkdown.svelte'
import AgentTrace from './AgentTrace.svelte'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import LabeledDivider from './LabeledDivider.svelte'
import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte'
import { buildAgentTrace, splitFinalAnswer } from './agentTrace'
@@ -35,6 +36,8 @@
// answered itself. Its citations move down with it.
let answer = $derived(splitFinalAnswer(buildAgentTrace(result.messages), result.output))
let trace = $derived(answer.trace)
let reasoning = $derived(result.reasoning?.trim())
let reasoningExpanded = $state(false)
let anchor: HTMLElement | undefined = $state()
const sticker = createBottomSticker()
@@ -43,6 +46,7 @@
// so the end has moved from wherever the stream had the reader parked.
runKey
trace.length
reasoning
sticker.scrollToEnd(runPane(anchor))
})
</script>
@@ -50,6 +54,20 @@
<div bind:this={anchor} class="flex flex-col w-full py-3">
{#if trace.length > 0}
<AgentTrace entries={trace} {workspaceId} />
{/if}
{#if reasoning}
<!-- The whole run's thinking, which the worker hands back joined rather than
per iteration, so it reads as one block above the answer it led to. -->
<ChatCollapsibleCard
label="Thinking"
expanded={reasoningExpanded}
onToggle={() => (reasoningExpanded = !reasoningExpanded)}
contentClass="font-main"
>
<GfmMarkdown md={reasoning} prose="xs" noPadding />
</ChatCollapsibleCard>
{/if}
{#if trace.length > 0 || reasoning}
<LabeledDivider class="my-3">
<span class="text-2xs text-hint">Output</span>
</LabeledDivider>
@@ -22,6 +22,11 @@ describe('parseAgentResult', () => {
it('accepts the envelope with and without its optional keys', () => {
expect(parseAgentResult(envelope)?.output).toBe('the answer')
expect(parseAgentResult({ output: 1, messages: [{ role: 'user' }] })?.messages).toHaveLength(1)
// A key the worker serializes and the signature does not know costs every run
// carrying it the pretty display, so each one is pinned here.
expect(parseAgentResult({ ...envelope, reasoning: 'let me think' })?.reasoning).toBe(
'let me think'
)
})
// The signature is the only thing separating an agent result from any other
+10 -3
View File
@@ -31,10 +31,16 @@ export type AgentResult = {
messages: AgentMessage[]
usage?: AgentTokenUsage
wm_stream?: string
/** The model's thinking across every iteration, blank-line separated. */
reasoning?: string
}
/** Every key `AIAgentResult` can serialize. `usage` and `wm_stream` are skipped when empty. */
const ENVELOPE_KEYS = ['output', 'messages', 'usage', 'wm_stream']
/**
* Every key `AIAgentResult` can serialize; the optional ones are skipped when
* empty. The signature below is closed, so a key the worker gains and this list
* does not makes every run carrying it fall back to the raw JSON.
*/
const ENVELOPE_KEYS = ['output', 'messages', 'usage', 'wm_stream', 'reasoning']
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -129,7 +135,8 @@ export function parseAgentResult(result: unknown): AgentResult | undefined {
output: result.output,
messages: toAgentMessages(result.messages),
usage: isRecord(result.usage) ? (result.usage as AgentTokenUsage) : undefined,
wm_stream: typeof result.wm_stream === 'string' ? result.wm_stream : undefined
wm_stream: typeof result.wm_stream === 'string' ? result.wm_stream : undefined,
reasoning: typeof result.reasoning === 'string' ? result.reasoning : undefined
}
}