mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
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:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '生データ',
|
||||
|
||||
@@ -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': '원본 데이터',
|
||||
|
||||
@@ -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': '原始資料',
|
||||
|
||||
@@ -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': '原始数据',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user