feat(desktop): surface the system prompt and injected context in trace

A traced session already recorded everything needed to explain a model
request, but the presentation kept it out of reach. The system prompt lived
inside each individual call, so reading it meant picking a call first. The
context the harness assembles — CLAUDE.md, system-reminder blocks,
deferred-tool rosters — was indistinguishable from what the person typed,
because the provider receives all of it as user-role text.

- The session overview names the system prompt and tool catalog, read once
  from the first model call rather than hunted down per request. This is the
  opening header, not a session-wide invariant: late tool registration and a
  mid-session model change rewrite it for later requests, which keep their own
  header on their own detail.
- A model call's detail separates injected context from the exchange, one row
  per injection labelled by its own content instead of its wrapper tag.
- Tool rows carry their input summary and model rows their token counts, so
  scanning the tree distinguishes one call from the next.
- An assistant turn that only reasoned says so, including when the provider
  withheld the reasoning, instead of rendering as a bare label.

Recognition of injected context is by wrapper tag against a closed list.
Position cannot stand in for it: hoistToolResults moves every tool result to
the front of a merged user message, so an attachment the harness appended and
an instruction typed after interrupting a tool arrive in the same shape. The
ambiguity is resolved toward the conversation — unrecognized text stays the
person's message, because looking for what you said and not finding it is the
worse failure.

requestParse also learns the OpenAI Responses wire format, which about a
quarter of traced sessions use and which previously yielded an empty message
list and no system prompt. Responses splits a tool round trip into sibling
function_call / function_call_output entries; across 150 real trace files
those outnumber plain messages 1199 to 878, so they are mapped onto the
tool_use / tool_result vocabulary rather than skipped. Its flat tool
`parameters` and `instructions` spellings are read too, the latter with `||`
so an empty `system` array cannot shadow it.

All new surfaces use design tokens, so the six themes need no per-theme work.

Claude-Session: https://claude.ai/code/session_0119s8U5VzUvVpNgWA7B3pSg
This commit is contained in:
程序员阿江(Relakkes)
2026-08-24 03:25:09 +08:00
parent a834434284
commit 66206cd8a3
14 changed files with 819 additions and 18 deletions
+9 -1
View File
@@ -83,7 +83,15 @@ function DetailBody({
case 'event':
return <EventDetail span={span} />
default:
return <SessionOverview span={span} viewModel={viewModel} onSelect={onSelect} />
return (
<SessionOverview
span={span}
viewModel={viewModel}
onSelect={onSelect}
sessionId={sessionId}
revisionKey={revisionKey}
/>
)
}
}
+53 -5
View File
@@ -7,7 +7,7 @@ import { SearchField } from '@/components/ui/SearchField'
import { SegmentedControl } from '@/components/ui/SegmentedControl'
import { useTranslation } from '../../i18n'
import { previewTraceValue, type TraceSpan, type TraceViewModel } from '../../lib/traceViewModel'
import { formatDurationMs } from '../../lib/trace/formatters'
import { formatDurationMs, formatTokenCount } from '../../lib/trace/formatters'
import { StatusGlyph, TypeIcon, spanDisplayTitle, turnDisplayTitle } from './TraceBadges'
export type TraceTreeFilter = 'all' | 'llm' | 'tool' | 'error'
@@ -207,8 +207,9 @@ function TurnGroup({
function TreeRowButton({ row, selected, onSelect }: { row: TreeRow; selected: boolean; onSelect: () => void }) {
const t = useTranslation()
const span = row.span
const preview = rowPreview(span)
const preview = rowPreview(span, t)
const duration = span.durationMs !== undefined ? formatDurationMs(span.durationMs) : null
const tokens = rowTokens(span)
return (
<button
@@ -242,6 +243,9 @@ function TreeRowButton({ row, selected, onSelect }: { row: TreeRow; selected: bo
</span>
) : null}
</span>
{tokens ? (
<span className="shrink-0 font-mono text-[11.5px] text-[var(--color-text-tertiary)]">{tokens}</span>
) : null}
{duration ? (
<span className="shrink-0 font-mono text-[12px] text-[var(--color-text-tertiary)]">{duration}</span>
) : null}
@@ -250,14 +254,58 @@ function TreeRowButton({ row, selected, onSelect }: { row: TreeRow; selected: bo
)
}
function rowPreview(span: TraceSpan): string | null {
if (span.kind === 'message' || span.kind === 'event') {
function rowPreview(span: TraceSpan, t: ReturnType<typeof useTranslation>): string | null {
// A tool row's subtitle is its summarized input, which is what distinguishes
// one Bash call from the next; without it every tool row reads the same.
if (span.kind === 'message' || span.kind === 'event' || span.kind === 'tool') {
const preview = span.subtitle
return preview && preview !== 'empty' ? preview : null
if (preview && preview !== 'empty') return preview
// An assistant turn that only reasoned has no extractable text, which would
// otherwise leave the row as a bare "Assistant" label with nothing on it.
return span.kind === 'message' ? reasoningPreview(span.message?.content, t) : null
}
return null
}
const REASONING_PREVIEW_MAX = 120
/**
* What an assistant turn did when it produced no visible text. Readable
* reasoning is previewed; provider reasoning the harness stores encoded is
* named rather than dumped, since its payload is not text a reader can use.
*/
function reasoningPreview(content: unknown, t: ReturnType<typeof useTranslation>): string | null {
if (!Array.isArray(content)) return null
let redacted = false
for (const block of content) {
if (!block || typeof block !== 'object') continue
const record = block as { type?: unknown; thinking?: unknown }
if (record.type === 'redacted_thinking') {
redacted = true
continue
}
if (record.type !== 'thinking' || typeof record.thinking !== 'string') continue
const text = record.thinking.replace(/\s+/g, ' ').trim()
if (text.length === 0) continue
return text.length > REASONING_PREVIEW_MAX ? `${text.slice(0, REASONING_PREVIEW_MAX)}…` : text
}
return redacted ? t('trace.message.redactedReasoning') : null
}
/**
* Compact token counts for a model call, so cost reads off the row itself.
* Counts only `inputTokens`, matching the detail header's `formatUsageBrief`
* and the session overview — a row that added cached tokens here would
* contradict both the moment prompt caching is on.
*/
function rowTokens(span: TraceSpan): string | null {
if (span.kind !== 'llm') return null
const usage = span.call?.usage
if (!usage) return null
if (usage.inputTokens === 0 && usage.outputTokens === 0) return null
return `${formatTokenCount(usage.inputTokens)}↑ ${formatTokenCount(usage.outputTokens)}↓`
}
function turnPreview(turnSpan: TraceSpan, t: ReturnType<typeof useTranslation>): string {
return turnDisplayTitle(turnSpan.title, (turnSpan.turnIndex ?? 0) + 1, t)
}
@@ -5,6 +5,7 @@ import type { TraceSpan } from '../../../lib/traceViewModel'
import { formatTraceJson } from '../../../lib/traceViewModel'
import { fetchTraceCallDetail } from '../../../lib/trace/callCache'
import { parseTraceRequestBody, parseTraceResponseBody } from '../../../lib/trace/requestParse'
import { splitRequestMessages, type LocatedInjection } from '../../../lib/trace/semanticTimeline'
import type { NormalizedMessage } from '../../../lib/trace/types'
import { formatBytes } from '../../../lib/formatBytes'
import { CodeViewer } from '../../chat/CodeViewer'
@@ -68,6 +69,14 @@ export function LlmCallDetail({
}
}, [effectiveCall])
// The harness sends its assembled context as user-role text. Reading it
// beside the real exchange is what makes a request legible, so the two are
// split apart rather than rendered as one message list.
const split = useMemo(
() => splitRequestMessages(parsed.request?.messages ?? []),
[parsed.request],
)
if (!call || !effectiveCall) return null
const loadingDetail = isTerminal && (!detail || detail.id !== callId) && !fetchFailed
@@ -99,14 +108,25 @@ export function LlmCallDetail({
/>
</Section>
{parsed.request && parsed.request.messages.length > 0 ? (
{split.injections.length > 0 ? (
<Section
sectionKey="llm.context"
title={t('trace.section.context')}
badge={split.injections.length}
defaultOpen
>
<ContextInjectionList injections={split.injections} />
</Section>
) : null}
{split.conversation.length > 0 ? (
<Section
sectionKey="llm.messages"
title={t('trace.section.messages')}
badge={parsed.request.messages.length}
badge={split.conversation.length}
defaultOpen
>
<MessageList messages={parsed.request.messages} />
<MessageList messages={split.conversation} />
</Section>
) : null}
@@ -262,6 +282,57 @@ function MessageList({ messages }: { messages: NormalizedMessage[] }) {
)
}
const CONTEXT_KIND_LABEL = {
'system-reminder': 'trace.context.systemReminder',
'deferred-tools': 'trace.context.deferredTools',
other: 'trace.context.other',
} as const satisfies Record<LocatedInjection['kind'], string>
/**
* The context the harness assembled for this request, one row per injection.
* Collapsed rows carry the kind and size so a scan shows what was added
* without opening anything.
*/
function ContextInjectionList({ injections }: { injections: LocatedInjection[] }) {
const t = useTranslation()
// Keyed by content, not position: a live session re-renders on every poll and
// an index would then point at whichever injection landed in that slot.
const [expanded, setExpanded] = useState<string | null>(null)
return (
<div className="flex flex-col gap-1.5">
{injections.map((injection) => {
const key = `${injection.messageIndex}:${injection.kind}:${injection.label}`
const open = expanded === key
return (
<div key={key} className="rounded-[var(--radius-md)] border border-[var(--color-border)]">
<button
type="button"
onClick={() => setExpanded((current) => (current === key ? null : key))}
aria-expanded={open}
className="flex w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-container)]"
>
<span className="shrink-0 rounded-full bg-[var(--color-surface-container)] px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)]">
{t(CONTEXT_KIND_LABEL[injection.kind])}
</span>
<span className="min-w-0 flex-1 truncate text-[12.5px] text-[var(--color-text-primary)]">
{injection.label}
</span>
<span className="shrink-0 font-mono text-[11px] text-[var(--color-text-tertiary)]">
{t('trace.detail.chars', { count: injection.text.length })}
</span>
</button>
{open ? (
<pre className="max-h-[320px] overflow-y-auto whitespace-pre-wrap break-words border-t border-[var(--color-border)] px-2.5 py-2 text-[12.5px] leading-[1.7] text-[var(--color-text-secondary)]">
{injection.text}
</pre>
) : null}
</div>
)
})}
</div>
)
}
function ToolDefinitions({ tools }: { tools: Array<{ name: string; description?: string; schema?: unknown }> }) {
const [expanded, setExpanded] = useState<string | null>(null)
const active = tools.find((tool) => tool.name === expanded)
@@ -1,8 +1,104 @@
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from '../../../i18n'
import type { TraceSpan, TraceViewModel } from '../../../lib/traceViewModel'
import { formatDurationMs, formatTokenCount } from '../../../lib/trace/formatters'
import { fetchTraceCallDetail } from '../../../lib/trace/callCache'
import { parseTraceRequestBody } from '../../../lib/trace/requestParse'
import { StatusGlyph, TypeIcon, spanDisplayTitle } from '../TraceBadges'
import { Section } from './Section'
/**
* The model-visible request header as the session opened, read from the first
* model call so it does not have to be hunted down inside an individual one.
* Only that call is fetched, because session-level snapshots truncate request
* bodies.
*
* It is the opening header, not a session-wide invariant: late tool
* registration and a mid-session model change both rewrite the system prompt
* and tool catalog for later requests. A specific request's own header stays on
* that request's detail.
*/
function SessionRequestHeader({
viewModel,
sessionId,
revisionKey,
}: {
viewModel: TraceViewModel
sessionId?: string
revisionKey?: string
}) {
const t = useTranslation()
const [header, setHeader] = useState<{
system?: string
tools: Array<{ name: string; description?: string }>
} | null>(null)
const firstCallId = useMemo(() => {
for (const candidate of viewModel.spans) {
if (candidate.kind === 'llm' && candidate.call?.id) return candidate.call.id
}
return null
}, [viewModel])
useEffect(() => {
// Clear first: without this, switching sessions keeps showing the previous
// session's system prompt until the new fetch resolves.
setHeader(null)
if (!sessionId || !firstCallId) return
let cancelled = false
void fetchTraceCallDetail(sessionId, firstCallId, revisionKey).then((call) => {
if (cancelled) return
const preview = call?.request.body.preview
if (!preview || !call) return
const parsed = parseTraceRequestBody(preview, call.source)
if (!parsed) return
setHeader({
...(parsed.system !== undefined ? { system: parsed.system } : {}),
tools: parsed.tools.map((tool) => ({
name: tool.name,
...(tool.description ? { description: tool.description } : {}),
})),
})
})
return () => { cancelled = true }
}, [sessionId, firstCallId, revisionKey])
if (!header) return null
const hasSystem = Boolean(header.system)
if (!hasSystem && header.tools.length === 0) return null
return (
<div className="mt-5 flex flex-col gap-1">
{header.system ? (
<Section
sectionKey="overview.systemPrompt"
title={t('trace.section.systemPrompt')}
badge={t('trace.detail.chars', { count: header.system.length })}
>
<pre className="max-h-[360px] overflow-y-auto whitespace-pre-wrap break-words text-[12.5px] leading-[1.7] text-[var(--color-text-secondary)]">
{header.system}
</pre>
</Section>
) : null}
{header.tools.length > 0 ? (
<Section sectionKey="overview.tools" title={t('trace.section.tools')} badge={header.tools.length}>
<div className="flex flex-col gap-1">
{header.tools.map((tool) => (
<div key={tool.name} className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 font-mono text-[12px] text-[var(--color-text-primary)]">{tool.name}</span>
{tool.description ? (
<span className="min-w-0 truncate text-[12px] text-[var(--color-text-tertiary)]">
{tool.description}
</span>
) : null}
</div>
))}
</div>
</Section>
) : null}
</div>
)
}
type OverviewStats = {
llmCalls: number
@@ -20,10 +116,14 @@ export function SessionOverview({
span,
viewModel,
onSelect,
sessionId,
revisionKey,
}: {
span: TraceSpan
viewModel: TraceViewModel
onSelect: (spanId: string) => void
sessionId?: string
revisionKey?: string
}) {
const t = useTranslation()
const stats = useMemo(() => computeStats(span, viewModel), [span, viewModel])
@@ -47,6 +147,8 @@ export function SessionOverview({
<Stat label={t('trace.models')} value={stats.models.length > 0 ? stats.models.join(', ') : '--'} />
</div>
<SessionRequestHeader viewModel={viewModel} sessionId={sessionId} revisionKey={revisionKey} />
{children.length > 0 ? (
<div className="mt-6">
<div className="mb-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
+5
View File
@@ -2653,6 +2653,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'trace.status.pending': 'pending',
'trace.message.user': 'User message',
'trace.message.assistant': 'Assistant message',
'trace.message.redactedReasoning': 'Reasoning withheld by provider',
'trace.message.system': 'System message',
'trace.message.toolRequest': 'Assistant tool request',
'trace.message.toolResult': 'Tool result',
@@ -2678,6 +2679,10 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'trace.section.response': 'Response',
'trace.section.messages': 'Messages',
'trace.section.systemPrompt': 'System prompt',
'trace.section.context': 'Injected context',
'trace.context.systemReminder': 'System reminder',
'trace.context.deferredTools': 'Deferred tools',
'trace.context.other': 'Injected',
'trace.section.tools': 'Tools',
'trace.section.parameters': 'Parameters',
'trace.section.raw': 'Raw',
+5
View File
@@ -2655,6 +2655,7 @@ export const jp: Record<TranslationKey, string> = {
'trace.status.pending': '待機中',
'trace.message.user': 'ユーザーメッセージ',
'trace.message.assistant': 'アシスタントメッセージ',
'trace.message.redactedReasoning': '推論内容はプロバイダーにより非開示',
'trace.message.system': 'システムメッセージ',
'trace.message.toolRequest': 'アシスタントのツール要求',
'trace.message.toolResult': 'ツール結果',
@@ -2680,6 +2681,10 @@ export const jp: Record<TranslationKey, string> = {
'trace.section.response': 'レスポンス',
'trace.section.messages': 'メッセージ',
'trace.section.systemPrompt': 'システムプロンプト',
'trace.section.context': '注入されたコンテキスト',
'trace.context.systemReminder': 'システムリマインダー',
'trace.context.deferredTools': '遅延ツール',
'trace.context.other': '注入',
'trace.section.tools': 'ツール',
'trace.section.parameters': 'パラメータ',
'trace.section.raw': '生データ',
+5
View File
@@ -2655,6 +2655,7 @@ export const kr: Record<TranslationKey, string> = {
'trace.status.pending': '대기 중',
'trace.message.user': '사용자 메시지',
'trace.message.assistant': '어시스턴트 메시지',
'trace.message.redactedReasoning': '제공자가 추론 내용을 비공개 처리',
'trace.message.system': '시스템 메시지',
'trace.message.toolRequest': '어시스턴트 도구 요청',
'trace.message.toolResult': '도구 결과',
@@ -2680,6 +2681,10 @@ export const kr: Record<TranslationKey, string> = {
'trace.section.response': '응답',
'trace.section.messages': '메시지',
'trace.section.systemPrompt': '시스템 프롬프트',
'trace.section.context': '주입된 컨텍스트',
'trace.context.systemReminder': '시스템 알림',
'trace.context.deferredTools': '지연 도구',
'trace.context.other': '주입',
'trace.section.tools': '도구',
'trace.section.parameters': '파라미터',
'trace.section.raw': '원본 데이터',
+5
View File
@@ -2654,6 +2654,7 @@ export const zh: Record<TranslationKey, string> = {
'trace.status.pending': '等待中',
'trace.message.user': '使用者訊息',
'trace.message.assistant': '助手訊息',
'trace.message.redactedReasoning': '推理內容由服務方隱去',
'trace.message.system': '系統訊息',
'trace.message.toolRequest': '助手請求工具',
'trace.message.toolResult': '工具結果',
@@ -2679,6 +2680,10 @@ export const zh: Record<TranslationKey, string> = {
'trace.section.response': '回應',
'trace.section.messages': '訊息',
'trace.section.systemPrompt': '系統提示詞',
'trace.section.context': '注入的上下文',
'trace.context.systemReminder': '系統提醒',
'trace.context.deferredTools': '延遲工具',
'trace.context.other': '注入',
'trace.section.tools': '工具',
'trace.section.parameters': '參數',
'trace.section.raw': '原始資料',
+5
View File
@@ -2654,6 +2654,7 @@ export const zh: Record<TranslationKey, string> = {
'trace.status.pending': '等待中',
'trace.message.user': '用户消息',
'trace.message.assistant': '助手消息',
'trace.message.redactedReasoning': '推理内容由服务方隐去',
'trace.message.system': '系统消息',
'trace.message.toolRequest': '助手请求工具',
'trace.message.toolResult': '工具结果',
@@ -2679,6 +2680,10 @@ export const zh: Record<TranslationKey, string> = {
'trace.section.response': '响应',
'trace.section.messages': '消息',
'trace.section.systemPrompt': '系统提示词',
'trace.section.context': '注入的上下文',
'trace.context.systemReminder': '系统提醒',
'trace.context.deferredTools': '延迟工具',
'trace.context.other': '注入',
'trace.section.tools': '工具',
'trace.section.parameters': '参数',
'trace.section.raw': '原始数据',
+100
View File
@@ -112,6 +112,106 @@ describe('parseTraceRequestBody', () => {
})
})
describe('parseTraceRequestBody (Responses format)', () => {
it('reads the system prompt from instructions and the turns from input', () => {
const parsed = parseTraceRequestBody(
JSON.stringify({
model: 'gpt-5.6-sol',
instructions: 'You are a Claude agent.',
input: [
{ type: 'message', role: 'user', content: '<system-reminder>ctx</system-reminder>' },
{ type: 'message', role: 'user', content: 'hello' },
],
tools: [{ type: 'function', name: 'Bash', description: 'run', parameters: { type: 'object' } }],
store: false,
}),
'anthropic',
)
expect(parsed?.system).toBe('You are a Claude agent.')
expect(parsed?.messages).toHaveLength(2)
expect(parsed?.messages[1]).toEqual({ role: 'user', content: [{ type: 'text', text: 'hello' }] })
expect(parsed?.tools[0]?.name).toBe('Bash')
// The turn and header keys are not repeated as loose parameters.
expect(Object.keys(parsed?.params ?? {})).toEqual(['store'])
})
it('keeps the tool round trip that Responses splits into sibling entries', () => {
const parsed = parseTraceRequestBody(
JSON.stringify({
model: 'gpt-5.6-sol',
input: [
{ type: 'message', role: 'user', content: 'list the repo root' },
{ type: 'reasoning', summary: [] },
{
type: 'function_call',
name: 'Bash',
arguments: '{"command":"ls -la"}',
call_id: 'call_1',
},
{ type: 'function_call_output', call_id: 'call_1', output: 'file.txt' },
],
}),
'anthropic',
)
// Reasoning holds no model-visible turn; the tool call and its result do.
expect(parsed?.messages).toHaveLength(3)
expect(parsed?.messages[1]).toEqual({
role: 'assistant',
content: [{ type: 'tool_use', id: 'call_1', name: 'Bash', input: { command: 'ls -la' } }],
})
expect(parsed?.messages[2]).toEqual({
role: 'user',
content: [{ type: 'tool_result', toolUseId: 'call_1', content: 'file.txt' }],
})
})
it('reads a flat Responses tool schema', () => {
const parsed = parseTraceRequestBody(
JSON.stringify({
model: 'gpt-5.6-sol',
input: [],
tools: [{ type: 'function', name: 'Bash', description: 'run', parameters: { type: 'object' } }],
}),
'anthropic',
)
expect(parsed?.tools[0]?.schema).toEqual({ type: 'object' })
})
it('falls back to instructions when system is present but empty', () => {
const parsed = parseTraceRequestBody(
JSON.stringify({
model: 'gpt-5.6-sol',
system: [],
instructions: 'the real prompt',
input: [],
}),
'anthropic',
)
expect(parsed?.system).toBe('the real prompt')
})
it('still prefers the Anthropic spelling when both are present', () => {
const parsed = parseTraceRequestBody(
JSON.stringify({
model: 'claude-sonnet-4-5',
system: 'anthropic system',
instructions: 'responses instructions',
messages: [{ role: 'user', content: 'from messages' }],
input: [{ type: 'message', role: 'user', content: 'from input' }],
}),
'anthropic',
)
expect(parsed?.system).toBe('anthropic system')
expect(parsed?.messages).toHaveLength(1)
expect(parsed?.messages[0]?.content).toEqual([{ type: 'text', text: 'from messages' }])
})
})
describe('parseTraceResponseBody', () => {
it('parses an anthropic json message response', () => {
const parsed = parseTraceResponseBody(JSON.stringify(anthropicResponse), 'anthropic')
+64 -8
View File
@@ -19,7 +19,63 @@ export type ParsedResponse = {
type JsonRecord = Record<string, unknown>
const REQUEST_CORE_KEYS = new Set(['model', 'system', 'messages', 'tools'])
// `instructions` and `input` are the OpenAI Responses spellings of `system`
// and `messages`; both wire formats reach this parser through the same trace.
const REQUEST_CORE_KEYS = new Set(['model', 'system', 'messages', 'tools', 'instructions', 'input'])
/**
* The turns of a request, under whichever key this provider spells them.
*
* Responses splits a tool round trip into sibling `function_call` and
* `function_call_output` entries where the Messages format nests `tool_use`
* and `tool_result` blocks inside a turn. Real traces carry more of those than
* plain messages, so they are mapped onto the block vocabulary the rest of the
* viewer already speaks rather than skipped. Reasoning items hold no
* model-visible turn and are the one kind dropped.
*/
function requestTurns(body: JsonRecord): NormalizedMessage[] {
if (Array.isArray(body.messages)) {
return body.messages
.map((entry) => normalizeMessage(entry))
.filter((entry): entry is NormalizedMessage => entry !== null)
}
if (!Array.isArray(body.input)) return []
const turns: NormalizedMessage[] = []
for (const entry of body.input) {
if (!isRecord(entry)) continue
if (entry.type === undefined || entry.type === 'message') {
const message = normalizeMessage(entry)
if (message) turns.push(message)
continue
}
if (entry.type === 'function_call') {
const name = typeof entry.name === 'string' ? entry.name : ''
if (!name) continue
turns.push({
role: 'assistant',
content: [{
type: 'tool_use',
...(typeof entry.call_id === 'string' ? { id: entry.call_id } : {}),
name,
input: parseToolArguments(entry.arguments),
}],
})
continue
}
if (entry.type === 'function_call_output') {
turns.push({
role: 'user',
content: [{
type: 'tool_result',
...(typeof entry.call_id === 'string' ? { toolUseId: entry.call_id } : {}),
content: entry.output,
}],
})
}
}
return turns
}
export function parseTraceRequestBody(preview: string, source: 'anthropic' | 'proxy'): ParsedRequest | null {
const parsed = parseJsonRecord(preview)
@@ -28,12 +84,10 @@ export function parseTraceRequestBody(preview: string, source: 'anthropic' | 'pr
if (!isRecord(body)) return null
const model = typeof body.model === 'string' && body.model ? body.model : undefined
const system = extractSystemText(body.system)
const messages = Array.isArray(body.messages)
? body.messages
.map((entry) => normalizeMessage(entry))
.filter((entry): entry is NormalizedMessage => entry !== null)
: []
// `||`, not `??`: an empty `system` array flattens to '', which would
// otherwise shadow a real `instructions` string.
const system = extractSystemText(body.system) || extractSystemText(body.instructions)
const messages = requestTurns(body)
const tools = Array.isArray(body.tools)
? body.tools
.map((entry) => normalizeToolDefinition(entry))
@@ -163,7 +217,9 @@ function normalizeToolDefinition(entry: unknown): ParsedRequest['tools'][number]
: fn && typeof fn.description === 'string' && fn.description
? fn.description
: undefined
const schema = entry.input_schema ?? fn?.parameters
// Three spellings: Anthropic `input_schema`, Chat Completions
// `function.parameters`, and the flat Responses `parameters`.
const schema = entry.input_schema ?? fn?.parameters ?? entry.parameters
return {
name,
...(description ? { description } : {}),
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest'
import {
classifyUserContent,
splitRequestMessages,
} from './semanticTimeline'
import type { NormalizedBlock, NormalizedMessage } from './types'
const text = (value: string): NormalizedBlock => ({ type: 'text', text: value })
describe('classifyUserContent', () => {
it('keeps a plain typed message as user text', () => {
const result = classifyUserContent([text('安装这里面提到的选题skill')])
expect(result.injections).toEqual([])
expect(result.userText).toBe('安装这里面提到的选题skill')
})
it('separates harness reminders from the text the person typed', () => {
const result = classifyUserContent([
text('<system-reminder>\n# Companion\n\nA small chonk named Quirky Spark…\n</system-reminder>'),
text('<system-reminder>\nThe following skills are available for use with the Skill tool:\n- update-config\n</system-reminder>'),
text('<system-reminder>\nAs you answer the user\'s questions…\n# claudeMd\nCodebase instructions\n</system-reminder>'),
text('https://mp.weixin.qq.com/s/abc 安装这里面提到的 skill'),
])
expect(result.userText).toBe('https://mp.weixin.qq.com/s/abc 安装这里面提到的 skill')
expect(result.injections).toHaveLength(3)
expect(result.injections.every((entry) => entry.kind === 'system-reminder')).toBe(true)
expect(result.injections[0]?.label).toBe('Companion')
expect(result.injections[2]?.label).toBe('claudeMd')
})
it('labels a reminder without a heading from its first line', () => {
const result = classifyUserContent([
text('<system-reminder>\nThe following skills are available for use with the Skill tool:\n</system-reminder>'),
])
expect(result.injections[0]?.label).toBe('The following skills are available for use with the Skill to…')
})
it('recognizes the deferred-tool roster as its own injection', () => {
const result = classifyUserContent([
text('<available-deferred-tools> AskUserQuestion EnterPlanMode WebFetch </available-deferred-tools>'),
])
expect(result.injections[0]?.kind).toBe('deferred-tools')
expect(result.userText).toBeNull()
})
it('keeps an instruction typed after interrupting a tool as the message, not an attachment', () => {
// hoistToolResults moves tool results to the front of a merged user
// message, so this is the exact shape an interrupt-then-type produces.
const result = classifyUserContent([
{ type: 'tool_result', toolUseId: 'call_1', content: "The user doesn't want to take this action right now." },
text('停下,先把 trace 面板的 bug 修了'),
])
expect(result.injections).toEqual([])
expect(result.userText).toBe('停下,先把 trace 面板的 bug 修了')
})
it('recognizes the other harness wrappers it knows by name', () => {
const result = classifyUserContent([text('<background-job-complete id="x">done</background-job-complete>')])
expect(result.injections[0]?.kind).toBe('other')
expect(result.userText).toBeNull()
})
it('keeps markup the person typed as their message rather than calling it injected', () => {
const pasted = '<div class="card">hello</div>'
const result = classifyUserContent([text(pasted)])
expect(result.injections).toEqual([])
expect(result.userText).toBe(pasted)
})
it('ignores blank and non-text blocks', () => {
const result = classifyUserContent([
text(' '),
{ type: 'thinking', thinking: 'internal' },
{ type: 'image', mediaType: 'image/png' },
])
expect(result.injections).toEqual([])
expect(result.userText).toBeNull()
})
it('joins multiple typed paragraphs into one user text', () => {
const result = classifyUserContent([text('first'), text('second')])
expect(result.userText).toBe('first\n\nsecond')
})
})
describe('splitRequestMessages', () => {
it('lifts injections out and leaves the typed exchange behind', () => {
const result = splitRequestMessages([
{ role: 'user', content: [text('<available-deferred-tools> WebFetch </available-deferred-tools>')] },
{
role: 'user',
content: [
text('<system-reminder>\n# Companion\nwatcher\n</system-reminder>'),
text('装一下这个 skill'),
],
},
{ role: 'assistant', content: [{ type: 'tool_use', id: 'c1', name: 'Bash', input: { command: 'ls' } }] },
{ role: 'user', content: [{ type: 'tool_result', toolUseId: 'c1', content: 'ok' }] },
])
expect(result.injections.map((entry) => entry.messageIndex)).toEqual([0, 1])
expect(result.injections[1]?.label).toBe('Companion')
expect(result.conversation).toHaveLength(3)
expect(result.conversation[0]).toEqual({ role: 'user', content: [text('装一下这个 skill')] })
expect(result.conversation[1]?.role).toBe('assistant')
expect(result.conversation[2]?.content[0]?.type).toBe('tool_result')
})
it('drops a user message that carried only injected context', () => {
const result = splitRequestMessages([
{ role: 'user', content: [text('<system-reminder>only context</system-reminder>')] },
])
expect(result.conversation).toEqual([])
expect(result.injections).toHaveLength(1)
})
it('keeps a tool result alongside the text that arrived with it', () => {
const result = splitRequestMessages([
{
role: 'user',
content: [
{ type: 'tool_result', toolUseId: 'c1', content: 'Launching skill' },
text('Base directory for this skill: /tmp'),
],
},
])
expect(result.injections).toEqual([])
expect(result.conversation).toHaveLength(1)
expect(result.conversation[0]?.content).toEqual([
{ type: 'tool_result', toolUseId: 'c1', content: 'Launching skill' },
text('Base directory for this skill: /tmp'),
])
})
it('carries an unexpected non-text block on a user turn through instead of dropping it', () => {
const result = splitRequestMessages([
{
role: 'user',
content: [
text('<system-reminder>context</system-reminder>'),
{ type: 'image', mediaType: 'image/png', dataUrl: 'data:image/png;base64,AA' },
],
},
])
expect(result.conversation).toHaveLength(1)
expect(result.conversation[0]?.content).toEqual([
{ type: 'image', mediaType: 'image/png', dataUrl: 'data:image/png;base64,AA' },
])
})
it('passes assistant messages through untouched', () => {
const assistant: NormalizedMessage = {
role: 'assistant',
content: [{ type: 'thinking', thinking: 'private' }, text('visible')],
}
const result = splitRequestMessages([assistant])
expect(result.conversation[0]).toBe(assistant)
expect(result.injections).toEqual([])
})
})
+143
View File
@@ -0,0 +1,143 @@
import type { NormalizedBlock, NormalizedMessage } from './types'
/**
* What a non-user text block inside a user-role message actually is. The model
* sees these as user input, but they are assembled by the harness rather than
* typed by the person, so the timeline shows them as their own records.
*/
export type ContextInjectionKind =
| 'system-reminder'
| 'deferred-tools'
| 'other'
export type ContextInjection = {
kind: ContextInjectionKind
/** Short label for the timeline row; the full text stays in `text`. */
label: string
text: string
}
export type ClassifiedUserContent = {
injections: ContextInjection[]
/** The text the person actually typed, when this message carries one. */
userText: string | null
}
/**
* Tags the harness wraps around assembled context before sending it as user
* input. This is a closed list on purpose: an unknown tag is far more likely
* to be something the person typed or pasted than a new harness wrapper, and
* showing typed input under "injected context" is the worse mistake — the
* reader goes looking for their own message and does not find it.
*/
const INJECTION_TAGS: ReadonlyArray<{ tag: string; kind: ContextInjectionKind; label: string }> = [
{ tag: 'system-reminder', kind: 'system-reminder', label: 'System reminder' },
{ tag: 'available-deferred-tools', kind: 'deferred-tools', label: 'Deferred tools' },
{ tag: 'background-job-complete', kind: 'other', label: 'Background job' },
{ tag: 'command-name', kind: 'other', label: 'Command' },
{ tag: 'command-message', kind: 'other', label: 'Command' },
{ tag: 'local-command-stdout', kind: 'other', label: 'Command output' },
]
function openingTag(text: string): string | null {
const match = /^\s*<([a-z][a-z0-9-]*)(?:\s[^>]*)?>/i.exec(text)
return match?.[1]?.toLowerCase() ?? null
}
/**
* Name an injection from its own content so the timeline row says what was
* injected rather than repeating the wrapper tag. A `# Heading` wins because
* the harness writes one for each distinct reminder; otherwise the first
* non-empty line stands in.
*/
function deriveLabel(fallback: string, text: string): string {
const body = text.replace(/^\s*<[a-z][a-z0-9-]*(?:\s[^>]*)?>/i, '').replace(/<\/[a-z][a-z0-9-]*>\s*$/i, '')
const heading = /^\s*#{1,3}\s+(.{1,60}?)\s*$/m.exec(body)
if (heading?.[1]) return heading[1].trim()
for (const line of body.split('\n')) {
const trimmed = line.trim()
if (trimmed.length === 0) continue
return trimmed.length > 60 ? `${trimmed.slice(0, 60)}…` : trimmed
}
return fallback
}
/**
* Split one user-role message into harness-assembled context and the person's
* own text.
*
* Recognition is by wrapper tag alone. Position cannot stand in for it here:
* `hoistToolResults` in `src/utils/messages.ts` moves every tool result to the
* front of a merged user message, so an attachment the harness appended after a
* tool result and an instruction the person typed after interrupting one arrive
* in exactly the same shape. Faced with that ambiguity this errs toward the
* conversation — unrecognized text stays the person's message, because someone
* looking for what they said and not finding it is the worse failure.
*/
export function classifyUserContent(blocks: readonly NormalizedBlock[]): ClassifiedUserContent {
const injections: ContextInjection[] = []
const plain: string[] = []
for (const block of blocks) {
if (block.type !== 'text') continue
const text = block.text
if (text.trim().length === 0) continue
const tag = openingTag(text)
const known = tag ? INJECTION_TAGS.find((entry) => entry.tag === tag) : undefined
if (known) {
injections.push({ kind: known.kind, label: deriveLabel(known.label, text), text })
continue
}
plain.push(text)
}
return {
injections,
userText: plain.length > 0 ? plain.join('\n\n') : null,
}
}
export type LocatedInjection = ContextInjection & { messageIndex: number }
export type SplitRequestMessages = {
/** Harness-assembled context, in the order the provider received it. */
injections: LocatedInjection[]
/**
* The exchange with the injections lifted out: assistant turns untouched,
* user turns reduced to what the person typed plus any tool results. A user
* message that carried nothing else is dropped.
*/
conversation: NormalizedMessage[]
}
/**
* Separate the assembled context from the conversation inside one request's
* message list, so each can be read on its own terms.
*/
export function splitRequestMessages(
messages: readonly NormalizedMessage[],
): SplitRequestMessages {
const injections: LocatedInjection[] = []
const conversation: NormalizedMessage[] = []
messages.forEach((message, messageIndex) => {
if (message.role !== 'user') {
conversation.push(message)
return
}
const { injections: found, userText } = classifyUserContent(message.content)
for (const injection of found) injections.push({ ...injection, messageIndex })
// Only text blocks were classified above, so only they are replaced. Any
// other block type — tool results, images, or something unexpected on a
// user turn — is carried through rather than silently dropped. The text
// goes last because `hoistToolResults` already put tool results first.
const kept: NormalizedBlock[] = message.content.filter((block) => block.type !== 'text')
if (userText !== null) kept.push({ type: 'text', text: userText })
if (kept.length > 0) conversation.push({ role: message.role, content: kept })
})
return { injections, conversation }
}
+85
View File
@@ -156,6 +156,9 @@ const baseMessages: MessageEntry[] = [
async function renderReady(pollIntervalMs = 60_000) {
render(<TraceSession sessionId={SESSION_ID} pollIntervalMs={pollIntervalMs} />)
await screen.findByTestId('trace-split-layout')
// The overview lazily fetches the opening request header. Let that settle so
// its state update lands inside act() rather than warning after the test.
await waitFor(() => expect(screen.getByTestId('trace-detail')).toBeInTheDocument())
}
describe('TraceSession', () => {
@@ -735,4 +738,86 @@ describe('TraceSession', () => {
// The standalone window also drops "open in window" — it is already one.
expect(screen.queryByRole('button', { name: 'Open in separate window' })).not.toBeInTheDocument()
})
it('answers what the system prompt is from the session overview, without opening a call', async () => {
await renderReady()
const overview = await screen.findByTestId('trace-overview')
expect(await within(overview).findByText('System prompt')).toBeInTheDocument()
// The catalog names every tool the request carried, called or not.
fireEvent.click(within(overview).getByText('Tools'))
expect(await within(overview).findByText('Bash')).toBeInTheDocument()
})
it('says what an assistant turn did when the provider withheld its reasoning', async () => {
vi.mocked(sessionsApi.getMessages).mockResolvedValue({
messages: [
baseMessages[0]!,
{
id: 'msg-reasoned',
type: 'assistant',
// OpenAI reasoning the harness stores encoded: no readable text, so
// the row would otherwise render as a bare "Assistant message".
content: [{ type: 'redacted_thinking', data: 'cc-haha:openai-reasoning:v1:{}' }],
timestamp: '2026-06-09T10:00:04.000Z',
model: 'gpt-5.6-sol',
},
],
})
await renderReady()
const tree = await screen.findByTestId('trace-tree')
expect(await within(tree).findByText('Reasoning withheld by provider')).toBeInTheDocument()
})
it('separates harness-injected context from the message the person typed', async () => {
const injectedPreview = JSON.stringify({
model: 'claude-sonnet-4-5',
system: 'You are helpful.',
messages: [
{ role: 'user', content: [{ type: 'text', text: '<available-deferred-tools> WebFetch </available-deferred-tools>' }] },
{
role: 'user',
content: [
{ type: 'text', text: '<system-reminder>\n# Companion\nA watcher sits beside the input box.\n</system-reminder>' },
{ type: 'text', text: 'Hello world' },
],
},
],
tools: [{ name: 'Bash', description: 'Run shell commands', input_schema: { type: 'object' } }],
max_tokens: 4096,
})
vi.mocked(sessionsApi.getTraceCall).mockResolvedValue({
call: makeCall({
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
headers: { 'content-type': 'application/json' },
body: {
contentType: 'json',
bytes: injectedPreview.length,
sha256: 'c'.repeat(64),
preview: injectedPreview,
truncated: false,
},
},
}),
})
await renderReady()
fireEvent.click(await screen.findByRole('treeitem', { name: /claude-sonnet-4-5/ }))
const detail = await screen.findByTestId('trace-llm-detail')
expect(await within(detail).findByText('Injected context')).toBeInTheDocument()
// Each injection is named by its own content rather than its wrapper tag:
// the reminder is labelled by its heading, the roster by its first line.
expect(within(detail).getByText('Companion')).toBeInTheDocument()
expect(within(detail).getByText('WebFetch')).toBeInTheDocument()
// The kind chip is separate from that derived label.
expect(within(detail).getByText('Deferred tools')).toBeInTheDocument()
// What the person actually typed stays in the conversation.
expect(within(detail).getByText('Hello world')).toBeInTheDocument()
})
})