refactor(trace): redesign trace UI with LangSmith-style two-pane layout

UI rebuild (desktop):
- TraceSession: replace 3-column layout with two panes — turn-grouped
  timeline tree (draggable splitter, search/filter, keyboard nav) and a
  section-flow detail panel (Response / Messages / System Prompt /
  Tools / Parameters / Raw), collapse state persists across spans
- Render LLM requests/responses semantically: messages as role-colored
  conversation with tool_use/tool_result pairing instead of raw JSON
  dumps; Raw fallback via CodeViewer for legacy truncated records
- TraceList: row-style list with model chips, mono metrics, hover
  actions; content-visibility rows (no virtualization, WebKit-safe)
- i18n synced across zh/en/jp/kr/zh-TW (+25/-55 keys)

Data & capture (server):
- Capture full bodies: preview cap 2048 -> 240k chars, stream cap
  256KB -> 1MB; list API trims previews to keep polling light; new
  GET /api/sessions/:id/trace/calls/:callId returns the full record
- Extract per-call token usage at read time (SSE + JSON + proxy
  wrapped); mtime-keyed read cache for the polling path
- Fix sensitive-key regex redacting *_tokens count fields, which made
  token stats always report 0

Frontend data layer:
- SSE stream reassembly (Anthropic + OpenAI chat) adapted from
  claude-tap (MIT, attribution in THIRD_PARTY_LICENSES.md), request/
  response body parsers, shared formatters, on-demand call detail
  cache; traceViewModel gains tokenUsage/isLifecycleNoise, drops
  fullRaw

Tested:
- bun run check:server (1201 pass)
- bun run check:desktop (lint + 1358 tests + build)
- Chromium walkthrough against real local traces: list, session tree,
  LLM semantic detail (new format), legacy fallback, tool detail
This commit is contained in:
程序员阿江(Relakkes) 2026-06-11 00:41:04 +08:00
parent cf7e48e540
commit d3d7566f0c
40 changed files with 4775 additions and 1787 deletions

33
THIRD_PARTY_LICENSES.md Normal file
View File

@ -0,0 +1,33 @@
# Third-Party Licenses
This project includes code adapted from the following open source projects.
## claude-tap
- Project: claude-tap (https://github.com/liaohch3/claude-tap)
- Adapted in: `desktop/src/lib/trace/sse.ts` (SSE stream reassembly, ported from Python to TypeScript)
- License: MIT
```
MIT License
Copyright (c) 2025 liaohch3
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

View File

@ -39,4 +39,21 @@ describe('sessionsApi', () => {
}),
})
})
it('fetches a single trace call from the call detail endpoint', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
call: { id: 'call-1', sessionId: 'session-1' },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
const result = await sessionsApi.getTraceCall('session-1', 'call-1')
expect(result.call.id).toBe('call-1')
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('http://127.0.0.1:3456/api/sessions/session-1/trace/calls/call-1')
expect(init).toMatchObject({ method: 'GET' })
})
})

View File

@ -2,7 +2,7 @@ import { api } from './client'
import type { AgentTaskNotification } from '../types/chat'
import type { SessionListItem, MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings'
import type { TraceSession } from '../types/trace'
import type { TraceCallRecord, TraceSession } from '../types/trace'
type SessionsResponse = { sessions: SessionListItem[]; total: number }
type MessagesResponse = {
@ -325,6 +325,10 @@ export const sessionsApi = {
return api.get<TraceSession>(`/api/sessions/${sessionId}/trace`)
},
getTraceCall(sessionId: string, callId: string) {
return api.get<{ call: TraceCallRecord }>(`/api/sessions/${sessionId}/trace/calls/${callId}`)
},
create(input?: string | CreateSessionRequest) {
const body = typeof input === 'string'
? (input ? { workDir: input } : {})

View File

@ -0,0 +1,174 @@
import type { ReactNode } from 'react'
import {
AlertTriangle,
Bot,
CircleDot,
Clock3,
FileJson2,
GitBranch,
MessageSquareText,
RadioTower,
Sparkles,
Wrench,
} from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { TraceSpan, TraceSpanStatus } from '../../lib/traceViewModel'
type TraceTranslator = ReturnType<typeof useTranslation>
export function TypeIcon({ span, size = 14 }: { span: TraceSpan; size?: number }) {
const { icon, className } = iconForSpan(span, size)
return (
<span className={`inline-flex shrink-0 items-center justify-center ${className}`} aria-hidden="true">
{icon}
</span>
)
}
function iconForSpan(span: TraceSpan, size: number): { icon: ReactNode; className: string } {
const tertiary = 'text-[var(--color-text-tertiary)]'
switch (span.kind) {
case 'llm':
return { icon: <Sparkles size={size} strokeWidth={2} />, className: 'text-[var(--color-brand)]' }
case 'tool':
return { icon: <Wrench size={size} strokeWidth={2} />, className: 'text-[var(--color-warning)]' }
case 'tool_result':
return { icon: <Wrench size={size} strokeWidth={2} />, className: tertiary }
case 'turn':
return { icon: <GitBranch size={size} strokeWidth={2} />, className: tertiary }
case 'session':
return { icon: <RadioTower size={size} strokeWidth={2} />, className: tertiary }
case 'event':
return span.status === 'error'
? { icon: <AlertTriangle size={size} strokeWidth={2} />, className: 'text-[var(--color-error)]' }
: { icon: <CircleDot size={size} strokeWidth={2} />, className: tertiary }
case 'message':
if (span.message?.type === 'assistant') {
return { icon: <Bot size={size} strokeWidth={2} />, className: tertiary }
}
if (span.message?.type === 'system') {
return { icon: <FileJson2 size={size} strokeWidth={2} />, className: tertiary }
}
return { icon: <MessageSquareText size={size} strokeWidth={2} />, className: tertiary }
default:
return { icon: <FileJson2 size={size} strokeWidth={2} />, className: tertiary }
}
}
export function StatusGlyph({ status }: { status: TraceSpanStatus }) {
if (status === 'error') {
return <AlertTriangle size={13} strokeWidth={2} className="shrink-0 text-[var(--color-error)]" aria-hidden="true" />
}
if (status === 'pending') {
return <Clock3 size={13} strokeWidth={2} className="shrink-0 animate-pulse-dot text-[var(--color-warning)]" aria-hidden="true" />
}
return null
}
export function StatusPill({ status }: { status: TraceSpanStatus }) {
const t = useTranslation()
const className = status === 'error'
? 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
: status === 'pending'
? 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]'
: 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
const label = status === 'error'
? t('trace.status.error')
: status === 'pending'
? t('trace.status.pending')
: t('trace.status.ok')
return (
<span className={`inline-flex shrink-0 items-center rounded-[var(--radius-sm)] px-1.5 py-0.5 text-[10px] font-semibold ${className}`}>
{label}
</span>
)
}
export function MetaChip({
label,
value,
tone = 'default',
title,
}: {
label: string
value: string
tone?: 'default' | 'danger'
title?: string
}) {
return (
<span
className="inline-flex min-w-0 items-center gap-1 text-[10px]"
{...(title ? { title } : {})}
>
<span className="shrink-0 text-[var(--color-text-tertiary)]">{label}</span>
<span className={`truncate font-mono ${tone === 'danger' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-secondary)]'}`}>
{value}
</span>
</span>
)
}
export function LiveBadge() {
const t = useTranslation()
return (
<span className="inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-success)]/25 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-success)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
{t('trace.live')}
</span>
)
}
export function spanDisplayTitle(span: TraceSpan, t: TraceTranslator): string {
if (span.kind === 'message' && span.message) {
switch (span.message.type) {
case 'user': return t('trace.message.user')
case 'assistant': return t('trace.message.assistant')
case 'system': return t('trace.message.system')
case 'tool_use': return t('trace.message.toolRequest')
case 'tool_result': return t('trace.message.toolResult')
default: return span.message.type
}
}
if (span.kind === 'llm') {
return span.call?.model ?? span.call?.provider?.name ?? t('trace.modelCall')
}
if (span.kind === 'tool') {
return span.toolName ?? span.title
}
if (span.kind === 'tool_result') {
return span.status === 'error' ? t('trace.toolError') : t('trace.toolResult')
}
if (span.kind === 'event' && span.event) {
return traceEventPhaseLabel(span.event.phase, t)
}
if (span.kind === 'turn') {
return turnDisplayTitle(span.title, (span.turnIndex ?? 0) + 1, t)
}
return span.title
}
export function turnDisplayTitle(title: string, oneBasedIndex: number, t: TraceTranslator): string {
if (title === 'Session activity') return t('trace.sessionActivity')
const match = title.match(/^Turn (\d+)$/)
if (match) return t('trace.turnLabel', { index: match[1]! })
if (!title.trim()) return t('trace.turnLabel', { index: oneBasedIndex })
return title
}
export function traceEventPhaseLabel(phase: string, t: TraceTranslator): string {
switch (phase) {
case 'api_call_started': return t('trace.event.apiCallStarted')
case 'api_call_completed': return t('trace.event.apiCallCompleted')
case 'api_call_failed': return t('trace.event.apiCallFailed')
case 'response_capture_failed': return t('trace.event.responseCaptureFailed')
case 'upstream_fetch_started': return t('trace.event.upstreamFetchStarted')
case 'upstream_fetch_completed': return t('trace.event.upstreamFetchCompleted')
case 'upstream_fetch_failed': return t('trace.event.upstreamFetchFailed')
default:
return phase
.split(/[_\s-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
}

View File

@ -0,0 +1,164 @@
import { useTranslation } from '../../i18n'
import type { TraceSpan, TraceViewModel } from '../../lib/traceViewModel'
import { formatTraceJson } from '../../lib/traceViewModel'
import { formatClockTime, formatDurationMs, formatTokenCount, formatUsageBrief } from '../../lib/trace/formatters'
import { formatBytes } from '../../lib/formatBytes'
import { CodeViewer } from '../chat/CodeViewer'
import { MetaChip, StatusPill, TypeIcon, spanDisplayTitle, traceEventPhaseLabel } from './TraceBadges'
import { Section } from './detail/Section'
import { LlmCallDetail } from './detail/LlmCallDetail'
import { ToolDetail } from './detail/ToolDetail'
import { MessageDetail } from './detail/MessageDetail'
import { SessionOverview } from './detail/SessionOverview'
export function TraceDetail({
span,
viewModel,
sessionId,
onSelect,
}: {
span: TraceSpan
viewModel: TraceViewModel
sessionId: string
onSelect: (spanId: string) => void
}) {
const t = useTranslation()
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="trace-detail">
<div className="shrink-0 border-b border-[var(--color-border)] px-4 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<TypeIcon span={span} />
<h2 className="min-w-0 truncate text-sm font-semibold text-[var(--color-text-primary)]">
{spanDisplayTitle(span, t)}
</h2>
<StatusPill status={span.status} />
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
<HeaderChips span={span} />
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<DetailBody span={span} viewModel={viewModel} sessionId={sessionId} onSelect={onSelect} />
</div>
</div>
)
}
function DetailBody({
span,
viewModel,
sessionId,
onSelect,
}: {
span: TraceSpan
viewModel: TraceViewModel
sessionId: string
onSelect: (spanId: string) => void
}) {
switch (span.kind) {
case 'llm':
return <LlmCallDetail sessionId={sessionId} span={span} />
case 'tool':
case 'tool_result':
return <ToolDetail span={span} />
case 'message':
return <MessageDetail span={span} />
case 'event':
return <EventDetail span={span} />
default:
return <SessionOverview span={span} viewModel={viewModel} onSelect={onSelect} />
}
}
function HeaderChips({ span }: { span: TraceSpan }) {
const t = useTranslation()
const call = span.call
if (call) {
return (
<>
{call.model ? <MetaChip label={t('trace.model')} value={call.model} /> : null}
{call.provider?.name ? <MetaChip label={t('trace.provider')} value={call.provider.name} /> : null}
{call.durationMs !== undefined ? (
<MetaChip label={t('trace.duration')} value={formatDurationMs(call.durationMs)} />
) : null}
{call.usage ? (
<MetaChip
label={t('trace.tokens')}
value={formatUsageBrief(call.usage)}
title={usageTooltip(call.usage)}
/>
) : null}
<MetaChip label={t('trace.request')} value={formatBytes(call.request.body.bytes)} />
{call.response ? (
<>
<MetaChip label={t('trace.response')} value={formatBytes(call.response.body.bytes)} />
<MetaChip
label={t('trace.status')}
value={String(call.response.status)}
tone={call.response.status >= 400 ? 'danger' : 'default'}
/>
</>
) : null}
<MetaChip label={t('trace.started')} value={formatClockTime(call.startedAt)} />
</>
)
}
return (
<>
{span.toolUseId ? <MetaChip label={t('trace.detail.toolUseId')} value={span.toolUseId} /> : null}
{span.durationMs !== undefined ? (
<MetaChip label={t('trace.duration')} value={formatDurationMs(span.durationMs)} />
) : null}
<MetaChip label={t('trace.started')} value={formatClockTime(span.timestamp)} />
</>
)
}
function usageTooltip(usage: NonNullable<TraceSpan['tokenUsage']>): string {
const parts = [
`in ${formatTokenCount(usage.inputTokens)}`,
`out ${formatTokenCount(usage.outputTokens)}`,
]
if (usage.cacheReadInputTokens !== undefined) {
parts.push(`cache read ${formatTokenCount(usage.cacheReadInputTokens)}`)
}
if (usage.cacheCreationInputTokens !== undefined) {
parts.push(`cache write ${formatTokenCount(usage.cacheCreationInputTokens)}`)
}
return parts.join(' · ')
}
function EventDetail({ span }: { span: TraceSpan }) {
const t = useTranslation()
const event = span.event
if (!event) return null
return (
<div data-testid="trace-event-detail">
<Section sectionKey="event.detail" title={t('trace.section.event')} defaultOpen>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-[11px]">
<dt className="text-[var(--color-text-tertiary)]">{t('trace.detail.phase')}</dt>
<dd className="min-w-0 truncate font-mono text-[var(--color-text-secondary)]">
{traceEventPhaseLabel(event.phase, t)}
</dd>
<dt className="text-[var(--color-text-tertiary)]">{t('trace.detail.severity')}</dt>
<dd className={`min-w-0 truncate font-mono ${event.severity === 'error' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-secondary)]'}`}>
{event.severity}
</dd>
{event.message ? (
<>
<dt className="text-[var(--color-text-tertiary)]">{t('trace.detail.message')}</dt>
<dd className="min-w-0 whitespace-pre-wrap break-words text-[var(--color-text-secondary)]">
{event.message}
</dd>
</>
) : null}
</dl>
</Section>
{event.metadata && Object.keys(event.metadata).length > 0 ? (
<Section sectionKey="event.metadata" title={t('trace.section.metadata')} defaultOpen>
<CodeViewer code={formatTraceJson(event.metadata)} language="json" maxLines={32} showLineNumbers />
</Section>
) : null}
</div>
)
}

View File

@ -0,0 +1,95 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
const STORAGE_KEY = 'trace.treeWidth'
const DEFAULT_WIDTH = 380
const MIN_WIDTH = 280
const MAX_WIDTH = 560
function clampWidth(width: number): number {
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(width)))
}
function readStoredWidth(): number {
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
const parsed = stored === null ? Number.NaN : Number.parseInt(stored, 10)
return Number.isFinite(parsed) ? clampWidth(parsed) : DEFAULT_WIDTH
} catch {
return DEFAULT_WIDTH
}
}
function persistWidth(width: number) {
try {
window.localStorage.setItem(STORAGE_KEY, String(width))
} catch {
// localStorage unavailable (private mode); keep the in-memory width.
}
}
export function TraceSplitLayout({ tree, detail }: { tree: ReactNode; detail: ReactNode }) {
const [width, setWidth] = useState(readStoredWidth)
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
const widthRef = useRef(width)
widthRef.current = width
const onPointerMove = useCallback((event: MouseEvent) => {
const drag = dragRef.current
if (!drag) return
setWidth(clampWidth(drag.startWidth + (event.clientX - drag.startX)))
}, [])
const onPointerUp = useCallback(() => {
if (!dragRef.current) return
dragRef.current = null
persistWidth(widthRef.current)
document.body.style.removeProperty('cursor')
document.body.style.removeProperty('user-select')
window.removeEventListener('mousemove', onPointerMove)
window.removeEventListener('mouseup', onPointerUp)
}, [onPointerMove])
const onDividerMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault()
dragRef.current = { startX: event.clientX, startWidth: widthRef.current }
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onPointerMove)
window.addEventListener('mouseup', onPointerUp)
}, [onPointerMove, onPointerUp])
const onDividerDoubleClick = useCallback(() => {
setWidth(DEFAULT_WIDTH)
persistWidth(DEFAULT_WIDTH)
}, [])
useEffect(() => () => {
window.removeEventListener('mousemove', onPointerMove)
window.removeEventListener('mouseup', onPointerUp)
}, [onPointerMove, onPointerUp])
return (
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden lg:flex-row"
style={{ '--trace-tree-width': `${width}px` } as CSSProperties}
data-testid="trace-split-layout"
>
<div className="flex h-[40vh] min-h-0 shrink-0 flex-col overflow-hidden lg:h-auto lg:w-[var(--trace-tree-width)]">
{tree}
</div>
<div
role="separator"
aria-orientation="vertical"
data-testid="trace-split-divider"
onMouseDown={onDividerMouseDown}
onDoubleClick={onDividerDoubleClick}
className="group relative hidden w-px shrink-0 cursor-col-resize bg-[var(--color-border)] lg:block"
>
<div className="absolute inset-y-0 -left-[2px] w-[5px] transition-colors group-hover:bg-[var(--color-brand)]/25" />
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden border-t border-[var(--color-border)] lg:border-t-0">
{detail}
</div>
</div>
)
}

View File

@ -0,0 +1,361 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { ChevronDown, ChevronRight, Search } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { previewTraceValue, type TraceSpan, type TraceViewModel } from '../../lib/traceViewModel'
import { formatDurationMs } from '../../lib/trace/formatters'
import { StatusGlyph, TypeIcon, spanDisplayTitle, turnDisplayTitle } from './TraceBadges'
export type TraceTreeFilter = 'all' | 'llm' | 'tool' | 'error'
type TreeRow = {
span: TraceSpan
depth: number
}
type TreeGroup = {
turnId: string
turnSpan: TraceSpan
rows: TreeRow[]
errorCount: number
}
export function TraceTree({
viewModel,
selectedId,
onSelect,
}: {
viewModel: TraceViewModel
selectedId: string | null
onSelect: (spanId: string) => void
}) {
const t = useTranslation()
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<TraceTreeFilter>('all')
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<string>>(new Set())
const scrollRef = useRef<HTMLDivElement>(null)
const groups = useMemo(
() => buildTreeGroups(viewModel, filter, query),
[viewModel, filter, query],
)
const navigableIds = useMemo(() => {
const ids: string[] = []
for (const group of groups) {
ids.push(group.turnId)
if (collapsedTurns.has(group.turnId)) continue
for (const row of group.rows) ids.push(row.span.id)
}
return ids
}, [groups, collapsedTurns])
useEffect(() => {
if (!selectedId) return
const container = scrollRef.current
if (!container) return
const row = container.querySelector<HTMLElement>(`[data-span-id="${CSS.escape(selectedId)}"]`)
row?.scrollIntoView({ block: 'nearest' })
}, [selectedId])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
if (navigableIds.length === 0) return
event.preventDefault()
const currentIndex = selectedId ? navigableIds.indexOf(selectedId) : -1
const nextIndex = event.key === 'ArrowDown'
? Math.min(navigableIds.length - 1, currentIndex + 1)
: Math.max(0, currentIndex <= 0 ? 0 : currentIndex - 1)
const nextId = navigableIds[nextIndex]
if (nextId && nextId !== selectedId) onSelect(nextId)
}
const toggleTurn = (turnId: string) => {
setCollapsedTurns((previous) => {
const next = new Set(previous)
if (next.has(turnId)) next.delete(turnId)
else next.add(turnId)
return next
})
}
return (
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface-container-lowest)]" data-testid="trace-tree">
<div className="shrink-0 border-b border-[var(--color-border)] px-3 py-2.5">
<label className="flex h-7 items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 text-xs text-[var(--color-text-tertiary)]">
<Search size={13} strokeWidth={2} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('trace.searchSpans')}
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</label>
<div className="mt-2 flex flex-wrap gap-1">
{(['all', 'llm', 'tool', 'error'] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => setFilter(value)}
className={`rounded-[var(--radius-sm)] px-2 py-0.5 text-[10px] font-semibold transition-colors ${
filter === value
? 'bg-[var(--color-primary-container)] text-[var(--color-on-primary-container)]'
: 'border border-[var(--color-border)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
}`}
>
{filterLabel(value, t)}
</button>
))}
</div>
</div>
<div
ref={scrollRef}
role="tree"
aria-label={t('trace.tree.aria')}
tabIndex={0}
onKeyDown={onKeyDown}
className="min-h-0 flex-1 overflow-y-auto pb-2 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
{groups.length > 0 ? (
groups.map((group) => (
<TurnGroup
key={group.turnId}
group={group}
collapsed={collapsedTurns.has(group.turnId)}
selectedId={selectedId}
onSelect={onSelect}
onToggle={() => toggleTurn(group.turnId)}
/>
))
) : (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
{t('trace.noMatchingSpans')}
</div>
)}
</div>
</div>
)
}
function TurnGroup({
group,
collapsed,
selectedId,
onSelect,
onToggle,
}: {
group: TreeGroup
collapsed: boolean
selectedId: string | null
onSelect: (spanId: string) => void
onToggle: () => void
}) {
const t = useTranslation()
const turnSpan = group.turnSpan
const selected = selectedId === group.turnId
const turnNumber = (turnSpan.turnIndex ?? 0) + 1
const turnLabel = t('trace.turnLabel', { index: turnNumber })
const preview = turnPreview(turnSpan, t)
return (
<section>
<div
className={`sticky top-0 z-10 flex items-center gap-1 border-b border-[var(--color-border)]/60 bg-[var(--color-surface-container-lowest)] py-1.5 pl-1.5 pr-3 ${
selected ? 'shadow-[inset_2px_0_0_var(--color-brand)]' : ''
}`}
>
<button
type="button"
onClick={onToggle}
aria-label={t('trace.tree.toggleTurn')}
aria-expanded={!collapsed}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
>
{collapsed
? <ChevronRight size={13} strokeWidth={2} />
: <ChevronDown size={13} strokeWidth={2} />}
</button>
<button
type="button"
onClick={() => onSelect(group.turnId)}
data-span-id={group.turnId}
className="flex min-w-0 flex-1 items-baseline gap-1.5 text-left"
>
<span className={`shrink-0 text-[10px] font-semibold uppercase tracking-[0.08em] ${
selected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'
}`}>
{turnLabel}
</span>
{preview && preview !== turnLabel ? (
<span className="truncate text-[11px] text-[var(--color-text-tertiary)]">{preview}</span>
) : null}
</button>
{group.errorCount > 0 ? (
<span className="shrink-0 font-mono text-[10px] font-semibold text-[var(--color-error)]">
{group.errorCount}
</span>
) : null}
</div>
{!collapsed ? group.rows.map((row) => (
<TreeRowButton
key={row.span.id}
row={row}
selected={selectedId === row.span.id}
onSelect={() => onSelect(row.span.id)}
/>
)) : null}
</section>
)
}
function TreeRowButton({ row, selected, onSelect }: { row: TreeRow; selected: boolean; onSelect: () => void }) {
const t = useTranslation()
const span = row.span
const preview = rowPreview(span)
const duration = span.durationMs !== undefined ? formatDurationMs(span.durationMs) : null
return (
<button
type="button"
role="treeitem"
aria-selected={selected}
aria-level={row.depth + 1}
data-span-id={span.id}
onClick={onSelect}
className={`trace-row-cv relative flex h-[34px] w-full items-center gap-2 pr-3 text-left transition-colors ${
selected
? 'bg-[var(--color-surface-container-high)]'
: 'hover:bg-[var(--color-surface-container-low)]'
}`}
style={{ paddingLeft: `${12 + row.depth * 14}px` }}
>
{selected ? <span className="absolute inset-y-0 left-0 w-[2px] bg-[var(--color-brand)]" aria-hidden="true" /> : null}
<TypeIcon span={span} />
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<span className={`shrink-0 truncate text-xs font-semibold ${
selected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'
}`}>
{spanDisplayTitle(span, t)}
</span>
{preview ? (
<span className="truncate text-[11px] text-[var(--color-text-tertiary)]">{preview}</span>
) : null}
{span.isSidechain ? (
<span className="shrink-0 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-1 text-[9px] text-[var(--color-text-tertiary)]">
{t('trace.sidechain')}
</span>
) : null}
</span>
{duration ? (
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-tertiary)]">{duration}</span>
) : null}
<StatusGlyph status={span.status} />
</button>
)
}
function rowPreview(span: TraceSpan): string | null {
if (span.kind === 'message' || span.kind === 'event') {
const preview = span.subtitle
return preview && preview !== 'empty' ? preview : null
}
return null
}
function turnPreview(turnSpan: TraceSpan, t: ReturnType<typeof useTranslation>): string {
return turnDisplayTitle(turnSpan.title, (turnSpan.turnIndex ?? 0) + 1, t)
}
function buildTreeGroups(viewModel: TraceViewModel, filter: TraceTreeFilter, query: string): TreeGroup[] {
const visibleIds = filterSpanIds(viewModel, filter, query)
const depthById = computeDepths(viewModel)
const groupsByTurn = new Map<string, TreeGroup>()
const groups: TreeGroup[] = []
for (const id of viewModel.orderedSpanIds) {
const span = viewModel.spansById.get(id)
if (!span) continue
if (span.kind === 'session') continue
if (span.kind === 'turn') {
const group: TreeGroup = { turnId: span.id, turnSpan: span, rows: [], errorCount: 0 }
groupsByTurn.set(span.id, group)
groups.push(group)
continue
}
if (span.kind === 'tool_result') continue
if (span.isLifecycleNoise === true) continue
if (!visibleIds.has(span.id)) continue
const turnId = `turn:${span.turnIndex ?? 0}`
const group = groupsByTurn.get(turnId)
if (!group) continue
// Depth relative to the turn header: session=0, turn=1, direct child=2.
const depth = Math.max(0, (depthById.get(span.id) ?? 2) - 2)
group.rows.push({ span, depth })
if (span.status === 'error') group.errorCount += 1
}
return groups.filter((group) => group.rows.length > 0 || (!query.trim() && filter === 'all'))
}
function filterSpanIds(viewModel: TraceViewModel, filter: TraceTreeFilter, query: string): Set<string> {
const normalizedQuery = query.trim().toLowerCase()
const matched = new Set<string>()
for (const span of viewModel.spans) {
const filterMatch =
filter === 'all' ||
(filter === 'llm' && span.kind === 'llm') ||
(filter === 'tool' && (span.kind === 'tool' || span.kind === 'tool_result')) ||
(filter === 'error' && span.status === 'error')
const queryMatch = !normalizedQuery || spanSearchText(span).includes(normalizedQuery)
if (filterMatch && queryMatch) {
includeWithAncestors(viewModel, span.id, matched)
}
}
return matched
}
function includeWithAncestors(viewModel: TraceViewModel, spanId: string, target: Set<string>) {
let current = viewModel.spansById.get(spanId)
while (current) {
target.add(current.id)
current = current.parentId ? viewModel.spansById.get(current.parentId) : undefined
}
}
function spanSearchText(span: TraceSpan): string {
return [
span.title,
span.subtitle,
span.kind,
span.status,
span.toolName,
span.toolUseId,
span.call?.model,
span.call?.provider?.name,
span.call?.request.url,
span.event?.phase,
span.event?.message,
span.event?.provider?.name,
previewTraceValue(span.raw, 500),
].filter(Boolean).join(' ').toLowerCase()
}
function computeDepths(viewModel: TraceViewModel): Map<string, number> {
const depths = new Map<string, number>()
const visit = (id: string, depth: number) => {
depths.set(id, depth)
const span = viewModel.spansById.get(id)
if (!span) return
for (const childId of span.childIds) visit(childId, depth + 1)
}
visit(viewModel.rootId, 0)
return depths
}
function filterLabel(filter: TraceTreeFilter, t: ReturnType<typeof useTranslation>): string {
switch (filter) {
case 'llm': return t('trace.filter.llm')
case 'tool': return t('trace.filter.tools')
case 'error': return t('trace.filter.errors')
default: return t('trace.filter.all')
}
}

View File

@ -0,0 +1,338 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import type { TraceBodySnapshot, TraceCallRecord } from '../../../types/trace'
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 type { NormalizedMessage } from '../../../lib/trace/types'
import { formatBytes } from '../../../lib/formatBytes'
import { CodeViewer } from '../../chat/CodeViewer'
import { CopyButton } from '../../shared/CopyButton'
import { MetaChip } from '../TraceBadges'
import { Section } from './Section'
import { MessageBlocks } from './MessageBlocks'
const MESSAGE_FOLD_THRESHOLD = 20
const MESSAGE_HEAD_COUNT = 2
const MESSAGE_TAIL_COUNT = 6
export function LlmCallDetail({ sessionId, span }: { sessionId: string; span: TraceSpan }) {
const t = useTranslation()
const call = span.call
const callId = call?.id ?? null
const isTerminal = span.status !== 'pending'
const [detail, setDetail] = useState<TraceCallRecord | null>(null)
const [fetchFailed, setFetchFailed] = useState(false)
const fetchKeyRef = useRef<string | null>(null)
useEffect(() => {
if (!callId || !isTerminal) return
const key = `${sessionId}:${callId}`
// Ref guard keeps React StrictMode's double effect run from issuing a
// second request; staleness is checked against the ref at resolve time.
if (fetchKeyRef.current === key) return
fetchKeyRef.current = key
void fetchTraceCallDetail(sessionId, callId).then((full) => {
if (fetchKeyRef.current !== key) return
if (full) {
setDetail(full)
setFetchFailed(false)
} else {
setFetchFailed(true)
}
})
}, [sessionId, callId, isTerminal])
const effectiveCall = detail && detail.id === callId ? detail : call
const parsed = useMemo(() => {
if (!effectiveCall) return { request: null, response: null }
return {
request: effectiveCall.request.body.preview
? parseTraceRequestBody(effectiveCall.request.body.preview, effectiveCall.source)
: null,
response: effectiveCall.response?.body.preview
? parseTraceResponseBody(effectiveCall.response.body.preview, effectiveCall.source)
: null,
}
}, [effectiveCall])
if (!call || !effectiveCall) return null
const loadingDetail = isTerminal && (!detail || detail.id !== callId) && !fetchFailed
const requestParseFailed = Boolean(effectiveCall.request.body.preview) && parsed.request === null
const responseParseFailed = Boolean(effectiveCall.response?.body.preview) &&
(parsed.response === null || parsed.response.message === null)
const legacyFallback = !loadingDetail && (requestParseFailed || (isTerminal && !call.error && responseParseFailed))
const params = parsed.request?.params ?? {}
const paramEntries = Object.entries(params)
return (
<div data-testid="trace-llm-detail">
{loadingDetail ? (
<div className="progress-indeterminate-track h-0.5 bg-[var(--color-surface-container)]" data-testid="trace-detail-loading" />
) : null}
{fetchFailed ? (
<NoticeBar text={t('trace.detail.fetchFailed')} />
) : null}
{legacyFallback ? (
<NoticeBar text={t('trace.detail.legacyTruncated')} />
) : null}
<Section sectionKey="llm.response" title={t('trace.section.response')} defaultOpen>
<ResponseContent
call={effectiveCall}
pending={!isTerminal}
parsedMessage={parsed.response?.message ?? null}
stopReason={parsed.response?.stopReason}
/>
</Section>
{parsed.request && parsed.request.messages.length > 0 ? (
<Section
sectionKey="llm.messages"
title={t('trace.section.messages')}
badge={parsed.request.messages.length}
defaultOpen
>
<MessageList messages={parsed.request.messages} />
</Section>
) : null}
{parsed.request?.system ? (
<Section
sectionKey="llm.systemPrompt"
title={t('trace.section.systemPrompt')}
badge={t('trace.detail.chars', { count: parsed.request.system.length })}
actions={
<CopyButton
text={parsed.request.system}
copiedLabel={t('common.copied')}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
/>
}
>
<pre className="max-h-[400px] overflow-y-auto whitespace-pre-wrap break-words text-[11px] leading-5 text-[var(--color-text-secondary)]">
{parsed.request.system}
</pre>
</Section>
) : null}
{parsed.request && parsed.request.tools.length > 0 ? (
<Section sectionKey="llm.tools" title={t('trace.section.tools')} badge={parsed.request.tools.length}>
<ToolDefinitions tools={parsed.request.tools} />
</Section>
) : null}
{paramEntries.length > 0 ? (
<Section sectionKey="llm.parameters" title={t('trace.section.parameters')} badge={paramEntries.length}>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-[11px]">
{paramEntries.map(([key, value]) => (
<ParamRow key={key} name={key} value={value} />
))}
</dl>
</Section>
) : null}
<Section sectionKey="llm.raw" title={t('trace.section.raw')} defaultOpen={legacyFallback}>
<RawBodies call={effectiveCall} />
</Section>
</div>
)
}
function ResponseContent({
call,
pending,
parsedMessage,
stopReason,
}: {
call: TraceCallRecord
pending: boolean
parsedMessage: NormalizedMessage | null
stopReason?: string
}) {
const t = useTranslation()
if (call.error) {
return (
<div className="rounded-[var(--radius-md)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/40 px-3 py-2">
<div className="text-xs font-semibold text-[var(--color-error)]">{call.error.name}</div>
<div className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)]">{call.error.message}</div>
{call.error.stack ? (
<details className="mt-1.5">
<summary className="cursor-pointer text-[10px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
stack
</summary>
<pre className="mt-1 max-h-[240px] overflow-y-auto whitespace-pre-wrap break-words font-mono text-[10px] leading-4 text-[var(--color-text-tertiary)]">
{call.error.stack}
</pre>
</details>
) : null}
</div>
)
}
if (pending) {
return (
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-3 text-xs text-[var(--color-text-tertiary)]">
<Loader2 size={13} strokeWidth={2} className="animate-spin" />
{t('trace.detail.streaming')}
</div>
)
}
if (!parsedMessage) {
return (
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-3 text-xs text-[var(--color-text-tertiary)]">
{call.response ? t('trace.detail.legacyTruncated') : t('trace.noResponse')}
</div>
)
}
return (
<div className="flex flex-col gap-2">
<MessageBlocks message={parsedMessage} />
{stopReason ? (
<div>
<MetaChip label={t('trace.detail.stopReason')} value={stopReason} />
</div>
) : null}
</div>
)
}
function MessageList({ messages }: { messages: NormalizedMessage[] }) {
const t = useTranslation()
const [showAll, setShowAll] = useState(false)
if (showAll || messages.length <= MESSAGE_FOLD_THRESHOLD) {
return (
<div className="flex flex-col gap-2">
{messages.map((message, index) => <MessageBlocks key={index} message={message} />)}
</div>
)
}
const head = messages.slice(0, MESSAGE_HEAD_COUNT)
const tail = messages.slice(messages.length - MESSAGE_TAIL_COUNT)
const hiddenCount = messages.length - head.length - tail.length
return (
<div className="flex flex-col gap-2">
{head.map((message, index) => <MessageBlocks key={`head-${index}`} message={message} />)}
<button
type="button"
onClick={() => setShowAll(true)}
className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] active:scale-[0.98]"
>
{t('trace.detail.earlierMessages', { count: hiddenCount })}
</button>
{tail.map((message, index) => <MessageBlocks key={`tail-${index}`} message={message} />)}
</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)
return (
<div>
<div className="flex flex-wrap gap-1">
{tools.map((tool) => (
<button
key={tool.name}
type="button"
onClick={() => setExpanded((current) => current === tool.name ? null : tool.name)}
aria-pressed={expanded === tool.name}
{...(tool.description ? { title: tool.description } : {})}
className={`rounded-[var(--radius-sm)] border px-1.5 py-0.5 font-mono text-[10px] transition-colors ${
expanded === tool.name
? 'border-[var(--color-border-focus)] text-[var(--color-text-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}`}
>
{tool.name}
</button>
))}
</div>
{active ? (
<div className="mt-2">
{active.description ? (
<p className="mb-1.5 text-[11px] leading-5 text-[var(--color-text-secondary)]">{active.description}</p>
) : null}
<CodeViewer code={formatTraceJson(active.schema ?? null)} language="json" maxLines={24} showLineNumbers />
</div>
) : null}
</div>
)
}
function ParamRow({ name, value }: { name: string; value: unknown }) {
return (
<>
<dt className="font-mono text-[var(--color-text-tertiary)]">{name}</dt>
<dd className="min-w-0 truncate font-mono text-[var(--color-text-secondary)]" title={stringifyParam(value)}>
{stringifyParam(value)}
</dd>
</>
)
}
function stringifyParam(value: unknown): string {
if (typeof value === 'string') return value
try {
return JSON.stringify(value) ?? 'null'
} catch {
return String(value)
}
}
function RawBodies({ call }: { call: TraceCallRecord }) {
const t = useTranslation()
return (
<div className="flex flex-col gap-3">
<RawBody title={t('trace.requestBody')} body={call.request.body} maxLines={80} />
<RawHeaders title={t('trace.requestHeaders')} headers={call.request.headers} />
{call.response ? (
<>
<RawBody title={t('trace.responseBody')} body={call.response.body} maxLines={80} />
<RawHeaders title={t('trace.responseHeaders')} headers={call.response.headers} />
</>
) : null}
</div>
)
}
function RawBody({ title, body, maxLines }: { title: string; body: TraceBodySnapshot; maxLines: number }) {
const t = useTranslation()
const code = body.contentType === 'json' ? formatTraceJson(body.preview) : body.preview
return (
<div>
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">{title}</span>
<span className="font-mono text-[10px] text-[var(--color-text-tertiary)]">
{formatBytes(body.bytes)}{body.truncated ? ` · ${t('trace.truncatedShort')}` : ''}
</span>
</div>
{code ? (
<CodeViewer code={code} language={body.contentType === 'json' ? 'json' : 'text'} maxLines={maxLines} showLineNumbers={body.contentType === 'json'} />
) : (
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-[11px] text-[var(--color-text-tertiary)]">
{t('trace.noData')}
</div>
)}
</div>
)
}
function RawHeaders({ title, headers }: { title: string; headers: Record<string, string> }) {
return (
<div>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">{title}</div>
<CodeViewer code={formatTraceJson(headers)} language="json" maxLines={20} showLineNumbers />
</div>
)
}
function NoticeBar({ text }: { text: string }) {
return (
<div className="mx-4 mt-3 rounded-[var(--radius-md)] border border-[var(--color-warning)]/30 bg-[var(--color-warning-container)]/30 px-3 py-1.5 text-[11px] text-[var(--color-text-secondary)]">
{text}
</div>
)
}

View File

@ -0,0 +1,196 @@
import { useState, type CSSProperties } from 'react'
import { Wrench } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import type { NormalizedBlock, NormalizedMessage } from '../../../lib/trace/types'
import { MarkdownRenderer } from '../../markdown/MarkdownRenderer'
import { CopyButton } from '../../shared/CopyButton'
import { CodeViewer } from '../../chat/CodeViewer'
const LONG_TEXT_CHARS = 2000
/** Skip off-screen paint without virtualization (WebKit-friendly). */
const MESSAGE_CV_STYLE = {
contentVisibility: 'auto',
containIntrinsicSize: 'auto 120px',
} as const satisfies CSSProperties
const ROLE_STYLES: Record<NormalizedMessage['role'], { badge: string; container: string }> = {
user: {
badge: 'text-[var(--color-info)]',
container: 'border-l-[var(--color-info)] bg-[var(--color-info)]/8',
},
assistant: {
badge: 'text-[var(--color-brand)]',
container: 'border-l-[var(--color-brand)] bg-[var(--color-brand)]/8',
},
system: {
badge: 'text-[var(--color-warning)]',
container: 'border-l-[var(--color-warning)] bg-[var(--color-warning)]/8',
},
tool: {
badge: 'text-[var(--color-text-tertiary)]',
container: 'border-l-[var(--color-outline)] bg-[var(--color-surface-container)]/60',
},
}
export function MessageBlocks({ message }: { message: NormalizedMessage }) {
const styles = ROLE_STYLES[message.role]
return (
<div
className={`rounded-[var(--radius-md)] border-l-2 px-3 py-2 ${styles.container}`}
style={MESSAGE_CV_STYLE}
data-testid={`trace-message-${message.role}`}
>
<div className={`text-[10px] font-semibold uppercase tracking-[0.12em] ${styles.badge}`}>
{message.role}
</div>
<div className="mt-1.5 flex flex-col gap-2">
{message.content.map((block, index) => (
<BlockView key={index} block={block} />
))}
</div>
</div>
)
}
function BlockView({ block }: { block: NormalizedBlock }) {
switch (block.type) {
case 'text':
return <TextBlock text={block.text} />
case 'thinking':
return <ThinkingBlock thinking={block.thinking} />
case 'tool_use':
return <ToolUseBlock id={block.id} name={block.name} input={block.input} />
case 'tool_result':
return <ToolResultBlock toolUseId={block.toolUseId} content={block.content} isError={block.isError} />
case 'image':
return <ImageChip mediaType={block.mediaType} />
default:
return null
}
}
function TextBlock({ text }: { text: string }) {
const t = useTranslation()
if (!text.trim()) return null
if (text.length < LONG_TEXT_CHARS) {
return <MarkdownRenderer content={text} variant="compact" />
}
return (
<div className="relative">
<pre className="max-h-[400px] overflow-y-auto whitespace-pre-wrap break-words rounded-[var(--radius-sm)] bg-[var(--color-surface)]/60 px-2 py-1.5 font-mono text-[11px] leading-5 text-[var(--color-text-secondary)]">
{text}
</pre>
<CopyButton
text={text}
copiedLabel={t('common.copied')}
className="absolute right-1.5 top-1.5 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
/>
</div>
)
}
function ThinkingBlock({ thinking }: { thinking: string }) {
const t = useTranslation()
const [open, setOpen] = useState(false)
return (
<div>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
>
{t('trace.detail.thinking')} · {t('trace.detail.chars', { count: thinking.length })}
</button>
{open ? (
<pre className="mt-1.5 max-h-[300px] overflow-y-auto whitespace-pre-wrap break-words text-[11px] italic leading-5 text-[var(--color-text-tertiary)]">
{thinking}
</pre>
) : null}
</div>
)
}
function ToolUseBlock({ id, name, input }: { id?: string; name: string; input: unknown }) {
return (
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold text-[var(--color-text-secondary)]">
<Wrench size={13} strokeWidth={2} className="shrink-0 text-[var(--color-warning)]" />
<span className="truncate">{name}</span>
{id ? <span className="truncate font-mono text-[10px] font-normal text-[var(--color-text-tertiary)]">{id}</span> : null}
</div>
<div className="mt-1">
<CodeViewer code={safeJson(input)} language="json" maxLines={24} showLineNumbers />
</div>
</div>
)
}
function ToolResultBlock({ toolUseId, content, isError }: { toolUseId?: string; content: unknown; isError?: boolean }) {
const t = useTranslation()
const text = extractPlainText(content)
return (
<div className={`min-w-0 ${isError ? 'rounded-[var(--radius-sm)] border border-[var(--color-error)]/40 p-1.5' : ''}`}>
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold text-[var(--color-text-secondary)]">
<span className={isError ? 'text-[var(--color-error)]' : ''}>
{isError ? t('trace.toolError') : t('trace.toolResult')}
</span>
{toolUseId ? (
<span className="truncate font-mono text-[10px] font-normal text-[var(--color-text-tertiary)]">{toolUseId}</span>
) : null}
</div>
<div className="mt-1">
{text !== null
? <TextResult text={text} />
: <CodeViewer code={safeJson(content)} language="json" maxLines={24} showLineNumbers />}
</div>
</div>
)
}
function TextResult({ text }: { text: string }) {
if (!text.trim()) return null
return (
<pre className="max-h-[400px] overflow-y-auto whitespace-pre-wrap break-words rounded-[var(--radius-sm)] bg-[var(--color-surface)]/60 px-2 py-1.5 font-mono text-[11px] leading-5 text-[var(--color-text-secondary)]">
{text}
</pre>
)
}
function ImageChip({ mediaType }: { mediaType?: string }) {
return (
<span className="inline-flex w-fit items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-tertiary)]">
[image]
{mediaType ? <span>{mediaType}</span> : null}
</span>
)
}
function extractPlainText(content: unknown): string | null {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
const parts: string[] = []
for (const item of content) {
if (typeof item === 'string') {
parts.push(item)
continue
}
if (item && typeof item === 'object' && typeof (item as { text?: unknown }).text === 'string') {
parts.push((item as { text: string }).text)
continue
}
return null
}
return parts.join('\n')
}
return null
}
function safeJson(value: unknown): string {
try {
return JSON.stringify(value, null, 2) ?? 'null'
} catch {
return String(value)
}
}

View File

@ -0,0 +1,60 @@
import { useMemo } from 'react'
import { useTranslation } from '../../../i18n'
import type { MessageEntry } from '../../../types/session'
import type { TraceSpan } from '../../../lib/traceViewModel'
import { formatTraceJson } from '../../../lib/traceViewModel'
import type { NormalizedBlock, NormalizedMessage } from '../../../lib/trace/types'
import { normalizeContentBlock } from '../../../lib/trace/sse'
import { CodeViewer } from '../../chat/CodeViewer'
import { Section } from './Section'
import { MessageBlocks } from './MessageBlocks'
export function MessageDetail({ span }: { span: TraceSpan }) {
const t = useTranslation()
const message = span.message
const normalized = useMemo(
() => message ? normalizeMessageEntry(message) : null,
[message],
)
if (!message || !normalized) return null
return (
<div data-testid="trace-message-detail">
<Section sectionKey="message.content" title={t('trace.section.content')} defaultOpen>
{normalized.content.length > 0 ? (
<MessageBlocks message={normalized} />
) : (
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-3 text-xs text-[var(--color-text-tertiary)]">
{t('trace.noData')}
</div>
)}
</Section>
<Section sectionKey="message.raw" title={t('trace.section.raw')}>
<CodeViewer code={formatTraceJson(message.content)} language="json" maxLines={48} showLineNumbers />
</Section>
</div>
)
}
function normalizeMessageEntry(message: MessageEntry): NormalizedMessage {
const role: NormalizedMessage['role'] =
message.type === 'assistant' || message.type === 'tool_use'
? 'assistant'
: message.type === 'system'
? 'system'
: message.type === 'tool_result'
? 'tool'
: 'user'
const content = message.content
if (typeof content === 'string') {
return { role, content: [{ type: 'text', text: content }] }
}
if (Array.isArray(content)) {
const blocks = content
.map((block) => normalizeContentBlock(block))
.filter((block): block is NormalizedBlock => block !== null)
return { role, content: blocks }
}
return { role, content: [] }
}

View File

@ -0,0 +1,72 @@
import { useEffect, useState, type ReactNode } from 'react'
import { ChevronRight } from 'lucide-react'
/**
* Collapse state is remembered per section key at module level so that
* switching the selected span keeps the user's reading layout intact.
*/
const sectionOpenState = new Map<string, boolean>()
export function resetTraceSectionState(): void {
sectionOpenState.clear()
}
export function Section({
sectionKey,
title,
badge,
actions,
defaultOpen = false,
children,
}: {
sectionKey: string
title: string
badge?: string | number
actions?: ReactNode
defaultOpen?: boolean
children: ReactNode
}) {
const [open, setOpen] = useState(() => sectionOpenState.get(sectionKey) ?? defaultOpen)
// defaultOpen can flip after async detail loads (e.g. legacy fallback opens
// Raw). Follow it until the user toggles this section explicitly.
useEffect(() => {
if (!sectionOpenState.has(sectionKey)) setOpen(defaultOpen)
}, [sectionKey, defaultOpen])
const toggle = () => {
setOpen((previous) => {
sectionOpenState.set(sectionKey, !previous)
return !previous
})
}
return (
<section className="border-t border-[var(--color-border)] first:border-t-0">
<div className="flex items-center gap-2 px-4 py-2">
<button
type="button"
onClick={toggle}
aria-expanded={open}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left transition-colors"
>
<ChevronRight
size={13}
strokeWidth={2}
className={`shrink-0 text-[var(--color-text-tertiary)] transition-transform ${open ? 'rotate-90' : ''}`}
/>
<span className="truncate text-[11px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{title}
</span>
{badge !== undefined ? (
<span className="shrink-0 rounded-[var(--radius-sm)] bg-[var(--color-surface-container)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-tertiary)]">
{badge}
</span>
) : null}
</button>
{actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
</div>
{open ? <div className="px-4 pb-4">{children}</div> : null}
</section>
)
}

View File

@ -0,0 +1,132 @@
import { useMemo } from 'react'
import { useTranslation } from '../../../i18n'
import type { TraceSpan, TraceViewModel } from '../../../lib/traceViewModel'
import { formatDurationMs, formatTokenCount } from '../../../lib/trace/formatters'
import { StatusGlyph, TypeIcon, spanDisplayTitle } from '../TraceBadges'
type OverviewStats = {
llmCalls: number
toolCalls: number
errors: number
durationMs?: number
inputTokens: number
outputTokens: number
models: string[]
}
export function SessionOverview({
span,
viewModel,
onSelect,
}: {
span: TraceSpan
viewModel: TraceViewModel
onSelect: (spanId: string) => void
}) {
const t = useTranslation()
const stats = useMemo(() => computeStats(span, viewModel), [span, viewModel])
const children = span.childIds
.map((id) => viewModel.spansById.get(id))
.filter((child): child is TraceSpan => !!child && child.isLifecycleNoise !== true)
return (
<div className="px-4 py-3" data-testid="trace-overview">
<div className="grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-3">
<Stat label={t('trace.llmCalls')} value={String(stats.llmCalls)} />
<Stat label={t('trace.toolCalls')} value={String(stats.toolCalls)} />
<Stat label={t('trace.errors')} value={String(stats.errors)} tone={stats.errors > 0 ? 'danger' : 'default'} />
<Stat label={t('trace.duration')} value={formatDurationMs(stats.durationMs)} />
<Stat
label={t('trace.tokens')}
value={`${formatTokenCount(stats.inputTokens)}${formatTokenCount(stats.outputTokens)}`}
/>
<Stat label={t('trace.models')} value={stats.models.length > 0 ? stats.models.join(', ') : '--'} />
</div>
{children.length > 0 ? (
<div className="mt-4">
<div className="mb-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{t('trace.childSpans')}
</div>
<div className="divide-y divide-[var(--color-border)]/60">
{children.map((child) => (
<button
key={child.id}
type="button"
onClick={() => onSelect(child.id)}
className="flex h-[34px] w-full items-center gap-2 text-left transition-colors hover:bg-[var(--color-surface-container-low)]"
>
<TypeIcon span={child} />
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--color-text-secondary)]">
{spanDisplayTitle(child, t)}
</span>
{child.durationMs !== undefined ? (
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-tertiary)]">
{formatDurationMs(child.durationMs)}
</span>
) : null}
<StatusGlyph status={child.status} />
</button>
))}
</div>
</div>
) : null}
</div>
)
}
function Stat({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) {
return (
<div className="min-w-0">
<div className="text-[10px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">{label}</div>
<div className={`mt-0.5 truncate font-mono text-xs ${tone === 'danger' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-primary)]'}`}>
{value}
</div>
</div>
)
}
function computeStats(span: TraceSpan, viewModel: TraceViewModel): OverviewStats {
const scoped = span.kind === 'session'
? viewModel.spans.filter((item) => item.id !== viewModel.rootId)
: collectSubtree(span, viewModel)
const stats: OverviewStats = {
llmCalls: 0,
toolCalls: 0,
errors: 0,
inputTokens: 0,
outputTokens: 0,
models: [],
}
const models = new Set<string>()
let llmDuration = 0
for (const item of scoped) {
if (item.kind === 'llm') {
stats.llmCalls += 1
if (item.call?.model) models.add(item.call.model)
if (item.durationMs !== undefined) llmDuration += item.durationMs
if (item.tokenUsage) {
stats.inputTokens += item.tokenUsage.inputTokens
stats.outputTokens += item.tokenUsage.outputTokens
}
}
if (item.kind === 'tool') stats.toolCalls += 1
if (item.status === 'error') stats.errors += 1
}
stats.models = [...models]
const durationMs = span.kind === 'session' ? span.durationMs ?? llmDuration : llmDuration
if (durationMs > 0) stats.durationMs = durationMs
return stats
}
function collectSubtree(span: TraceSpan, viewModel: TraceViewModel): TraceSpan[] {
const result: TraceSpan[] = []
const visit = (id: string) => {
const current = viewModel.spansById.get(id)
if (!current) return
if (current.id !== span.id) result.push(current)
for (const childId of current.childIds) visit(childId)
}
visit(span.id)
return result
}

View File

@ -0,0 +1,99 @@
import { useTranslation } from '../../../i18n'
import type { TraceSpan } from '../../../lib/traceViewModel'
import { formatTraceJson } from '../../../lib/traceViewModel'
import { formatClockTime } from '../../../lib/trace/formatters'
import { CodeViewer } from '../../chat/CodeViewer'
import { Section } from './Section'
export function ToolDetail({ span }: { span: TraceSpan }) {
const t = useTranslation()
const outputs = collectOutputs(span)
const pending = span.status === 'pending'
return (
<div data-testid="trace-tool-detail">
{span.input !== undefined ? (
<Section sectionKey="tool.input" title={t('trace.section.input')} defaultOpen>
<CodeViewer code={formatTraceJson(span.input)} language="json" maxLines={32} showLineNumbers />
</Section>
) : null}
<Section sectionKey="tool.result" title={t('trace.section.result')} defaultOpen>
{pending ? (
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-3 text-xs text-[var(--color-text-tertiary)]">
{t('trace.waitingForResult')}
</div>
) : outputs.length > 0 ? (
<div className="flex flex-col gap-2">
{outputs.map((output, index) => <OutputView key={index} value={output} />)}
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-3 text-xs text-[var(--color-text-tertiary)]">
{t('trace.noData')}
</div>
)}
</Section>
<Section sectionKey="tool.meta" title={t('trace.section.meta')}>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-[11px]">
{span.toolUseId ? (
<MetaRow label={t('trace.detail.toolUseId')} value={span.toolUseId} />
) : null}
<MetaRow
label={t('trace.status')}
value={span.status === 'error' ? t('trace.status.error') : span.status === 'pending' ? t('trace.status.pending') : t('trace.status.ok')}
/>
<MetaRow label={t('trace.started')} value={formatClockTime(span.timestamp)} />
</dl>
</Section>
</div>
)
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<>
<dt className="text-[var(--color-text-tertiary)]">{label}</dt>
<dd className="min-w-0 truncate font-mono text-[var(--color-text-secondary)]">{value}</dd>
</>
)
}
function OutputView({ value }: { value: unknown }) {
const text = extractPlainText(value)
if (text !== null) {
if (!text.trim()) return null
return (
<pre className="max-h-[400px] overflow-y-auto whitespace-pre-wrap break-words rounded-[var(--radius-sm)] bg-[var(--color-surface-container-low)] px-2 py-1.5 font-mono text-[11px] leading-5 text-[var(--color-text-secondary)]">
{text}
</pre>
)
}
return <CodeViewer code={formatTraceJson(value)} language="json" maxLines={32} showLineNumbers />
}
function collectOutputs(span: TraceSpan): unknown[] {
if (span.output === undefined) return []
if (Array.isArray(span.output)) return span.output
return [span.output]
}
function extractPlainText(content: unknown): string | null {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
const parts: string[] = []
for (const item of content) {
if (typeof item === 'string') {
parts.push(item)
continue
}
if (item && typeof item === 'object' && typeof (item as { text?: unknown }).text === 'string') {
parts.push((item as { text: string }).text)
continue
}
return null
}
return parts.join('\n')
}
return null
}

View File

@ -1661,7 +1661,6 @@ export const en = {
'trace.open': 'Trace',
'trace.title': 'Model trace',
'trace.loadFailed': 'Failed to load trace',
'trace.noModel': 'No model calls',
'trace.apiCalls': 'API calls',
'trace.failedCalls': 'Failed',
'trace.duration': 'Duration',
@ -1669,28 +1668,18 @@ export const en = {
'trace.request': 'Request',
'trace.response': 'Response',
'trace.status': 'Status',
'trace.querySource': 'Source',
'trace.size': 'Size',
'trace.live': 'Live',
'trace.updatedAt': 'Updated',
'trace.copySessionId': 'Copy session ID',
'trace.refresh': 'Refresh trace',
'trace.openWindow': 'Open in separate window',
'trace.missingSession': 'Missing trace session id',
'trace.noResponse': 'No response body captured',
'trace.truncated': 'Preview truncated to protect desktop performance.',
'trace.emptyTitle': 'No trace calls yet',
'trace.emptyBody': 'This session has no captured model calls. New desktop sessions record compact request and response previews automatically.',
'trace.sessionTrace': 'Session trace',
'trace.tokens': 'Tokens',
'trace.snapshotEmpty': 'Trace snapshot is empty.',
'trace.diagnosis': 'Diagnosis',
'trace.lastActivity': 'Last activity',
'trace.errors': 'Errors',
'trace.pendingModels': 'Pending models',
'trace.pendingTools': 'Pending tools',
'trace.focus': 'Focus',
'trace.runTree': 'Run tree',
'trace.searchSpans': 'Search spans',
'trace.noMatchingSpans': 'No matching spans',
'trace.filter.all': 'All',
@ -1698,60 +1687,21 @@ export const en = {
'trace.filter.tools': 'Tools',
'trace.filter.errors': 'Errors',
'trace.sidechain': 'sub',
'trace.thread': 'Thread',
'trace.threadStats': '{turns} turns · {spans} spans · live polling',
'trace.turnCount': '{count} turns',
'trace.spanCount': '{count} spans',
'trace.turnLabel': 'Turn {index}',
'trace.stat.llm': 'LLM',
'trace.stat.tools': 'tools',
'trace.stat.errors': 'errors',
'trace.waitingForResult': 'Waiting for result',
'trace.emptyMessage': 'Empty message',
'trace.inspectorAria': 'Trace span details',
'trace.tab.overview': 'Overview',
'trace.tab.input': 'Input',
'trace.tab.output': 'Output',
'trace.tab.metadata': 'Metadata',
'trace.tab.raw': 'Raw',
'trace.kind': 'Kind',
'trace.started': 'Started',
'trace.provider': 'Provider',
'trace.model': 'Model',
'trace.notAvailable': 'n/a',
'trace.sessionSummary': 'Session summary',
'trace.childSpans': 'Child spans',
'trace.requestBody': 'Request body',
'trace.requestHeaders': 'Request headers',
'trace.toolArguments': 'Tool arguments',
'trace.messageContent': 'Message content',
'trace.eventPayload': 'Event payload',
'trace.noInput': 'No input captured for this span.',
'trace.responseBody': 'Response body',
'trace.responseHeaders': 'Response headers',
'trace.toolResult': 'Tool result',
'trace.toolResultPending': 'Tool result has not arrived yet.',
'trace.noSeparateOutput': 'This span does not have a separate output.',
'trace.spanMetadata': 'Span metadata',
'trace.rawScope.span': 'This span',
'trace.rawScope.full': 'Full trace',
'trace.rawSpanJson': 'Raw span JSON',
'trace.rawTraceJson': 'Raw trace JSON',
'trace.truncatedShort': 'truncated',
'trace.emptyBodyShort': 'Empty body',
'trace.noData': 'No data',
'trace.spanFailed': 'This span failed.',
'trace.trace': 'Trace',
'trace.turns': 'Turns',
'trace.llmCalls': 'LLM calls',
'trace.toolCalls': 'Tool calls',
'trace.kind.session': 'Session',
'trace.kind.turn': 'Turn',
'trace.kind.llm': 'LLM call',
'trace.kind.tool': 'Tool call',
'trace.kind.toolResult': 'Tool result',
'trace.kind.message': 'Message',
'trace.kind.event': 'Event',
'trace.status.ok': 'ok',
'trace.status.error': 'error',
'trace.status.pending': 'pending',
@ -1762,9 +1712,7 @@ export const en = {
'trace.message.toolResult': 'Tool result',
'trace.toolError': 'Tool error',
'trace.modelCall': 'Model call',
'trace.modelCalls': '{count} model calls',
'trace.sessionActivity': 'Session activity',
'trace.emptyValue': 'empty',
'trace.diagnosis.modelError': 'Model call failed',
'trace.diagnosis.toolError': 'Tool execution failed',
'trace.diagnosis.eventError': 'Trace event failed',
@ -1780,19 +1728,41 @@ export const en = {
'trace.event.upstreamFetchStarted': 'Upstream fetch started',
'trace.event.upstreamFetchCompleted': 'Upstream fetch completed',
'trace.event.upstreamFetchFailed': 'Upstream fetch failed',
'trace.section.response': 'Response',
'trace.section.messages': 'Messages',
'trace.section.systemPrompt': 'System prompt',
'trace.section.tools': 'Tools',
'trace.section.parameters': 'Parameters',
'trace.section.raw': 'Raw',
'trace.section.input': 'Input',
'trace.section.result': 'Result',
'trace.section.meta': 'Meta',
'trace.section.content': 'Content',
'trace.section.event': 'Event',
'trace.section.metadata': 'Metadata',
'trace.detail.stopReason': 'Stop reason',
'trace.detail.streaming': 'Waiting for the model response...',
'trace.detail.legacyTruncated': 'Legacy truncated record; the semantic view is unavailable. See Raw below.',
'trace.detail.fetchFailed': 'Failed to load the full record; showing the truncated preview.',
'trace.detail.earlierMessages': 'Show {count} earlier messages',
'trace.detail.chars': '{count} chars',
'trace.detail.thinking': 'thinking',
'trace.detail.toolUseId': 'Tool use ID',
'trace.detail.phase': 'Phase',
'trace.detail.severity': 'Severity',
'trace.detail.message': 'Message',
'trace.tree.aria': 'Trace timeline',
'trace.tree.toggleTurn': 'Toggle turn',
'trace.list.eyebrow': 'Agent trace',
'trace.list.title': 'Trace list',
'trace.list.collecting': 'Collecting',
'trace.list.paused': 'Paused',
'trace.list.settings': 'Trace settings',
'trace.list.sessions': 'Sessions',
'trace.list.session': 'Session',
'trace.list.actions': 'Actions',
'trace.list.searchPlaceholder': 'Search title, session ID, or project path',
'trace.list.emptyTitle': 'No trace records yet',
'trace.list.emptyBody': 'When collection is enabled, new desktop sessions write model-call traces to the local traces directory.',
'trace.list.loadFailed': 'Failed to load trace list',
'trace.list.fileSize': 'File size',
'trace.list.loadedCount': 'Showing {shown} of {total}',
'trace.list.loadMore': 'Load more',

View File

@ -1663,7 +1663,6 @@ export const jp: Record<TranslationKey, string> = {
'trace.open': 'Trace',
'trace.title': 'モデル Trace',
'trace.loadFailed': 'Trace の読み込みに失敗しました',
'trace.noModel': 'モデル呼び出しなし',
'trace.apiCalls': 'API 呼び出し',
'trace.failedCalls': '失敗',
'trace.duration': '所要時間',
@ -1671,28 +1670,18 @@ export const jp: Record<TranslationKey, string> = {
'trace.request': 'リクエスト',
'trace.response': 'レスポンス',
'trace.status': 'ステータス',
'trace.querySource': 'ソース',
'trace.size': 'サイズ',
'trace.live': 'ライブ',
'trace.updatedAt': '更新',
'trace.copySessionId': 'Session ID をコピー',
'trace.refresh': 'Trace を更新',
'trace.openWindow': '別ウィンドウで開く',
'trace.missingSession': 'Trace session id がありません',
'trace.noResponse': 'レスポンス本文はキャプチャされていません',
'trace.truncated': 'デスクトップ性能を保護するため、プレビューは切り詰められています。',
'trace.emptyTitle': 'Trace 呼び出しはまだありません',
'trace.emptyBody': 'このセッションにはキャプチャ済みのモデル呼び出しがありません。新しいデスクトップセッションでは、軽量なリクエストとレスポンスのプレビューを自動記録します。',
'trace.sessionTrace': 'セッショントレース',
'trace.tokens': 'Tokens',
'trace.snapshotEmpty': 'Trace スナップショットは空です。',
'trace.diagnosis': '診断',
'trace.lastActivity': '最終アクティビティ',
'trace.errors': 'エラー',
'trace.pendingModels': '待機中のモデル',
'trace.pendingTools': '待機中のツール',
'trace.focus': '注目箇所',
'trace.runTree': '実行ツリー',
'trace.searchSpans': 'Span を検索',
'trace.noMatchingSpans': '一致する Span はありません',
'trace.filter.all': 'すべて',
@ -1700,60 +1689,21 @@ export const jp: Record<TranslationKey, string> = {
'trace.filter.tools': 'ツール',
'trace.filter.errors': 'エラー',
'trace.sidechain': '子',
'trace.thread': 'スレッド',
'trace.threadStats': '{turns} ターン · {spans} Span · ライブポーリング',
'trace.turnCount': '{count} ターン',
'trace.spanCount': '{count} Span',
'trace.turnLabel': 'ターン {index}',
'trace.stat.llm': 'LLM',
'trace.stat.tools': 'ツール',
'trace.stat.errors': 'エラー',
'trace.waitingForResult': '結果待ち',
'trace.emptyMessage': '空のメッセージ',
'trace.inspectorAria': 'Trace Span 詳細',
'trace.tab.overview': '概要',
'trace.tab.input': '入力',
'trace.tab.output': '出力',
'trace.tab.metadata': 'メタデータ',
'trace.tab.raw': 'Raw',
'trace.kind': '種類',
'trace.started': '開始',
'trace.provider': 'Provider',
'trace.model': 'モデル',
'trace.notAvailable': 'なし',
'trace.sessionSummary': 'セッション概要',
'trace.childSpans': '子 Span',
'trace.requestBody': 'リクエスト本文',
'trace.requestHeaders': 'リクエストヘッダー',
'trace.toolArguments': 'ツール引数',
'trace.messageContent': 'メッセージ内容',
'trace.eventPayload': 'イベントペイロード',
'trace.noInput': 'この Span の入力はキャプチャされていません。',
'trace.responseBody': 'レスポンス本文',
'trace.responseHeaders': 'レスポンスヘッダー',
'trace.toolResult': 'ツール結果',
'trace.toolResultPending': 'ツール結果はまだ届いていません。',
'trace.noSeparateOutput': 'この Span には個別の出力がありません。',
'trace.spanMetadata': 'Span メタデータ',
'trace.rawScope.span': 'この Span',
'trace.rawScope.full': 'Trace 全体',
'trace.rawSpanJson': 'Raw Span JSON',
'trace.rawTraceJson': 'Raw Trace JSON',
'trace.truncatedShort': '切り詰め済み',
'trace.emptyBodyShort': '空の本文',
'trace.noData': 'データなし',
'trace.spanFailed': 'この Span は失敗しました。',
'trace.trace': 'Trace',
'trace.turns': 'ターン',
'trace.llmCalls': 'LLM 呼び出し',
'trace.toolCalls': 'ツール呼び出し',
'trace.kind.session': 'セッション',
'trace.kind.turn': 'ターン',
'trace.kind.llm': 'LLM 呼び出し',
'trace.kind.tool': 'ツール呼び出し',
'trace.kind.toolResult': 'ツール結果',
'trace.kind.message': 'メッセージ',
'trace.kind.event': 'イベント',
'trace.status.ok': '正常',
'trace.status.error': 'エラー',
'trace.status.pending': '待機中',
@ -1764,9 +1714,7 @@ export const jp: Record<TranslationKey, string> = {
'trace.message.toolResult': 'ツール結果',
'trace.toolError': 'ツールエラー',
'trace.modelCall': 'モデル呼び出し',
'trace.modelCalls': '{count} 件のモデル呼び出し',
'trace.sessionActivity': 'セッションアクティビティ',
'trace.emptyValue': '空',
'trace.diagnosis.modelError': 'モデル呼び出しに失敗しました',
'trace.diagnosis.toolError': 'ツール実行に失敗しました',
'trace.diagnosis.eventError': 'トレースイベントに失敗しました',
@ -1782,19 +1730,41 @@ export const jp: Record<TranslationKey, string> = {
'trace.event.upstreamFetchStarted': 'アップストリーム要求開始',
'trace.event.upstreamFetchCompleted': 'アップストリーム要求完了',
'trace.event.upstreamFetchFailed': 'アップストリーム要求失敗',
'trace.section.response': 'レスポンス',
'trace.section.messages': 'メッセージ',
'trace.section.systemPrompt': 'システムプロンプト',
'trace.section.tools': 'ツール',
'trace.section.parameters': 'パラメータ',
'trace.section.raw': '生データ',
'trace.section.input': '入力',
'trace.section.result': '結果',
'trace.section.meta': 'メタ情報',
'trace.section.content': '内容',
'trace.section.event': 'イベント',
'trace.section.metadata': 'メタデータ',
'trace.detail.stopReason': '停止理由',
'trace.detail.streaming': 'モデルの応答を待機中...',
'trace.detail.legacyTruncated': 'このレコードは旧形式の切り詰められたデータのため、セマンティック表示はできません。下の生データを確認してください。',
'trace.detail.fetchFailed': '完全なレコードの読み込みに失敗したため、切り詰められたプレビューを表示しています。',
'trace.detail.earlierMessages': '以前の {count} 件のメッセージを表示',
'trace.detail.chars': '{count} 文字',
'trace.detail.thinking': '思考',
'trace.detail.toolUseId': 'Tool use ID',
'trace.detail.phase': 'フェーズ',
'trace.detail.severity': '重要度',
'trace.detail.message': 'メッセージ',
'trace.tree.aria': 'トレースタイムライン',
'trace.tree.toggleTurn': 'ターンを開閉',
'trace.list.eyebrow': 'Agent Trace',
'trace.list.title': 'Trace 一覧',
'trace.list.collecting': '収集中',
'trace.list.paused': '一時停止',
'trace.list.settings': 'Trace 設定',
'trace.list.sessions': 'セッション',
'trace.list.session': 'Session',
'trace.list.actions': '操作',
'trace.list.searchPlaceholder': 'タイトル、Session ID、プロジェクトパスを検索',
'trace.list.emptyTitle': 'Trace 記録はまだありません',
'trace.list.emptyBody': '収集を有効にすると、新しいデスクトップセッションはモデル呼び出しトレースをローカルの traces ディレクトリに書き込みます。',
'trace.list.loadFailed': 'Trace 一覧の読み込みに失敗しました',
'trace.list.fileSize': 'ファイルサイズ',
'trace.list.loadedCount': '{shown} / {total} を表示中',
'trace.list.loadMore': 'さらに読み込む',

View File

@ -1663,7 +1663,6 @@ export const kr: Record<TranslationKey, string> = {
'trace.open': 'Trace',
'trace.title': '모델 Trace',
'trace.loadFailed': 'Trace를 불러오지 못했습니다',
'trace.noModel': '모델 호출 없음',
'trace.apiCalls': 'API 호출',
'trace.failedCalls': '실패',
'trace.duration': '소요 시간',
@ -1671,28 +1670,18 @@ export const kr: Record<TranslationKey, string> = {
'trace.request': '요청',
'trace.response': '응답',
'trace.status': '상태',
'trace.querySource': '소스',
'trace.size': '크기',
'trace.live': '실시간',
'trace.updatedAt': '업데이트',
'trace.copySessionId': 'Session ID 복사',
'trace.refresh': 'Trace 새로고침',
'trace.openWindow': '별도 창에서 열기',
'trace.missingSession': 'Trace session id가 없습니다',
'trace.noResponse': '응답 본문이 캡처되지 않았습니다',
'trace.truncated': '데스크톱 성능을 보호하기 위해 미리보기가 잘렸습니다.',
'trace.emptyTitle': '아직 Trace 호출이 없습니다',
'trace.emptyBody': '이 세션에는 캡처된 모델 호출이 없습니다. 새 데스크톱 세션은 간단한 요청 및 응답 미리보기를 자동으로 기록합니다.',
'trace.sessionTrace': '세션 Trace',
'trace.tokens': 'Tokens',
'trace.snapshotEmpty': 'Trace 스냅샷이 비어 있습니다.',
'trace.diagnosis': '진단',
'trace.lastActivity': '마지막 활동',
'trace.errors': '오류',
'trace.pendingModels': '대기 중인 모델',
'trace.pendingTools': '대기 중인 도구',
'trace.focus': '집중 지점',
'trace.runTree': '실행 트리',
'trace.searchSpans': 'Span 검색',
'trace.noMatchingSpans': '일치하는 Span이 없습니다',
'trace.filter.all': '전체',
@ -1700,60 +1689,21 @@ export const kr: Record<TranslationKey, string> = {
'trace.filter.tools': '도구',
'trace.filter.errors': '오류',
'trace.sidechain': '하위',
'trace.thread': '스레드',
'trace.threadStats': '{turns} 턴 · {spans} Span · 실시간 폴링',
'trace.turnCount': '{count} 턴',
'trace.spanCount': '{count} Span',
'trace.turnLabel': '{index}번째 턴',
'trace.stat.llm': 'LLM',
'trace.stat.tools': '도구',
'trace.stat.errors': '오류',
'trace.waitingForResult': '결과 대기 중',
'trace.emptyMessage': '빈 메시지',
'trace.inspectorAria': 'Trace Span 상세',
'trace.tab.overview': '개요',
'trace.tab.input': '입력',
'trace.tab.output': '출력',
'trace.tab.metadata': '메타데이터',
'trace.tab.raw': 'Raw',
'trace.kind': '종류',
'trace.started': '시작',
'trace.provider': 'Provider',
'trace.model': '모델',
'trace.notAvailable': '없음',
'trace.sessionSummary': '세션 요약',
'trace.childSpans': '하위 Span',
'trace.requestBody': '요청 본문',
'trace.requestHeaders': '요청 헤더',
'trace.toolArguments': '도구 인수',
'trace.messageContent': '메시지 내용',
'trace.eventPayload': '이벤트 페이로드',
'trace.noInput': '이 Span에는 캡처된 입력이 없습니다.',
'trace.responseBody': '응답 본문',
'trace.responseHeaders': '응답 헤더',
'trace.toolResult': '도구 결과',
'trace.toolResultPending': '도구 결과가 아직 도착하지 않았습니다.',
'trace.noSeparateOutput': '이 Span에는 별도 출력이 없습니다.',
'trace.spanMetadata': 'Span 메타데이터',
'trace.rawScope.span': '현재 Span',
'trace.rawScope.full': '전체 Trace',
'trace.rawSpanJson': '원본 Span JSON',
'trace.rawTraceJson': '원본 Trace JSON',
'trace.truncatedShort': '잘림',
'trace.emptyBodyShort': '빈 본문',
'trace.noData': '데이터 없음',
'trace.spanFailed': '이 Span은 실패했습니다.',
'trace.trace': 'Trace',
'trace.turns': '턴',
'trace.llmCalls': 'LLM 호출',
'trace.toolCalls': '도구 호출',
'trace.kind.session': '세션',
'trace.kind.turn': '턴',
'trace.kind.llm': 'LLM 호출',
'trace.kind.tool': '도구 호출',
'trace.kind.toolResult': '도구 결과',
'trace.kind.message': '메시지',
'trace.kind.event': '이벤트',
'trace.status.ok': '정상',
'trace.status.error': '오류',
'trace.status.pending': '대기 중',
@ -1764,9 +1714,7 @@ export const kr: Record<TranslationKey, string> = {
'trace.message.toolResult': '도구 결과',
'trace.toolError': '도구 오류',
'trace.modelCall': '모델 호출',
'trace.modelCalls': '모델 호출 {count}회',
'trace.sessionActivity': '세션 활동',
'trace.emptyValue': '비어 있음',
'trace.diagnosis.modelError': '모델 호출 실패',
'trace.diagnosis.toolError': '도구 실행 실패',
'trace.diagnosis.eventError': '트레이스 이벤트 실패',
@ -1782,19 +1730,41 @@ export const kr: Record<TranslationKey, string> = {
'trace.event.upstreamFetchStarted': '업스트림 요청 시작',
'trace.event.upstreamFetchCompleted': '업스트림 요청 완료',
'trace.event.upstreamFetchFailed': '업스트림 요청 실패',
'trace.section.response': '응답',
'trace.section.messages': '메시지',
'trace.section.systemPrompt': '시스템 프롬프트',
'trace.section.tools': '도구',
'trace.section.parameters': '파라미터',
'trace.section.raw': '원본 데이터',
'trace.section.input': '입력',
'trace.section.result': '결과',
'trace.section.meta': '메타 정보',
'trace.section.content': '내용',
'trace.section.event': '이벤트',
'trace.section.metadata': '메타데이터',
'trace.detail.stopReason': '중지 사유',
'trace.detail.streaming': '모델 응답을 기다리는 중...',
'trace.detail.legacyTruncated': '이 레코드는 구형식의 잘린 데이터여서 시맨틱 보기를 사용할 수 없습니다. 아래 원본 데이터를 확인하세요.',
'trace.detail.fetchFailed': '전체 레코드를 불러오지 못해 잘린 미리보기를 표시합니다.',
'trace.detail.earlierMessages': '이전 메시지 {count}개 보기',
'trace.detail.chars': '{count}자',
'trace.detail.thinking': '생각',
'trace.detail.toolUseId': 'Tool use ID',
'trace.detail.phase': '단계',
'trace.detail.severity': '심각도',
'trace.detail.message': '메시지',
'trace.tree.aria': '트레이스 타임라인',
'trace.tree.toggleTurn': '턴 접기/펼치기',
'trace.list.eyebrow': 'Agent Trace',
'trace.list.title': 'Trace 목록',
'trace.list.collecting': '수집 중',
'trace.list.paused': '일시 중지됨',
'trace.list.settings': 'Trace 설정',
'trace.list.sessions': '세션',
'trace.list.session': 'Session',
'trace.list.actions': '작업',
'trace.list.searchPlaceholder': '제목, Session ID 또는 프로젝트 경로 검색',
'trace.list.emptyTitle': '아직 Trace 기록이 없습니다',
'trace.list.emptyBody': '수집을 켜면 새 데스크톱 세션이 모델 호출 트레이스를 로컬 traces 디렉터리에 기록합니다.',
'trace.list.loadFailed': 'Trace 목록을 불러오지 못했습니다',
'trace.list.fileSize': '파일 크기',
'trace.list.loadedCount': '{shown} / {total} 표시 중',
'trace.list.loadMore': '더 불러오기',

View File

@ -1663,7 +1663,6 @@ export const zh: Record<TranslationKey, string> = {
'trace.open': 'Trace',
'trace.title': '模型 Trace',
'trace.loadFailed': 'Trace 載入失敗',
'trace.noModel': '暫無模型呼叫',
'trace.apiCalls': 'API 呼叫',
'trace.failedCalls': '失敗',
'trace.duration': '耗時',
@ -1671,28 +1670,18 @@ export const zh: Record<TranslationKey, string> = {
'trace.request': '請求',
'trace.response': '回應',
'trace.status': '狀態',
'trace.querySource': '來源',
'trace.size': '大小',
'trace.live': '即時',
'trace.updatedAt': '更新',
'trace.copySessionId': '複製 Session ID',
'trace.refresh': '重新整理 Trace',
'trace.openWindow': '在獨立視窗開啟',
'trace.missingSession': '缺少 Trace session id',
'trace.noResponse': '未捕獲回應正文',
'trace.truncated': '預覽已截斷,以避免影響桌面端效能。',
'trace.emptyTitle': '暫無 Trace 呼叫',
'trace.emptyBody': '這個會話還沒有捕獲到模型呼叫。新的桌面會話會自動記錄精簡的請求和回應預覽。',
'trace.sessionTrace': '會話鏈路',
'trace.tokens': 'Tokens',
'trace.snapshotEmpty': 'Trace 快照為空。',
'trace.diagnosis': '診斷',
'trace.lastActivity': '最後活動',
'trace.errors': '錯誤',
'trace.pendingModels': '等待模型',
'trace.pendingTools': '等待工具',
'trace.focus': '關注點',
'trace.runTree': '執行樹',
'trace.searchSpans': '搜尋 Span',
'trace.noMatchingSpans': '沒有符合的 Span',
'trace.filter.all': '全部',
@ -1700,60 +1689,21 @@ export const zh: Record<TranslationKey, string> = {
'trace.filter.tools': '工具',
'trace.filter.errors': '錯誤',
'trace.sidechain': '子鏈',
'trace.thread': '對話執行緒',
'trace.threadStats': '{turns} 輪 · {spans} 個 Span · 即時輪詢',
'trace.turnCount': '{count} 輪',
'trace.spanCount': '{count} 個 Span',
'trace.turnLabel': '第 {index} 輪',
'trace.stat.llm': 'LLM',
'trace.stat.tools': '工具',
'trace.stat.errors': '錯誤',
'trace.waitingForResult': '等待結果',
'trace.emptyMessage': '空訊息',
'trace.inspectorAria': 'Trace Span 詳情',
'trace.tab.overview': '概覽',
'trace.tab.input': '輸入',
'trace.tab.output': '輸出',
'trace.tab.metadata': '元資料',
'trace.tab.raw': '原始',
'trace.kind': '類型',
'trace.started': '開始',
'trace.provider': 'Provider',
'trace.model': '模型',
'trace.notAvailable': '無',
'trace.sessionSummary': '會話摘要',
'trace.childSpans': '子 Span',
'trace.requestBody': '請求正文',
'trace.requestHeaders': '請求頭',
'trace.toolArguments': '工具參數',
'trace.messageContent': '訊息內容',
'trace.eventPayload': '事件負載',
'trace.noInput': '這個 Span 沒有捕獲輸入。',
'trace.responseBody': '回應正文',
'trace.responseHeaders': '回應頭',
'trace.toolResult': '工具結果',
'trace.toolResultPending': '工具結果還沒有返回。',
'trace.noSeparateOutput': '這個 Span 沒有單獨的輸出。',
'trace.spanMetadata': 'Span 元資料',
'trace.rawScope.span': '目前 Span',
'trace.rawScope.full': '完整 Trace',
'trace.rawSpanJson': '原始 Span JSON',
'trace.rawTraceJson': '原始 Trace JSON',
'trace.truncatedShort': '已截斷',
'trace.emptyBodyShort': '空正文',
'trace.noData': '無資料',
'trace.spanFailed': '這個 Span 失敗了。',
'trace.trace': 'Trace',
'trace.turns': '輪次',
'trace.llmCalls': 'LLM 呼叫',
'trace.toolCalls': '工具呼叫',
'trace.kind.session': '會話',
'trace.kind.turn': '輪次',
'trace.kind.llm': 'LLM 呼叫',
'trace.kind.tool': '工具呼叫',
'trace.kind.toolResult': '工具結果',
'trace.kind.message': '訊息',
'trace.kind.event': '事件',
'trace.status.ok': '正常',
'trace.status.error': '錯誤',
'trace.status.pending': '等待中',
@ -1764,9 +1714,7 @@ export const zh: Record<TranslationKey, string> = {
'trace.message.toolResult': '工具結果',
'trace.toolError': '工具錯誤',
'trace.modelCall': '模型呼叫',
'trace.modelCalls': '{count} 次模型呼叫',
'trace.sessionActivity': '會話活動',
'trace.emptyValue': '空',
'trace.diagnosis.modelError': '模型呼叫失敗',
'trace.diagnosis.toolError': '工具執行失敗',
'trace.diagnosis.eventError': '鏈路事件失敗',
@ -1782,19 +1730,41 @@ export const zh: Record<TranslationKey, string> = {
'trace.event.upstreamFetchStarted': '上游請求開始',
'trace.event.upstreamFetchCompleted': '上游請求完成',
'trace.event.upstreamFetchFailed': '上游請求失敗',
'trace.section.response': '回應',
'trace.section.messages': '訊息',
'trace.section.systemPrompt': '系統提示詞',
'trace.section.tools': '工具',
'trace.section.parameters': '參數',
'trace.section.raw': '原始資料',
'trace.section.input': '輸入',
'trace.section.result': '結果',
'trace.section.meta': '中繼資訊',
'trace.section.content': '內容',
'trace.section.event': '事件',
'trace.section.metadata': '中繼資料',
'trace.detail.stopReason': '停止原因',
'trace.detail.streaming': '等待模型回應...',
'trace.detail.legacyTruncated': '此記錄為舊格式截斷資料,無法解析語意檢視,請查看下方原始資料。',
'trace.detail.fetchFailed': '完整記錄載入失敗,以下顯示截斷預覽。',
'trace.detail.earlierMessages': '展開更早的 {count} 則訊息',
'trace.detail.chars': '{count} 字元',
'trace.detail.thinking': '思考',
'trace.detail.toolUseId': 'Tool use ID',
'trace.detail.phase': '階段',
'trace.detail.severity': '層級',
'trace.detail.message': '訊息',
'trace.tree.aria': 'Trace 時間軸',
'trace.tree.toggleTurn': '摺疊/展開輪次',
'trace.list.eyebrow': 'Agent Trace',
'trace.list.title': 'Trace 列表',
'trace.list.collecting': '正在收集',
'trace.list.paused': '已暫停',
'trace.list.settings': 'Trace 設定',
'trace.list.sessions': '會話',
'trace.list.session': 'Session',
'trace.list.actions': '操作',
'trace.list.searchPlaceholder': '搜尋標題、Session ID 或專案路徑',
'trace.list.emptyTitle': '還沒有 Trace 記錄',
'trace.list.emptyBody': '開啟收集後,新的桌面會話會把模型呼叫鏈路寫入本地 traces 目錄。',
'trace.list.loadFailed': 'Trace 列表載入失敗',
'trace.list.fileSize': '檔案大小',
'trace.list.loadedCount': '已顯示 {shown} / {total}',
'trace.list.loadMore': '載入更多',

View File

@ -1663,7 +1663,6 @@ export const zh: Record<TranslationKey, string> = {
'trace.open': 'Trace',
'trace.title': '模型 Trace',
'trace.loadFailed': 'Trace 加载失败',
'trace.noModel': '暂无模型调用',
'trace.apiCalls': 'API 调用',
'trace.failedCalls': '失败',
'trace.duration': '耗时',
@ -1671,28 +1670,18 @@ export const zh: Record<TranslationKey, string> = {
'trace.request': '请求',
'trace.response': '响应',
'trace.status': '状态',
'trace.querySource': '来源',
'trace.size': '大小',
'trace.live': '实时',
'trace.updatedAt': '更新',
'trace.copySessionId': '复制 Session ID',
'trace.refresh': '刷新 Trace',
'trace.openWindow': '在独立窗口打开',
'trace.missingSession': '缺少 Trace session id',
'trace.noResponse': '未捕获响应正文',
'trace.truncated': '预览已截断,以避免影响桌面端性能。',
'trace.emptyTitle': '暂无 Trace 调用',
'trace.emptyBody': '这个会话还没有捕获到模型调用。新的桌面会话会自动记录精简的请求和响应预览。',
'trace.sessionTrace': '会话链路',
'trace.tokens': 'Tokens',
'trace.snapshotEmpty': 'Trace 快照为空。',
'trace.diagnosis': '诊断',
'trace.lastActivity': '最后活动',
'trace.errors': '错误',
'trace.pendingModels': '等待模型',
'trace.pendingTools': '等待工具',
'trace.focus': '关注点',
'trace.runTree': '运行树',
'trace.searchSpans': '搜索 Span',
'trace.noMatchingSpans': '没有匹配的 Span',
'trace.filter.all': '全部',
@ -1700,60 +1689,21 @@ export const zh: Record<TranslationKey, string> = {
'trace.filter.tools': '工具',
'trace.filter.errors': '错误',
'trace.sidechain': '子链',
'trace.thread': '对话线程',
'trace.threadStats': '{turns} 轮 · {spans} 个 Span · 实时轮询',
'trace.turnCount': '{count} 轮',
'trace.spanCount': '{count} 个 Span',
'trace.turnLabel': '第 {index} 轮',
'trace.stat.llm': 'LLM',
'trace.stat.tools': '工具',
'trace.stat.errors': '错误',
'trace.waitingForResult': '等待结果',
'trace.emptyMessage': '空消息',
'trace.inspectorAria': 'Trace Span 详情',
'trace.tab.overview': '概览',
'trace.tab.input': '输入',
'trace.tab.output': '输出',
'trace.tab.metadata': '元数据',
'trace.tab.raw': '原始',
'trace.kind': '类型',
'trace.started': '开始',
'trace.provider': 'Provider',
'trace.model': '模型',
'trace.notAvailable': '无',
'trace.sessionSummary': '会话摘要',
'trace.childSpans': '子 Span',
'trace.requestBody': '请求正文',
'trace.requestHeaders': '请求头',
'trace.toolArguments': '工具参数',
'trace.messageContent': '消息内容',
'trace.eventPayload': '事件负载',
'trace.noInput': '这个 Span 没有捕获输入。',
'trace.responseBody': '响应正文',
'trace.responseHeaders': '响应头',
'trace.toolResult': '工具结果',
'trace.toolResultPending': '工具结果还没有返回。',
'trace.noSeparateOutput': '这个 Span 没有单独的输出。',
'trace.spanMetadata': 'Span 元数据',
'trace.rawScope.span': '当前 Span',
'trace.rawScope.full': '完整 Trace',
'trace.rawSpanJson': '原始 Span JSON',
'trace.rawTraceJson': '原始 Trace JSON',
'trace.truncatedShort': '已截断',
'trace.emptyBodyShort': '空正文',
'trace.noData': '无数据',
'trace.spanFailed': '这个 Span 失败了。',
'trace.trace': 'Trace',
'trace.turns': '轮次',
'trace.llmCalls': 'LLM 调用',
'trace.toolCalls': '工具调用',
'trace.kind.session': '会话',
'trace.kind.turn': '轮次',
'trace.kind.llm': 'LLM 调用',
'trace.kind.tool': '工具调用',
'trace.kind.toolResult': '工具结果',
'trace.kind.message': '消息',
'trace.kind.event': '事件',
'trace.status.ok': '正常',
'trace.status.error': '错误',
'trace.status.pending': '等待中',
@ -1764,9 +1714,7 @@ export const zh: Record<TranslationKey, string> = {
'trace.message.toolResult': '工具结果',
'trace.toolError': '工具错误',
'trace.modelCall': '模型调用',
'trace.modelCalls': '{count} 次模型调用',
'trace.sessionActivity': '会话活动',
'trace.emptyValue': '空',
'trace.diagnosis.modelError': '模型调用失败',
'trace.diagnosis.toolError': '工具执行失败',
'trace.diagnosis.eventError': '链路事件失败',
@ -1782,19 +1730,41 @@ export const zh: Record<TranslationKey, string> = {
'trace.event.upstreamFetchStarted': '上游请求开始',
'trace.event.upstreamFetchCompleted': '上游请求完成',
'trace.event.upstreamFetchFailed': '上游请求失败',
'trace.section.response': '响应',
'trace.section.messages': '消息',
'trace.section.systemPrompt': '系统提示词',
'trace.section.tools': '工具',
'trace.section.parameters': '参数',
'trace.section.raw': '原始数据',
'trace.section.input': '输入',
'trace.section.result': '结果',
'trace.section.meta': '元信息',
'trace.section.content': '内容',
'trace.section.event': '事件',
'trace.section.metadata': '元数据',
'trace.detail.stopReason': '停止原因',
'trace.detail.streaming': '等待模型响应...',
'trace.detail.legacyTruncated': '该记录为旧格式截断数据,无法解析语义视图,请查看下方原始数据。',
'trace.detail.fetchFailed': '完整记录加载失败,以下展示截断预览。',
'trace.detail.earlierMessages': '展开更早的 {count} 条消息',
'trace.detail.chars': '{count} 字符',
'trace.detail.thinking': '思考',
'trace.detail.toolUseId': 'Tool use ID',
'trace.detail.phase': '阶段',
'trace.detail.severity': '级别',
'trace.detail.message': '消息',
'trace.tree.aria': 'Trace 时间线',
'trace.tree.toggleTurn': '折叠/展开轮次',
'trace.list.eyebrow': 'Agent Trace',
'trace.list.title': 'Trace 列表',
'trace.list.collecting': '正在收集',
'trace.list.paused': '已暂停',
'trace.list.settings': 'Trace 设置',
'trace.list.sessions': '会话',
'trace.list.session': 'Session',
'trace.list.actions': '操作',
'trace.list.searchPlaceholder': '搜索标题、Session ID 或项目路径',
'trace.list.emptyTitle': '还没有 Trace 记录',
'trace.list.emptyBody': '开启收集后,新的桌面会话会把模型调用链路写入本地 traces 目录。',
'trace.list.loadFailed': 'Trace 列表加载失败',
'trace.list.fileSize': '文件大小',
'trace.list.loadedCount': '已显示 {shown} / {total}',
'trace.list.loadMore': '加载更多',

View File

@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setBaseUrl } from '../../api/client'
import { clearTraceCallCache, fetchTraceCallDetail } from './callCache'
import type { TraceCallRecord } from '../../types/trace'
function makeCall(overrides: Partial<TraceCallRecord> = {}): TraceCallRecord {
return {
id: 'call-1',
sessionId: 'session-1',
source: 'anthropic',
status: 'ok',
startedAt: '2026-06-09T10:00:00.000Z',
completedAt: '2026-06-09T10:00:01.000Z',
durationMs: 1000,
request: {
method: 'POST',
url: 'https://api.example/v1/messages',
headers: {},
body: { contentType: 'json', bytes: 10, sha256: 'a', preview: '{"x":1}', truncated: false },
},
response: {
status: 200,
headers: {},
body: { contentType: 'json', bytes: 10, sha256: 'b', preview: '{"ok":true}', truncated: false },
},
...overrides,
}
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
function mockFetch(handler: (url: string) => Response | Promise<Response>) {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = String(input)
if (url.includes('/api/diagnostics/')) return new Response(null, { status: 204 })
return handler(url)
})
}
function traceCallRequests(fetchMock: ReturnType<typeof mockFetch>): string[] {
return fetchMock.mock.calls
.map(([input]) => String(input))
.filter((url) => url.includes('/trace/calls/'))
}
describe('fetchTraceCallDetail', () => {
beforeEach(() => {
clearTraceCallCache()
})
afterEach(() => {
setBaseUrl('http://127.0.0.1:3456')
vi.restoreAllMocks()
})
it('fetches the call detail endpoint and caches terminal records', async () => {
const call = makeCall({ status: 'ok' })
const fetchMock = mockFetch(() => jsonResponse({ call }))
const first = await fetchTraceCallDetail('session-1', 'call-1')
const second = await fetchTraceCallDetail('session-1', 'call-1')
expect(first?.id).toBe('call-1')
expect(second).toBe(first)
expect(traceCallRequests(fetchMock)).toEqual([
'http://127.0.0.1:3456/api/sessions/session-1/trace/calls/call-1',
])
})
it('caches error-status records as terminal', async () => {
const call = makeCall({ status: 'error', error: { name: 'Error', message: 'boom' } })
const fetchMock = mockFetch(() => jsonResponse({ call }))
await fetchTraceCallDetail('session-1', 'call-1')
await fetchTraceCallDetail('session-1', 'call-1')
expect(traceCallRequests(fetchMock)).toHaveLength(1)
})
it('does not cache pending records', async () => {
const call = makeCall({ status: 'pending', response: undefined, completedAt: undefined })
const fetchMock = mockFetch(() => jsonResponse({ call }))
await fetchTraceCallDetail('session-1', 'call-1')
await fetchTraceCallDetail('session-1', 'call-1')
expect(traceCallRequests(fetchMock)).toHaveLength(2)
})
it('returns null on 404 without caching', async () => {
const fetchMock = mockFetch(() => jsonResponse({ error: 'call not found' }, 404))
expect(await fetchTraceCallDetail('session-1', 'missing')).toBeNull()
expect(await fetchTraceCallDetail('session-1', 'missing')).toBeNull()
expect(traceCallRequests(fetchMock)).toHaveLength(2)
})
it('returns null on network errors', async () => {
mockFetch(() => {
throw new TypeError('network down')
})
expect(await fetchTraceCallDetail('session-1', 'call-1')).toBeNull()
})
it('clearTraceCallCache forces a refetch', async () => {
const call = makeCall({ status: 'ok' })
const fetchMock = mockFetch(() => jsonResponse({ call }))
await fetchTraceCallDetail('session-1', 'call-1')
clearTraceCallCache()
await fetchTraceCallDetail('session-1', 'call-1')
expect(traceCallRequests(fetchMock)).toHaveLength(2)
})
})

View File

@ -0,0 +1,29 @@
import { sessionsApi } from '../../api/sessions'
import type { TraceCallRecord } from '../../types/trace'
const callCache = new Map<string, TraceCallRecord>()
export async function fetchTraceCallDetail(sessionId: string, callId: string): Promise<TraceCallRecord | null> {
const key = `${sessionId}:${callId}`
const cached = callCache.get(key)
if (cached) return cached
try {
const result = await sessionsApi.getTraceCall(sessionId, callId)
const call = result?.call
if (!call) return null
if (isTerminalCall(call)) callCache.set(key, call)
return call
} catch {
return null
}
}
export function clearTraceCallCache(): void {
callCache.clear()
}
function isTerminalCall(call: TraceCallRecord): boolean {
if (call.status === 'ok' || call.status === 'error') return true
if (call.status === 'pending') return false
return Boolean(call.response || call.error)
}

View File

@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { formatClockTime, formatDurationMs, formatTokenCount, formatUsageBrief } from './formatters'
describe('formatDurationMs', () => {
it('formats missing or invalid values as --', () => {
expect(formatDurationMs()).toBe('--')
expect(formatDurationMs(Number.NaN)).toBe('--')
expect(formatDurationMs(-5)).toBe('--')
})
it('formats sub-second durations in milliseconds', () => {
expect(formatDurationMs(0)).toBe('0ms')
expect(formatDurationMs(340)).toBe('340ms')
expect(formatDurationMs(999)).toBe('999ms')
})
it('formats seconds with two decimals below 10s and without decimals above', () => {
expect(formatDurationMs(1250)).toBe('1.25s')
expect(formatDurationMs(9990)).toBe('9.99s')
expect(formatDurationMs(10_000)).toBe('10s')
expect(formatDurationMs(42_300)).toBe('42s')
})
it('formats minutes with zero-padded seconds', () => {
expect(formatDurationMs(60_000)).toBe('1m 00s')
expect(formatDurationMs(65_000)).toBe('1m 05s')
expect(formatDurationMs(125_000)).toBe('2m 05s')
})
})
describe('formatTokenCount', () => {
it('formats missing or invalid values as --', () => {
expect(formatTokenCount()).toBe('--')
expect(formatTokenCount(Number.NaN)).toBe('--')
expect(formatTokenCount(-1)).toBe('--')
})
it('formats counts below 1000 verbatim', () => {
expect(formatTokenCount(0)).toBe('0')
expect(formatTokenCount(847)).toBe('847')
})
it('formats thousands with one decimal and a k suffix', () => {
expect(formatTokenCount(1000)).toBe('1.0k')
expect(formatTokenCount(1234)).toBe('1.2k')
expect(formatTokenCount(38_500)).toBe('38.5k')
})
it('formats millions with an m suffix', () => {
expect(formatTokenCount(2_400_000)).toBe('2.4m')
})
})
describe('formatUsageBrief', () => {
it('formats missing usage as --', () => {
expect(formatUsageBrief()).toBe('--')
})
it('formats input and output token counts', () => {
expect(formatUsageBrief({ inputTokens: 1234, outputTokens: 847 })).toBe('1.2k → 847')
expect(formatUsageBrief({ inputTokens: 12, outputTokens: 38_500, cacheReadInputTokens: 4 })).toBe('12 → 38.5k')
})
})
describe('formatClockTime', () => {
it('formats a valid iso timestamp with the locale time formatter', () => {
const iso = '2026-06-09T10:09:59.000Z'
const expected = new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
expect(formatClockTime(iso)).toBe(expected)
})
it('returns the input when the timestamp is invalid', () => {
expect(formatClockTime('not-a-date')).toBe('not-a-date')
})
})

View File

@ -0,0 +1,31 @@
import type { TraceCallUsage } from '../../types/trace'
import type { NormalizedUsage } from './types'
export function formatDurationMs(ms?: number): string {
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return '--'
if (ms < 1000) return `${Math.round(ms)}ms`
if (ms < 10_000) return `${(ms / 1000).toFixed(2)}s`
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
const totalSeconds = Math.round(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}m ${String(seconds).padStart(2, '0')}s`
}
export function formatTokenCount(n?: number): string {
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return '--'
if (n < 1000) return String(Math.round(n))
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
return `${(n / 1_000_000).toFixed(1)}m`
}
export function formatUsageBrief(u?: NormalizedUsage | TraceCallUsage): string {
if (!u) return '--'
return `${formatTokenCount(u.inputTokens)}${formatTokenCount(u.outputTokens)}`
}
export function formatClockTime(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}

View File

@ -0,0 +1,204 @@
import { describe, expect, it } from 'vitest'
import { parseTraceRequestBody, parseTraceResponseBody } from './requestParse'
const anthropicRequest = {
model: 'claude-sonnet-4-5',
max_tokens: 4096,
temperature: 0.7,
stream: true,
system: 'You are helpful.',
messages: [
{ role: 'user', content: 'hello' },
{
role: 'assistant',
content: [
{ type: 'text', text: 'hi' },
{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } },
],
},
{
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'total 8', is_error: false },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAA' } },
],
},
],
tools: [{ name: 'Bash', description: 'Run a command', input_schema: { type: 'object' } }],
}
const anthropicResponse = {
id: 'msg_1',
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'done' }],
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2 },
}
describe('parseTraceRequestBody', () => {
it('parses an anthropic request into normalized messages, tools and params', () => {
const parsed = parseTraceRequestBody(JSON.stringify(anthropicRequest), 'anthropic')
expect(parsed).not.toBeNull()
expect(parsed?.model).toBe('claude-sonnet-4-5')
expect(parsed?.system).toBe('You are helpful.')
expect(parsed?.messages).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
{
role: 'assistant',
content: [
{ type: 'text', text: 'hi' },
{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } },
],
},
{
role: 'user',
content: [
{ type: 'tool_result', toolUseId: 'toolu_1', content: 'total 8' },
{ type: 'image', mediaType: 'image/png', dataUrl: 'data:image/png;base64,AAA' },
],
},
])
expect(parsed?.tools).toEqual([{ name: 'Bash', description: 'Run a command', schema: { type: 'object' } }])
expect(parsed?.params).toEqual({ max_tokens: 4096, temperature: 0.7, stream: true })
})
it('unwraps the proxy {anthropic, upstream} envelope', () => {
const wrapped = JSON.stringify({ anthropic: anthropicRequest, upstream: { model: 'gpt-4o', messages: [] } })
const parsed = parseTraceRequestBody(wrapped, 'proxy')
expect(parsed?.model).toBe('claude-sonnet-4-5')
expect(parsed?.messages).toHaveLength(3)
})
it('joins system content block arrays into plain text', () => {
const body = JSON.stringify({
model: 'claude-sonnet-4-5',
system: [
{ type: 'text', text: 'Part A' },
{ type: 'text', text: 'Part B' },
],
messages: [],
})
expect(parseTraceRequestBody(body, 'anthropic')?.system).toBe('Part A\n\nPart B')
})
it('marks tool_result errors and keeps unknown blocks visible as text', () => {
const body = JSON.stringify({
messages: [
{
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'toolu_9', content: 'boom', is_error: true },
{ type: 'server_tool_use', id: 'srvtoolu_1' },
],
},
],
})
const parsed = parseTraceRequestBody(body, 'anthropic')
expect(parsed?.messages[0]?.content).toEqual([
{ type: 'tool_result', toolUseId: 'toolu_9', content: 'boom', isError: true },
{ type: 'text', text: '{"type":"server_tool_use","id":"srvtoolu_1"}' },
])
})
it('returns null for truncated json', () => {
const full = JSON.stringify(anthropicRequest)
expect(parseTraceRequestBody(full.slice(0, full.length - 20), 'anthropic')).toBeNull()
expect(parseTraceRequestBody('', 'anthropic')).toBeNull()
expect(parseTraceRequestBody('not json', 'proxy')).toBeNull()
})
})
describe('parseTraceResponseBody', () => {
it('parses an anthropic json message response', () => {
const parsed = parseTraceResponseBody(JSON.stringify(anthropicResponse), 'anthropic')
expect(parsed).toEqual({
kind: 'json',
message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
usage: { inputTokens: 10, outputTokens: 5, cacheReadInputTokens: 2 },
stopReason: 'end_turn',
model: 'claude-sonnet-4-5',
})
})
it('unwraps the proxy {upstream, anthropic} response envelope', () => {
const wrapped = JSON.stringify({
upstream: { id: 'chatcmpl-1', object: 'chat.completion', choices: [] },
anthropic: anthropicResponse,
})
const parsed = parseTraceResponseBody(wrapped, 'proxy')
expect(parsed?.kind).toBe('json')
expect(parsed?.message?.content).toEqual([{ type: 'text', text: 'done' }])
expect(parsed?.usage?.inputTokens).toBe(10)
})
it('detects sse responses and reassembles the stream', () => {
const stream = [
'event: message_start',
`data: ${JSON.stringify({ type: 'message_start', message: { role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: 4, output_tokens: 1 } } })}`,
'',
'event: content_block_start',
`data: ${JSON.stringify({ index: 0, content_block: { type: 'text', text: '' } })}`,
'',
'event: content_block_delta',
`data: ${JSON.stringify({ index: 0, delta: { type: 'text_delta', text: 'streamed' } })}`,
'',
'event: message_delta',
`data: ${JSON.stringify({ delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 6 } })}`,
'',
].join('\n')
const parsed = parseTraceResponseBody(stream, 'anthropic')
expect(parsed?.kind).toBe('sse')
expect(parsed?.message?.content).toEqual([{ type: 'text', text: 'streamed' }])
expect(parsed?.usage).toEqual({ inputTokens: 4, outputTokens: 6 })
expect(parsed?.stopReason).toBe('end_turn')
})
it('normalizes an openai chat completion json response', () => {
const body = JSON.stringify({
id: 'chatcmpl-2',
object: 'chat.completion',
model: 'gpt-4o',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: 'sure',
tool_calls: [{ id: 'call_9', type: 'function', function: { name: 'Bash', arguments: '{"command":"pwd"}' } }],
},
finish_reason: 'tool_calls',
},
],
usage: { prompt_tokens: 30, completion_tokens: 12 },
})
const parsed = parseTraceResponseBody(body, 'anthropic')
expect(parsed?.kind).toBe('json')
expect(parsed?.message?.content).toEqual([
{ type: 'text', text: 'sure' },
{ type: 'tool_use', id: 'call_9', name: 'Bash', input: { command: 'pwd' } },
])
expect(parsed?.usage).toEqual({ inputTokens: 30, outputTokens: 12 })
expect(parsed?.stopReason).toBe('tool_calls')
expect(parsed?.model).toBe('gpt-4o')
})
it('keeps non-message json visible without a normalized message', () => {
const parsed = parseTraceResponseBody(JSON.stringify({ type: 'error', error: { message: 'overloaded' } }), 'anthropic')
expect(parsed).toEqual({ kind: 'json', message: null, usage: null })
})
it('returns null for truncated or empty payloads', () => {
const full = JSON.stringify(anthropicResponse)
expect(parseTraceResponseBody(full.slice(0, full.length - 8), 'anthropic')).toBeNull()
expect(parseTraceResponseBody('', 'anthropic')).toBeNull()
expect(parseTraceResponseBody(' ', 'proxy')).toBeNull()
})
})

View File

@ -0,0 +1,213 @@
import type { NormalizedBlock, NormalizedMessage, NormalizedUsage } from './types'
import { looksLikeSseText, normalizeContentBlock, normalizeUsageValue, reassembleSseText } from './sse'
export type ParsedRequest = {
model?: string
system?: string
messages: NormalizedMessage[]
tools: Array<{ name: string; description?: string; schema?: unknown }>
params: Record<string, unknown>
}
export type ParsedResponse = {
kind: 'json' | 'sse'
message: NormalizedMessage | null
usage: NormalizedUsage | null
stopReason?: string
model?: string
}
type JsonRecord = Record<string, unknown>
const REQUEST_CORE_KEYS = new Set(['model', 'system', 'messages', 'tools'])
export function parseTraceRequestBody(preview: string, source: 'anthropic' | 'proxy'): ParsedRequest | null {
const parsed = parseJsonRecord(preview)
if (!parsed) return null
const body = source === 'proxy' ? unwrapProxyEnvelope(parsed) : parsed
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)
: []
const tools = Array.isArray(body.tools)
? body.tools
.map((entry) => normalizeToolDefinition(entry))
.filter((entry): entry is ParsedRequest['tools'][number] => entry !== null)
: []
const params: Record<string, unknown> = {}
for (const [key, value] of Object.entries(body)) {
if (!REQUEST_CORE_KEYS.has(key)) params[key] = value
}
return {
...(model ? { model } : {}),
...(system !== undefined ? { system } : {}),
messages,
tools,
params,
}
}
export function parseTraceResponseBody(preview: string, source: 'anthropic' | 'proxy'): ParsedResponse | null {
if (typeof preview !== 'string' || !preview.trim()) return null
if (looksLikeSseText(preview)) {
const reassembled = reassembleSseText(preview)
return reassembled ? { kind: 'sse', ...reassembled } : null
}
const parsed = parseJsonRecord(preview)
if (!parsed) return null
const body = source === 'proxy' ? unwrapProxyEnvelope(parsed) : parsed
if (typeof body === 'string') {
const reassembled = looksLikeSseText(body) ? reassembleSseText(body) : null
return reassembled ? { kind: 'sse', ...reassembled } : null
}
if (!isRecord(body)) return null
return normalizeJsonResponse(body)
}
function normalizeJsonResponse(body: JsonRecord): ParsedResponse {
const model = typeof body.model === 'string' && body.model ? body.model : undefined
if (Array.isArray(body.content)) {
const blocks = body.content
.map((block) => normalizeContentBlock(block))
.filter((block): block is NormalizedBlock => block !== null)
const stopReason = typeof body.stop_reason === 'string' && body.stop_reason ? body.stop_reason : undefined
return {
kind: 'json',
message: { role: normalizeRole(body.role, 'assistant'), content: blocks },
usage: normalizeUsageValue(body.usage),
...(stopReason ? { stopReason } : {}),
...(model ? { model } : {}),
}
}
const firstChoice = Array.isArray(body.choices) && isRecord(body.choices[0]) ? body.choices[0] : null
if (firstChoice) {
const message = isRecord(firstChoice.message) ? firstChoice.message : {}
const blocks: NormalizedBlock[] = []
if (typeof message.reasoning_content === 'string' && message.reasoning_content) {
blocks.push({ type: 'thinking', thinking: message.reasoning_content })
}
if (typeof message.content === 'string' && message.content) {
blocks.push({ type: 'text', text: message.content })
}
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []
for (const toolCall of toolCalls) {
if (!isRecord(toolCall)) continue
const fn = isRecord(toolCall.function) ? toolCall.function : {}
blocks.push({
type: 'tool_use',
...(typeof toolCall.id === 'string' && toolCall.id ? { id: toolCall.id } : {}),
name: typeof fn.name === 'string' ? fn.name : '',
input: parseToolArguments(fn.arguments),
})
}
const stopReason = typeof firstChoice.finish_reason === 'string' && firstChoice.finish_reason
? firstChoice.finish_reason
: undefined
return {
kind: 'json',
message: { role: normalizeRole(message.role, 'assistant'), content: blocks },
usage: normalizeUsageValue(body.usage),
...(stopReason ? { stopReason } : {}),
...(model ? { model } : {}),
}
}
return {
kind: 'json',
message: null,
usage: normalizeUsageValue(body.usage),
...(model ? { model } : {}),
}
}
function normalizeMessage(entry: unknown): NormalizedMessage | null {
if (!isRecord(entry)) return null
const role = normalizeRole(entry.role, 'user')
const content = entry.content
if (typeof content === 'string') {
return { role, content: [{ type: 'text', text: content }] }
}
if (Array.isArray(content)) {
const blocks = content
.map((block) => normalizeContentBlock(block))
.filter((block): block is NormalizedBlock => block !== null)
return { role, content: blocks }
}
return { role, content: [] }
}
function normalizeRole(role: unknown, fallback: NormalizedMessage['role']): NormalizedMessage['role'] {
return role === 'user' || role === 'assistant' || role === 'system' || role === 'tool' ? role : fallback
}
function normalizeToolDefinition(entry: unknown): ParsedRequest['tools'][number] | null {
if (!isRecord(entry)) return null
const fn = isRecord(entry.function) ? entry.function : null
const name = typeof entry.name === 'string' && entry.name
? entry.name
: fn && typeof fn.name === 'string' && fn.name
? fn.name
: null
if (!name) return null
const description = typeof entry.description === 'string' && entry.description
? entry.description
: fn && typeof fn.description === 'string' && fn.description
? fn.description
: undefined
const schema = entry.input_schema ?? fn?.parameters
return {
name,
...(description ? { description } : {}),
...(schema !== undefined ? { schema } : {}),
}
}
function extractSystemText(system: unknown): string | undefined {
if (typeof system === 'string') return system
if (!Array.isArray(system)) return undefined
const parts = system.flatMap((block) => {
if (typeof block === 'string') return [block]
if (isRecord(block) && typeof block.text === 'string') return [block.text]
return []
})
return parts.join('\n\n')
}
function unwrapProxyEnvelope(parsed: JsonRecord): unknown {
if ('anthropic' in parsed) return parsed.anthropic
return parsed
}
function parseToolArguments(value: unknown): unknown {
if (typeof value !== 'string') return value
try {
return JSON.parse(value)
} catch {
return value
}
}
function parseJsonRecord(text: string): JsonRecord | null {
if (typeof text !== 'string') return null
const trimmed = text.trim()
if (!trimmed.startsWith('{')) return null
try {
const parsed = JSON.parse(trimmed) as unknown
return isRecord(parsed) ? parsed : null
} catch {
return null
}
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

View File

@ -0,0 +1,180 @@
import { describe, expect, it } from 'vitest'
import { looksLikeSseText, reassembleSseText } from './sse'
function sse(frames: Array<{ event?: string; data: string }>): string {
return frames
.map((frame) => (frame.event ? `event: ${frame.event}\ndata: ${frame.data}\n` : `data: ${frame.data}\n`))
.join('\n') + '\n'
}
const anthropicTextStream = sse([
{
event: 'message_start',
data: JSON.stringify({
type: 'message_start',
message: {
id: 'msg_01',
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [],
stop_reason: null,
usage: { input_tokens: 25, output_tokens: 1, cache_read_input_tokens: 7, cache_creation_input_tokens: 3 },
},
}),
},
{ event: 'content_block_start', data: JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) },
{ event: 'content_block_delta', data: JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } }) },
{ event: 'content_block_delta', data: JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: ' world' } }) },
{ event: 'content_block_stop', data: JSON.stringify({ type: 'content_block_stop', index: 0 }) },
{ event: 'message_delta', data: JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 42 } }) },
{ event: 'message_stop', data: JSON.stringify({ type: 'message_stop' }) },
])
describe('looksLikeSseText', () => {
it('detects event and data prefixed streams', () => {
expect(looksLikeSseText('event: message_start\ndata: {}\n')).toBe(true)
expect(looksLikeSseText('\n\ndata: {"choices":[]}\n')).toBe(true)
expect(looksLikeSseText('{"type":"message"}')).toBe(false)
expect(looksLikeSseText('plain text')).toBe(false)
expect(looksLikeSseText('')).toBe(false)
})
})
describe('reassembleSseText', () => {
it('rebuilds a full anthropic text stream with merged usage', () => {
const result = reassembleSseText(anthropicTextStream)
expect(result).not.toBeNull()
expect(result?.message).toEqual({
role: 'assistant',
content: [{ type: 'text', text: 'Hello world' }],
})
expect(result?.usage).toEqual({
inputTokens: 25,
outputTokens: 42,
cacheReadInputTokens: 7,
cacheCreationInputTokens: 3,
})
expect(result?.stopReason).toBe('end_turn')
expect(result?.model).toBe('claude-sonnet-4-5')
})
it('accumulates input_json_delta and parses tool input on content_block_stop', () => {
const stream = sse([
{
event: 'message_start',
data: JSON.stringify({
type: 'message_start',
message: { role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: 10, output_tokens: 1 } },
}),
},
{ event: 'content_block_start', data: JSON.stringify({ index: 0, content_block: { type: 'text', text: '' } }) },
{ event: 'content_block_delta', data: JSON.stringify({ index: 0, delta: { type: 'text_delta', text: 'Checking.' } }) },
{ event: 'content_block_stop', data: JSON.stringify({ index: 0 }) },
{ event: 'content_block_start', data: JSON.stringify({ index: 1, content_block: { type: 'tool_use', id: 'toolu_01', name: 'Bash', input: {} } }) },
{ event: 'content_block_delta', data: JSON.stringify({ index: 1, delta: { type: 'input_json_delta', partial_json: '{"comm' } }) },
{ event: 'content_block_delta', data: JSON.stringify({ index: 1, delta: { type: 'input_json_delta', partial_json: 'and":"ls"}' } }) },
{ event: 'content_block_stop', data: JSON.stringify({ index: 1 }) },
{ event: 'message_delta', data: JSON.stringify({ delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 9 } }) },
])
const result = reassembleSseText(stream)
expect(result?.message?.content).toEqual([
{ type: 'text', text: 'Checking.' },
{ type: 'tool_use', id: 'toolu_01', name: 'Bash', input: { command: 'ls' } },
])
expect(result?.stopReason).toBe('tool_use')
expect(result?.usage).toEqual({ inputTokens: 10, outputTokens: 9 })
})
it('accumulates thinking deltas', () => {
const stream = sse([
{
event: 'message_start',
data: JSON.stringify({ type: 'message_start', message: { role: 'assistant', content: [], usage: { input_tokens: 5, output_tokens: 1 } } }),
},
{ event: 'content_block_start', data: JSON.stringify({ index: 0, content_block: { type: 'thinking', thinking: '' } }) },
{ event: 'content_block_delta', data: JSON.stringify({ index: 0, delta: { type: 'thinking_delta', thinking: 'Let me ' } }) },
{ event: 'content_block_delta', data: JSON.stringify({ index: 0, delta: { type: 'thinking_delta', thinking: 'reason.' } }) },
{ event: 'content_block_stop', data: JSON.stringify({ index: 0 }) },
])
const result = reassembleSseText(stream)
expect(result?.message?.content).toEqual([{ type: 'thinking', thinking: 'Let me reason.' }])
})
it('tolerates a truncated trailing frame without throwing', () => {
const truncated = anthropicTextStream.slice(0, anthropicTextStream.indexOf(' world') + 2)
const result = reassembleSseText(truncated)
expect(result).not.toBeNull()
expect(result?.message?.content).toEqual([{ type: 'text', text: 'Hello' }])
})
it('keeps the initial tool input when the stream truncates before content_block_stop', () => {
const stream = [
'event: message_start',
`data: ${JSON.stringify({ type: 'message_start', message: { role: 'assistant', content: [] } })}`,
'',
'event: content_block_start',
`data: ${JSON.stringify({ index: 0, content_block: { type: 'tool_use', id: 'toolu_02', name: 'Read', input: {} } })}`,
'',
'event: content_block_delta',
`data: ${JSON.stringify({ index: 0, delta: { type: 'input_json_delta', partial_json: '{"file_path":"/tm' } })}`,
'',
].join('\n')
const result = reassembleSseText(stream)
expect(result?.message?.content).toEqual([{ type: 'tool_use', id: 'toolu_02', name: 'Read', input: {} }])
})
it('rebuilds an openai chat completions stream into an anthropic-shaped message', () => {
const chunk = (payload: Record<string, unknown>) => JSON.stringify({ id: 'chatcmpl-1', object: 'chat.completion.chunk', model: 'gpt-4o', ...payload })
const stream = sse([
{ data: chunk({ choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] }) },
{ data: chunk({ choices: [{ index: 0, delta: { content: 'Hel' }, finish_reason: null }] }) },
{ data: chunk({ choices: [{ index: 0, delta: { content: 'lo' }, finish_reason: null }] }) },
{ data: chunk({ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'get_weather', arguments: '' } }] }, finish_reason: null }] }) },
{ data: chunk({ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: '{"city":' } }] }, finish_reason: null }] }) },
{ data: chunk({ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: '"SF"}' } }] }, finish_reason: null }] }) },
{ data: chunk({ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }) },
{ data: chunk({ choices: [], usage: { prompt_tokens: 100, completion_tokens: 20, prompt_tokens_details: { cached_tokens: 60 } } }) },
{ data: '[DONE]' },
])
const result = reassembleSseText(stream)
expect(result?.message?.content).toEqual([
{ type: 'text', text: 'Hello' },
{ type: 'tool_use', id: 'call_1', name: 'get_weather', input: { city: 'SF' } },
])
expect(result?.usage).toEqual({ inputTokens: 100, outputTokens: 20, cacheReadInputTokens: 60 })
expect(result?.stopReason).toBe('tool_calls')
expect(result?.model).toBe('gpt-4o')
})
it('mirrors openai reasoning_content into a leading thinking block', () => {
const stream = sse([
{ data: JSON.stringify({ id: 'c1', model: 'deepseek-r1', choices: [{ index: 0, delta: { role: 'assistant', reasoning_content: 'pondering' }, finish_reason: null }] }) },
{ data: JSON.stringify({ id: 'c1', choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: 'stop' }] }) },
{ data: '[DONE]' },
])
const result = reassembleSseText(stream)
expect(result?.message?.content).toEqual([
{ type: 'thinking', thinking: 'pondering' },
{ type: 'text', text: 'answer' },
])
expect(result?.stopReason).toBe('stop')
})
it('returns null for non-SSE input', () => {
expect(reassembleSseText('{"type":"message","content":[]}')).toBeNull()
expect(reassembleSseText('plain text response')).toBeNull()
expect(reassembleSseText('')).toBeNull()
})
it('returns null when no snapshot can be reconstructed', () => {
expect(reassembleSseText('event: ping\ndata: {}\n\n')).toBeNull()
expect(reassembleSseText('data: [DONE]\n\n')).toBeNull()
expect(reassembleSseText('data: {broken json\n\n')).toBeNull()
})
})

View File

@ -0,0 +1,418 @@
/**
* Portions of this file are adapted from claude-tap
* (https://github.com/liaohch3/claude-tap), Copyright (c) 2025 liaohch3,
* licensed under the MIT License. See THIRD_PARTY_LICENSES.md for the full text.
*/
import type { NormalizedBlock, NormalizedMessage, NormalizedUsage } from './types'
export type ReassembledSse = {
message: NormalizedMessage | null
usage: NormalizedUsage | null
stopReason?: string
model?: string
}
type JsonRecord = Record<string, unknown>
export function reassembleSseText(sseText: string): ReassembledSse | null {
if (typeof sseText !== 'string' || !looksLikeSseText(sseText)) return null
try {
const snapshot = reassembleSnapshot(sseText)
if (!snapshot) return null
return snapshotToResult(snapshot)
} catch {
return null
}
}
export function looksLikeSseText(text: string): boolean {
if (typeof text !== 'string') return false
for (const rawLine of text.split('\n')) {
const line = rawLine.trim()
if (!line) continue
return line.startsWith('event:') || line.startsWith('data:')
}
return false
}
function reassembleSnapshot(sseText: string): JsonRecord | null {
const state: { snapshot: JsonRecord | null } = { snapshot: null }
let currentEvent: string | null = null
let currentData: string[] = []
const flush = () => {
if (currentEvent === null && currentData.length === 0) return
const rawData = currentData.join('\n')
const isDoneSentinel = rawData === '[DONE]' && currentEvent === null
if (!isDoneSentinel) {
let data: unknown
try {
data = JSON.parse(rawData)
} catch {
data = rawData
}
accumulate(state, currentEvent ?? 'message', data)
}
currentEvent = null
currentData = []
}
for (const rawLine of sseText.split('\n')) {
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
if (line.startsWith('event:')) {
currentEvent = line.slice('event:'.length).trim()
currentData = []
} else if (line.startsWith('data:')) {
currentData.push(line.slice('data:'.length).trim())
} else if (line === '') {
flush()
}
}
flush()
return state.snapshot
}
function accumulate(state: { snapshot: JsonRecord | null }, eventType: string, data: unknown): void {
if (!isRecord(data)) return
try {
if (eventType === 'message_start') {
state.snapshot = deepClone(isRecord(data.message) ? data.message : {})
} else if (eventType === 'message' && 'choices' in data) {
accumulateChatCompletionChunk(state, data)
} else if (!state.snapshot) {
return
} else if (eventType === 'content_block_start') {
const content = ensureContentArray(state.snapshot)
const index = typeof data.index === 'number' && data.index >= 0 ? data.index : content.length
while (content.length <= index) content.push({})
content[index] = deepClone(isRecord(data.content_block) ? data.content_block : {})
} else if (eventType === 'content_block_delta') {
const delta = isRecord(data.delta) ? data.delta : {}
const block = contentBlockForDelta(state.snapshot, data.index, delta)
if (delta.type === 'text_delta') {
block.text = stringOf(block.text) + stringOf(delta.text)
} else if (delta.type === 'thinking_delta') {
block.thinking = stringOf(block.thinking) + stringOf(delta.thinking)
if (typeof delta.signature === 'string' && delta.signature) block.signature = delta.signature
} else if (delta.type === 'input_json_delta') {
block._partial_json = stringOf(block._partial_json) + stringOf(delta.partial_json)
}
} else if (eventType === 'content_block_stop') {
const index = typeof data.index === 'number' ? data.index : 0
const content = state.snapshot.content
if (Array.isArray(content) && index >= 0 && index < content.length) {
const block = content[index]
if (isRecord(block) && typeof block._partial_json === 'string') {
try {
block.input = JSON.parse(block._partial_json)
} catch {
// partial JSON from a truncated stream — keep the previous input
}
delete block._partial_json
}
}
} else if (eventType === 'message_delta') {
const delta = isRecord(data.delta) ? data.delta : {}
for (const [key, value] of Object.entries(delta)) state.snapshot[key] = value
const usage = isRecord(data.usage) ? data.usage : null
if (usage && Object.keys(usage).length > 0) {
const existing = isRecord(state.snapshot.usage) ? state.snapshot.usage : {}
state.snapshot.usage = { ...existing, ...normalizeUsageRecord(usage) }
}
}
} catch {
// malformed event payloads must never break reassembly
}
}
function contentBlockForDelta(snapshot: JsonRecord, rawIndex: unknown, delta: JsonRecord): JsonRecord {
const index = typeof rawIndex === 'number' && Number.isInteger(rawIndex) && rawIndex >= 0 ? rawIndex : 0
const content = ensureContentArray(snapshot)
while (content.length <= index) content.push(emptyContentBlockForDelta(delta))
const existing = content[index]
const block: JsonRecord = isRecord(existing) ? existing : emptyContentBlockForDelta(delta)
content[index] = block
if (Object.keys(block).length === 0) Object.assign(block, emptyContentBlockForDelta(delta))
return block
}
function emptyContentBlockForDelta(delta: JsonRecord): JsonRecord {
if (delta.type === 'thinking_delta') return { type: 'thinking', thinking: '' }
if (delta.type === 'input_json_delta') return { type: 'tool_use', id: '', name: '', input: {} }
return { type: 'text', text: '' }
}
function accumulateChatCompletionChunk(state: { snapshot: JsonRecord | null }, data: JsonRecord): void {
const choices = Array.isArray(data.choices) ? data.choices : []
const usage = isRecord(data.usage) ? data.usage : null
if (choices.length === 0) {
if (usage && state.snapshot) mergeChatCompletionUsage(state.snapshot, usage)
return
}
const choice = isRecord(choices[0]) ? choices[0] : {}
const delta = isRecord(choice.delta) ? choice.delta : {}
const finishReason = choice.finish_reason
const choiceUsage = isRecord(choice.usage) ? choice.usage : null
if (!state.snapshot) {
state.snapshot = {
id: stringOf(data.id),
object: 'chat.completion',
model: stringOf(data.model),
choices: [
{
index: 0,
message: { role: typeof delta.role === 'string' && delta.role ? delta.role : 'assistant', content: '' },
finish_reason: null,
},
],
content: [{ type: 'text', text: '' }],
}
}
const snapshot = state.snapshot
const firstChoice = Array.isArray(snapshot.choices) && isRecord(snapshot.choices[0]) ? snapshot.choices[0] : null
const message = firstChoice && isRecord(firstChoice.message) ? firstChoice.message : null
if (!firstChoice || !message) return
const textBlock = chatCompletionTextBlock(snapshot)
if (typeof delta.role === 'string' && delta.role) message.role = delta.role
if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) {
message.reasoning_content = stringOf(message.reasoning_content) + delta.reasoning_content
mirrorReasoningToContent(snapshot, message.reasoning_content as string)
}
if (typeof delta.content === 'string' && delta.content) {
message.content = stringOf(message.content) + delta.content
textBlock.text = stringOf(textBlock.text) + delta.content
}
const toolCallDeltas = Array.isArray(delta.tool_calls) ? delta.tool_calls : []
for (const toolCallDelta of toolCallDeltas) {
if (!isRecord(toolCallDelta)) continue
const index = typeof toolCallDelta.index === 'number' && toolCallDelta.index >= 0 ? toolCallDelta.index : 0
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : (message.tool_calls = [])
while (toolCalls.length <= index) {
toolCalls.push({ id: '', type: 'function', function: { name: '', arguments: '' } })
}
const existing = isRecord(toolCalls[index]) ? toolCalls[index] as JsonRecord : (toolCalls[index] = {})
if (typeof toolCallDelta.id === 'string') existing.id = toolCallDelta.id
if (typeof toolCallDelta.type === 'string') existing.type = toolCallDelta.type
const fnDelta = isRecord(toolCallDelta.function) ? toolCallDelta.function : {}
const fn = isRecord(existing.function) ? existing.function : (existing.function = { name: '', arguments: '' })
if (typeof fnDelta.name === 'string') fn.name = stringOf(fn.name) + fnDelta.name
if (typeof fnDelta.arguments === 'string') fn.arguments = stringOf(fn.arguments) + fnDelta.arguments
mirrorToolCallToContent(snapshot, index, existing)
}
if (finishReason) firstChoice.finish_reason = finishReason
if (usage) mergeChatCompletionUsage(snapshot, usage)
if (choiceUsage) mergeChatCompletionUsage(snapshot, choiceUsage)
}
function mirrorToolCallToContent(snapshot: JsonRecord, index: number, toolCall: JsonRecord): void {
const content = ensureContentArray(snapshot)
const offset = 1 + (chatCompletionThinkingBlock(snapshot, { create: false }) ? 1 : 0)
const target = index + offset
while (content.length <= target) {
content.push({ type: 'tool_use', id: '', name: '', input: {} })
}
const block = isRecord(content[target]) ? content[target] as JsonRecord : (content[target] = { type: 'tool_use', id: '', name: '', input: {} })
if (toolCall.id) block.id = toolCall.id
const fn = isRecord(toolCall.function) ? toolCall.function : {}
if (fn.name) block.name = fn.name
const argsText = stringOf(fn.arguments)
if (argsText) {
try {
block.input = JSON.parse(argsText)
} catch {
// arguments are still streaming; keep the previously parsed input
}
}
}
function chatCompletionTextBlock(snapshot: JsonRecord): JsonRecord {
const content = ensureContentArray(snapshot)
for (const block of content) {
if (isRecord(block) && block.type === 'text') return block
}
const block: JsonRecord = { type: 'text', text: '' }
content.push(block)
return block
}
function chatCompletionThinkingBlock(snapshot: JsonRecord, options: { create: boolean }): JsonRecord | null {
const content = ensureContentArray(snapshot)
for (const block of content) {
if (isRecord(block) && block.type === 'thinking') return block
}
if (!options.create) return null
const block: JsonRecord = { type: 'thinking', thinking: '' }
content.unshift(block)
return block
}
function mirrorReasoningToContent(snapshot: JsonRecord, reasoning: string): void {
const block = chatCompletionThinkingBlock(snapshot, { create: true })
if (block) block.thinking = reasoning
}
function mergeChatCompletionUsage(snapshot: JsonRecord, usage: JsonRecord): void {
snapshot.usage = normalizeUsageRecord(usage)
}
function normalizeUsageRecord(usage: JsonRecord): JsonRecord {
const normalized: JsonRecord = { ...usage }
const fillToken = (target: string, sources: string[]) => {
for (const source of sources) {
if (!missingOrZero(normalized[target])) return
if (!missingOrZero(usage[source])) normalized[target] = usage[source]
}
}
fillToken('input_tokens', ['prompt_tokens', 'promptTokenCount', 'inputTokens'])
fillToken('output_tokens', ['completion_tokens', 'candidatesTokenCount', 'outputTokens'])
fillToken('total_tokens', ['totalTokens'])
if (!('cache_read_input_tokens' in normalized)) {
let cached = usage.cached_tokens ?? usage.cachedContentTokenCount ?? usage.cacheReadInputTokens
if (cached === undefined || cached === null) {
for (const detailsKey of ['input_tokens_details', 'prompt_tokens_details']) {
const details = usage[detailsKey]
if (isRecord(details) && details.cached_tokens !== undefined && details.cached_tokens !== null) {
cached = details.cached_tokens
break
}
}
}
if (cached !== undefined && cached !== null) normalized.cache_read_input_tokens = cached
}
if (!('cache_creation_input_tokens' in normalized)) {
const cacheWrite = usage.cacheWriteInputTokens
if (cacheWrite !== undefined && cacheWrite !== null) normalized.cache_creation_input_tokens = cacheWrite
}
return normalized
}
function snapshotToResult(snapshot: JsonRecord): ReassembledSse {
const rawContent = Array.isArray(snapshot.content) ? snapshot.content : []
const blocks = rawContent
.map((block) => normalizeContentBlock(block))
.filter((block): block is NormalizedBlock => block !== null)
const stopReason = pickStopReason(snapshot)
const model = typeof snapshot.model === 'string' && snapshot.model ? snapshot.model : undefined
return {
message: { role: 'assistant', content: blocks },
usage: normalizeUsageValue(snapshot.usage),
...(stopReason ? { stopReason } : {}),
...(model ? { model } : {}),
}
}
function pickStopReason(snapshot: JsonRecord): string | undefined {
if (typeof snapshot.stop_reason === 'string' && snapshot.stop_reason) return snapshot.stop_reason
const firstChoice = Array.isArray(snapshot.choices) && isRecord(snapshot.choices[0]) ? snapshot.choices[0] : null
if (firstChoice && typeof firstChoice.finish_reason === 'string' && firstChoice.finish_reason) {
return firstChoice.finish_reason
}
return undefined
}
export function normalizeUsageValue(value: unknown): NormalizedUsage | null {
if (!isRecord(value)) return null
const normalized = normalizeUsageRecord(value)
const inputTokens = numberOf(normalized.input_tokens)
const outputTokens = numberOf(normalized.output_tokens)
const cacheRead = numberOf(normalized.cache_read_input_tokens)
const cacheCreation = numberOf(normalized.cache_creation_input_tokens)
if (inputTokens === undefined && outputTokens === undefined && cacheRead === undefined && cacheCreation === undefined) {
return null
}
return {
inputTokens: inputTokens ?? 0,
outputTokens: outputTokens ?? 0,
...(cacheRead !== undefined ? { cacheReadInputTokens: cacheRead } : {}),
...(cacheCreation !== undefined ? { cacheCreationInputTokens: cacheCreation } : {}),
}
}
export function normalizeContentBlock(block: unknown): NormalizedBlock | null {
if (typeof block === 'string') return { type: 'text', text: block }
if (!isRecord(block)) return null
switch (block.type) {
case 'text':
return { type: 'text', text: stringOf(block.text) }
case 'thinking':
return { type: 'thinking', thinking: stringOf(block.thinking) }
case 'redacted_thinking':
return { type: 'thinking', thinking: '[redacted thinking]' }
case 'tool_use':
return {
type: 'tool_use',
...(typeof block.id === 'string' && block.id ? { id: block.id } : {}),
name: stringOf(block.name),
input: block.input,
}
case 'tool_result':
return {
type: 'tool_result',
...(typeof block.tool_use_id === 'string' && block.tool_use_id ? { toolUseId: block.tool_use_id } : {}),
content: block.content,
...(block.is_error === true ? { isError: true } : {}),
}
case 'image': {
const source = isRecord(block.source) ? block.source : {}
const mediaType = typeof source.media_type === 'string' ? source.media_type : undefined
const dataUrl = source.type === 'base64' && typeof source.data === 'string'
? `data:${mediaType ?? 'image/png'};base64,${source.data}`
: source.type === 'url' && typeof source.url === 'string'
? source.url
: undefined
return {
type: 'image',
...(mediaType ? { mediaType } : {}),
...(dataUrl ? { dataUrl } : {}),
}
}
default:
return { type: 'text', text: safeJsonStringify(block) }
}
}
function ensureContentArray(snapshot: JsonRecord): unknown[] {
if (!Array.isArray(snapshot.content)) snapshot.content = []
return snapshot.content as unknown[]
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stringOf(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function numberOf(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
function missingOrZero(value: unknown): boolean {
return value === undefined || value === null || value === 0
}
function deepClone(value: JsonRecord): JsonRecord {
try {
return JSON.parse(JSON.stringify(value)) as JsonRecord
} catch {
return { ...value }
}
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}

View File

@ -0,0 +1,18 @@
export type NormalizedBlock =
| { type: 'text'; text: string }
| { type: 'thinking'; thinking: string }
| { type: 'tool_use'; id?: string; name: string; input: unknown }
| { type: 'tool_result'; toolUseId?: string; content: unknown; isError?: boolean }
| { type: 'image'; mediaType?: string; dataUrl?: string }
export type NormalizedMessage = {
role: 'user' | 'assistant' | 'system' | 'tool'
content: NormalizedBlock[]
}
export type NormalizedUsage = {
inputTokens: number
outputTokens: number
cacheReadInputTokens?: number
cacheCreationInputTokens?: number
}

View File

@ -25,6 +25,7 @@ const trace: TraceSession = {
startedAt: '2026-06-09T10:00:01.000Z',
completedAt: '2026-06-09T10:00:02.000Z',
durationMs: 1000,
usage: { inputTokens: 12, outputTokens: 18, cacheReadInputTokens: 4 },
request: {
method: 'POST',
url: 'https://sub2api.example/v1/messages',
@ -135,6 +136,25 @@ describe('traceViewModel', () => {
expect(viewModel.spansById.get('llm:call-1')?.childIds).toContain('event:event-1')
})
it('passes call usage through to llm spans as tokenUsage', () => {
const viewModel = buildTraceViewModel(trace, messages)
expect(viewModel.spansById.get('llm:call-1')?.tokenUsage).toEqual({
inputTokens: 12,
outputTokens: 18,
cacheReadInputTokens: 4,
})
expect(viewModel.spansById.get('llm:call-2')?.tokenUsage).toBeUndefined()
})
it('marks info lifecycle events as noise and omits fullRaw', () => {
const viewModel = buildTraceViewModel(trace, messages)
expect(viewModel.spansById.get('event:event-1')?.isLifecycleNoise).toBe(true)
expect(viewModel.spansById.get('llm:call-1')?.isLifecycleNoise).toBeUndefined()
expect('fullRaw' in viewModel).toBe(false)
})
it('marks pending tool spans when a result has not arrived', () => {
const viewModel = buildTraceViewModel(trace, messages.slice(0, 2))
expect(viewModel.spansById.get('tool:tool-1')).toMatchObject({ status: 'pending' })
@ -175,6 +195,7 @@ describe('traceViewModel', () => {
kind: 'event',
status: 'error',
title: 'Upstream Fetch Failed',
isLifecycleNoise: false,
})
expect(viewModel.diagnosis).toMatchObject({
status: 'blocked',

View File

@ -1,5 +1,5 @@
import type { MessageEntry } from '../types/session'
import type { TraceCallRecord, TraceEventRecord, TraceSession as TraceSessionData } from '../types/trace'
import type { TraceCallRecord, TraceCallUsage, TraceEventRecord, TraceSession as TraceSessionData } from '../types/trace'
export type TraceSpanKind = 'session' | 'turn' | 'message' | 'llm' | 'tool' | 'tool_result' | 'event'
export type TraceSpanStatus = 'ok' | 'error' | 'pending'
@ -24,6 +24,8 @@ export type TraceSpan = {
input?: unknown
output?: unknown
isSidechain?: boolean
tokenUsage?: TraceCallUsage
isLifecycleNoise?: boolean
raw: unknown
}
@ -66,7 +68,6 @@ export type TraceViewModel = {
turns: TraceTurn[]
orderedSpanIds: string[]
diagnosis: TraceDiagnosis
fullRaw: unknown
}
type ToolUseBlock = {
@ -240,6 +241,7 @@ export function buildTraceViewModel(
durationMs: call.durationMs,
turnIndex: turn.index,
call,
tokenUsage: call.usage,
raw: call,
})
turn.spanIds.push(spanId)
@ -260,6 +262,7 @@ export function buildTraceViewModel(
timestamp: event.timestamp,
turnIndex: turn.index,
event,
isLifecycleNoise: isLifecycleNoiseEvent(event),
raw: event,
})
turn.spanIds.push(spanId)
@ -289,13 +292,6 @@ export function buildTraceViewModel(
turns,
orderedSpanIds,
diagnosis: buildDiagnosis(spanList, turns, rootId, fallbackTimestamp),
fullRaw: {
session: trace.session,
summary: trace.summary,
calls: trace.calls,
events: traceEvents,
messages,
},
}
}
@ -607,6 +603,17 @@ function getEventStatus(event: TraceEventRecord): TraceSpanStatus {
return event.severity === 'error' ? 'error' : 'ok'
}
const LIFECYCLE_NOISE_PHASES = new Set([
'api_call_started',
'api_call_completed',
'upstream_fetch_started',
'upstream_fetch_completed',
])
function isLifecycleNoiseEvent(event: TraceEventRecord): boolean {
return event.severity === 'info' && LIFECYCLE_NOISE_PHASES.has(event.phase)
}
function formatTraceEventPhase(phase: string): string {
return phase
.split(/[_\s-]+/)
@ -617,7 +624,8 @@ function formatTraceEventPhase(phase: string): string {
function formatUnknown(value: unknown): string {
try {
return JSON.stringify(value, null, 2)
// JSON.stringify(undefined) yields undefined, not a string.
return JSON.stringify(value, null, 2) ?? String(value)
} catch {
return String(value)
}

View File

@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TraceList } from './TraceList'
@ -13,6 +13,21 @@ vi.mock('../api/traces', () => ({
},
}))
const { openTraceWindowMock } = vi.hoisted(() => ({
openTraceWindowMock: vi.fn(async () => {}),
}))
vi.mock('../lib/desktopHost', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/desktopHost')>()
return {
...actual,
getDesktopHost: () => ({
...actual.getDesktopHost(),
trace: { openWindow: openTraceWindowMock },
}),
}
})
const traceList: TraceSessionList = {
total: 1,
storageDir: '/tmp/cc-haha/traces',
@ -32,9 +47,13 @@ const traceList: TraceSessionList = {
apiCalls: 3,
failedCalls: 1,
totalDurationMs: 4715,
totalInputTokens: 0,
totalOutputTokens: 0,
models: [{ model: 'gpt-5.5', calls: 2 }],
totalInputTokens: 1200,
totalOutputTokens: 300,
models: [
{ model: 'claude-sonnet-4-5-20250929', calls: 2 },
{ model: 'claude-haiku-4-5-20251001', calls: 1 },
{ model: 'gpt-5.5', calls: 1 },
],
updatedAt: '2026-06-09T15:03:40.010Z',
},
fileSize: 2048,
@ -62,6 +81,10 @@ const secondTraceList: TraceSessionList = {
}],
}
async function findTraceRow(title: RegExp) {
return await screen.findByRole('button', { name: title })
}
describe('TraceList', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
@ -76,18 +99,63 @@ describe('TraceList', () => {
useSettingsStore.setState({ locale: 'en' })
})
it('renders trace session rows and opens a session trace tab', async () => {
it('renders rows with title, model chips, failure count and metrics', async () => {
render(<TraceList />)
expect(await screen.findByText('Debug stuck agent')).toBeInTheDocument()
expect(screen.getByText('/tmp/cc-haha/traces')).toBeInTheDocument()
expect(screen.getByText('gpt-5.5 x2')).toBeInTheDocument()
const row = await findTraceRow(/Debug stuck agent/)
expect(tracesApi.list).toHaveBeenCalledWith({ limit: 50, offset: 0, query: '' })
fireEvent.click(screen.getAllByRole('button', { name: 'Trace' })[0]!)
// header: storage dir + collection badge + aggregate chips
expect(screen.getByText('/tmp/cc-haha/traces')).toBeInTheDocument()
expect(screen.getByText('Collecting')).toBeInTheDocument()
expect(screen.getByText('Sessions')).toBeInTheDocument()
// row line 1: model chips use short names, capped at 2 with a "+N" overflow chip
expect(within(row).getByText('sonnet-4-5')).toBeInTheDocument()
expect(within(row).getByText('haiku-4-5')).toBeInTheDocument()
expect(within(row).getByText('+1')).toBeInTheDocument()
expect(within(row).queryByText('gpt-5.5')).not.toBeInTheDocument()
expect(within(row).getByText('sonnet-4-5')).toHaveAttribute('title', 'claude-sonnet-4-5-20250929 x2')
// row line 1: failed-call indicator
expect(within(row).getByTitle('Failed')).toHaveTextContent('1')
// row line 2: short session id + project path
expect(within(row).getByText('session-')).toBeInTheDocument()
expect(within(row).getByText('/tmp/project')).toBeInTheDocument()
// right metrics: calls / duration / compact tokens
expect(within(row).getByText('3')).toBeInTheDocument()
expect(within(row).getByText('4.7s')).toBeInTheDocument()
expect(within(row).getByText('1.5k')).toBeInTheDocument()
})
it('opens a trace tab when the row is clicked or activated via keyboard', async () => {
render(<TraceList />)
fireEvent.click(await screen.findByText('Debug stuck agent'))
expect(useTabStore.getState().activeTabId).toBe('__trace__session-trace-list')
expect(useTabStore.getState().tabs.find((tab) => tab.type === 'trace')?.traceSessionId).toBe('session-trace-list')
useTabStore.setState({ tabs: [], activeTabId: null })
fireEvent.keyDown(await findTraceRow(/Debug stuck agent/), { key: 'Enter' })
expect(useTabStore.getState().activeTabId).toBe('__trace__session-trace-list')
})
it('runs hover actions without triggering the row click', async () => {
render(<TraceList />)
const row = await findTraceRow(/Debug stuck agent/)
fireEvent.click(within(row).getByRole('button', { name: 'Open in separate window' }))
expect(openTraceWindowMock).toHaveBeenCalledWith('session-trace-list')
expect(useTabStore.getState().activeTabId).toBeNull()
fireEvent.click(within(row).getByRole('button', { name: 'Trace' }))
expect(useTabStore.getState().activeTabId).toBe('__trace__session-trace-list')
})
it('opens General settings from the trace settings button', async () => {
@ -129,4 +197,16 @@ describe('TraceList', () => {
expect(tracesApi.list).toHaveBeenLastCalledWith({ limit: 50, offset: 0, query: 'stuck agent' })
})
})
it('shows the paused badge when capture is disabled', async () => {
vi.mocked(tracesApi.list).mockResolvedValue({
...traceList,
settings: { enabled: false, storageDir: '/tmp/cc-haha/traces' },
})
render(<TraceList />)
expect(await screen.findByText('Paused')).toBeInTheDocument()
expect(screen.queryByText('Collecting')).not.toBeInTheDocument()
})
})

View File

@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AlertTriangle, CheckCircle2, ExternalLink, RefreshCw, Search, Workflow } from 'lucide-react'
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
import { ExternalLink, RefreshCw, Search, Workflow } from 'lucide-react'
import { tracesApi } from '../api/traces'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
import { useTranslation } from '../i18n'
import { Button } from '../components/shared/Button'
import { formatBytes } from '../lib/formatBytes'
import { getDesktopHost } from '../lib/desktopHost'
import type { TraceSessionList, TraceSessionListItem } from '../types/trace'
@ -17,6 +17,13 @@ type TraceListState =
const POLL_MS = 5_000
const PAGE_SIZE = 50
const SEARCH_DEBOUNCE_MS = 250
const MAX_MODEL_CHIPS = 2
/** Skip off-screen row rendering without virtualization (WebKit-friendly). */
const ROW_CV_STYLE = {
contentVisibility: 'auto',
containIntrinsicSize: 'auto 56px',
} as const
export function TraceList() {
const t = useTranslation()
@ -102,13 +109,16 @@ export function TraceList() {
return (
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
<header className="shrink-0 border-b border-[var(--color-border)] px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-[var(--color-text-tertiary)]">
<Workflow className="h-4 w-4" strokeWidth={1.8} aria-hidden="true" />
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-text-tertiary)]">
<Workflow className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
<span>{t('trace.list.eyebrow')}</span>
</div>
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1">
<h1 className="text-lg font-semibold tracking-tight text-[var(--color-text-primary)]">{t('trace.list.title')}</h1>
{state.status === 'ready' && (
<span className={`rounded-md border px-1.5 py-0.5 ${
<span className={`rounded-[var(--radius-sm)] border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
state.data.settings.enabled
? 'border-[var(--color-success)]/25 bg-[var(--color-success)]/10 text-[var(--color-success)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]'
@ -116,37 +126,36 @@ export function TraceList() {
{state.data.settings.enabled ? t('trace.list.collecting') : t('trace.list.paused')}
</span>
)}
{state.status === 'ready' && (
<span className="min-w-0 max-w-full truncate font-mono text-[11px] text-[var(--color-text-tertiary)]" title={state.data.storageDir}>
{state.data.storageDir}
</span>
)}
</div>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-[var(--color-text-primary)]">{t('trace.list.title')}</h1>
{state.status === 'ready' && (
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">
{state.data.storageDir}
</p>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => openTraceSettings(t)}>
{t('trace.list.settings')}
</Button>
<Button size="sm" variant="secondary" onClick={() => void load()}>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<RefreshCw className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
{t('trace.refresh')}
</Button>
</div>
</div>
<div className="mt-4 grid grid-cols-4 gap-3">
<Metric label={t('trace.list.sessions')} value={state.status === 'ready' ? String(state.data.total) : '-'} />
<Metric label={t('trace.apiCalls')} value={String(summary.apiCalls)} />
<Metric label={t('trace.failedCalls')} value={String(summary.failedCalls)} tone={summary.failedCalls > 0 ? 'danger' : 'default'} />
<Metric label={t('trace.models')} value={String(summary.models)} />
<div className="mt-3 flex flex-wrap items-baseline gap-x-5 gap-y-1.5">
<MetaChip label={t('trace.list.sessions')} value={state.status === 'ready' ? String(state.data.total) : '-'} />
<MetaChip label={t('trace.apiCalls')} value={String(summary.apiCalls)} />
<MetaChip label={t('trace.failedCalls')} value={String(summary.failedCalls)} tone={summary.failedCalls > 0 ? 'danger' : 'default'} />
<MetaChip label={t('trace.models')} value={String(summary.models)} />
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-[var(--color-border)] px-5 py-3">
<div className="flex h-10 max-w-xl items-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 focus-within:border-[var(--color-border-focus)]">
<Search className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.8} aria-hidden="true" />
<div className="flex h-9 max-w-xl items-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 focus-within:border-[var(--color-border-focus)]">
<Search className="h-3.5 w-3.5 shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
<input
value={queryInput}
onChange={(event) => setQueryInput(event.currentTarget.value)}
@ -156,9 +165,7 @@ export function TraceList() {
</div>
</div>
{state.status === 'loading' && (
<div className="flex flex-1 items-center justify-center text-sm text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
)}
{state.status === 'loading' && <TraceListSkeleton label={t('common.loading')} />}
{state.status === 'error' && (
<div className="m-5 rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error)]/5 p-4 text-sm text-[var(--color-error)]">
{state.message}
@ -201,9 +208,9 @@ function TraceRows({
if (traces.length === 0) {
return (
<div className="flex flex-1 items-center justify-center px-6 text-center">
<div className="max-w-sm">
<Workflow className="mx-auto h-8 w-8 text-[var(--color-text-tertiary)]" strokeWidth={1.5} aria-hidden="true" />
<div className="flex flex-1 items-start justify-center px-6 py-10">
<div className="w-full max-w-md rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6 py-12 text-center">
<Workflow className="mx-auto h-8 w-8 text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
<h2 className="mt-3 text-sm font-semibold text-[var(--color-text-primary)]">{t('trace.list.emptyTitle')}</h2>
<p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{t('trace.list.emptyBody')}</p>
</div>
@ -213,18 +220,12 @@ function TraceRows({
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="grid grid-cols-[minmax(260px,1.5fr)_120px_90px_120px_minmax(160px,1fr)_96px] border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-2 text-[10px] font-semibold uppercase text-[var(--color-text-tertiary)]">
<div>{t('trace.list.session')}</div>
<div>{t('trace.apiCalls')}</div>
<div>{t('trace.failedCalls')}</div>
<div>{t('trace.duration')}</div>
<div>{t('trace.models')}</div>
<div className="text-right">{t('trace.list.actions')}</div>
<div className="divide-y divide-[var(--color-border)]">
{traces.map((trace) => (
<TraceRow key={trace.sessionId} trace={trace} onOpenWindow={onOpenWindow} />
))}
</div>
{traces.map((trace) => (
<TraceRow key={trace.sessionId} trace={trace} onOpenWindow={onOpenWindow} />
))}
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-5 py-3 text-xs text-[var(--color-text-tertiary)]">
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-5 py-3 text-xs text-[var(--color-text-tertiary)]">
<span>{t('trace.list.loadedCount', { shown: traces.length, total })}</span>
{traces.length < total && (
<Button size="sm" variant="secondary" onClick={onLoadMore} disabled={loadingMore}>
@ -245,69 +246,151 @@ function TraceRow({
}) {
const t = useTranslation()
const title = trace.session?.title || t('session.untitled')
const modelLabel = trace.summary.models.map((model) => `${model.model} x${model.calls}`).join(', ') || t('trace.noModel')
const updatedAt = trace.summary.updatedAt ?? trace.fileUpdatedAt
const hasError = trace.summary.failedCalls > 0
const failedCalls = trace.summary.failedCalls
const visibleModels = trace.summary.models.slice(0, MAX_MODEL_CHIPS)
const hiddenModels = trace.summary.models.length - visibleModels.length
const totalTokens = trace.summary.totalInputTokens + trace.summary.totalOutputTokens
const open = () => openTrace(trace.sessionId, title, t)
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
open()
}
return (
<div className="grid grid-cols-[minmax(260px,1.5fr)_120px_90px_120px_minmax(160px,1fr)_96px] items-center gap-0 border-b border-[var(--color-border)] px-5 py-3 text-sm hover:bg-[var(--color-surface-hover)]">
<div className="min-w-0">
<button
type="button"
onClick={() => openTrace(trace.sessionId, title, t)}
className="block min-w-0 text-left"
>
<div className="flex min-w-0 items-center gap-2">
{hasError
? <AlertTriangle className="h-4 w-4 shrink-0 text-[var(--color-error)]" strokeWidth={1.8} aria-hidden="true" />
: <CheckCircle2 className="h-4 w-4 shrink-0 text-[var(--color-success)]" strokeWidth={1.8} aria-hidden="true" />}
<span className="truncate font-medium text-[var(--color-text-primary)]">{title}</span>
</div>
<div className="mt-1 flex min-w-0 gap-2 text-xs text-[var(--color-text-tertiary)]">
<span className="truncate font-mono">{trace.sessionId}</span>
<span className="shrink-0">{formatUpdatedAt(updatedAt)}</span>
</div>
{trace.session?.projectPath && (
<div className="mt-1 truncate text-[11px] text-[var(--color-text-tertiary)]">{trace.session.projectPath}</div>
<div
role="button"
tabIndex={0}
onClick={open}
onKeyDown={onKeyDown}
className="group flex h-14 cursor-pointer items-center gap-4 px-5 transition-colors hover:bg-[var(--color-surface-hover)]"
style={ROW_CV_STYLE}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-sm font-semibold text-[var(--color-text-primary)]">{title}</span>
{visibleModels.map((model) => (
<span
key={model.model}
title={`${model.model} x${model.calls}`}
className="shrink-0 rounded-[var(--radius-sm)] bg-[var(--color-brand)]/10 px-1.5 py-0.5 font-mono text-[10px] leading-4 text-[var(--color-brand)]"
>
{shortModelName(model.model)}
</span>
))}
{hiddenModels > 0 && (
<span className="shrink-0 rounded-[var(--radius-sm)] bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[10px] leading-4 text-[var(--color-text-tertiary)]">
+{hiddenModels}
</span>
)}
</button>
{failedCalls > 0 && (
<span title={t('trace.failedCalls')} className="flex shrink-0 items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-error)]" aria-hidden="true" />
<span className="font-mono text-[10px] text-[var(--color-error)]">{failedCalls}</span>
</span>
)}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] text-[var(--color-text-tertiary)]">
<span className="shrink-0 font-mono">{trace.sessionId.slice(0, 8)}</span>
{trace.session?.projectPath && (
<>
<span aria-hidden="true">·</span>
<span className="truncate" title={trace.session.projectPath}>{trace.session.projectPath}</span>
</>
)}
<span aria-hidden="true">·</span>
<span className="shrink-0 font-mono">{formatUpdatedAt(updatedAt)}</span>
</div>
</div>
<div className="font-mono text-[var(--color-text-primary)]">{trace.summary.apiCalls}</div>
<div className={hasError ? 'font-mono text-[var(--color-error)]' : 'font-mono text-[var(--color-text-primary)]'}>{trace.summary.failedCalls}</div>
<div className="font-mono text-[var(--color-text-secondary)]">{formatDuration(trace.summary.totalDurationMs)}</div>
<div className="min-w-0 truncate text-xs text-[var(--color-text-secondary)]" title={modelLabel}>{modelLabel}</div>
<div className="flex justify-end gap-1">
<button
type="button"
onClick={() => openTrace(trace.sessionId, title, t)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
aria-label={t('trace.open')}
title={t('trace.open')}
>
<Workflow className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => onOpenWindow(trace.sessionId)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
aria-label={t('trace.openWindow')}
title={t('trace.openWindow')}
>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
</button>
<div className="grid shrink-0 grid-cols-[3.5rem_4rem_4rem] items-center gap-3">
<MetricCell label={t('trace.apiCalls')} value={String(trace.summary.apiCalls)} />
<MetricCell label={t('trace.duration')} value={formatDuration(trace.summary.totalDurationMs)} />
<MetricCell label={t('trace.tokens')} value={formatCompact(totalTokens)} />
</div>
<div className="col-span-6 mt-2 hidden text-[11px] text-[var(--color-text-tertiary)] md:block">
{t('trace.list.fileSize')}: {formatBytes(trace.fileSize)}
<div className="flex w-[60px] shrink-0 items-center justify-end gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<RowAction
label={t('trace.open')}
onClick={(event) => {
event.stopPropagation()
open()
}}
>
<Workflow className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</RowAction>
<RowAction
label={t('trace.openWindow')}
onClick={(event) => {
event.stopPropagation()
onOpenWindow(trace.sessionId)
}}
>
<ExternalLink className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</RowAction>
</div>
</div>
)
}
function Metric({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) {
function RowAction({
label,
onClick,
children,
}: {
label: string
onClick: (event: MouseEvent<HTMLButtonElement>) => void
children: ReactNode
}) {
return (
<div>
<div className="text-[10px] font-semibold uppercase text-[var(--color-text-tertiary)]">{label}</div>
<div className={`mt-1 truncate font-mono text-lg ${tone === 'danger' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-primary)]'}`}>{value}</div>
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
>
{children}
</button>
)
}
function MetricCell({ label, value }: { label: string; value: string }) {
return (
<div className="text-right">
<div className="font-mono text-[11px] leading-4 text-[var(--color-text-primary)]">{value}</div>
<div className="truncate text-[10px] uppercase leading-4 tracking-wide text-[var(--color-text-tertiary)]" title={label}>{label}</div>
</div>
)
}
function MetaChip({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) {
return (
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-[var(--color-text-tertiary)]">{label}</span>
<span className={`font-mono text-[13px] ${tone === 'danger' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-primary)]'}`}>{value}</span>
</div>
)
}
function TraceListSkeleton({ label }: { label: string }) {
return (
<div className="min-h-0 flex-1 overflow-hidden" role="status" aria-label={label}>
<div className="divide-y divide-[var(--color-border)]" aria-hidden="true">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="flex h-14 items-center gap-4 px-5">
<div className="min-w-0 flex-1">
<div className="h-3 w-48 max-w-full animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="mt-2 h-2.5 w-72 max-w-full animate-pulse rounded bg-[var(--color-surface-container-low)]" />
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="h-3 w-10 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="h-3 w-12 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="h-3 w-12 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
</div>
</div>
))}
</div>
</div>
)
}
@ -321,6 +404,22 @@ function openTraceSettings(t: ReturnType<typeof useTranslation>) {
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
}
/** `claude-sonnet-4-5-20250929` -> `sonnet-4-5`; non-Claude ids pass through. */
function shortModelName(model: string): string {
const short = model.replace(/^claude-/i, '').replace(/-\d{8}$/, '')
return short || model
}
/** Compact count: 847 -> "847", 1234 -> "1.2k", 2345678 -> "2.3m". */
function formatCompact(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '0'
if (value < 1000) return String(value)
const scaled = value < 1_000_000 ? value / 1000 : value / 1_000_000
const unit = value < 1_000_000 ? 'k' : 'm'
const text = scaled >= 100 ? String(Math.round(scaled)) : scaled.toFixed(1).replace(/\.0$/, '')
return `${text}${unit}`
}
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '-'
if (ms < 1000) return `${Math.round(ms)}ms`

View File

@ -1,23 +1,111 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TraceSession } from './TraceSession'
import { sessionsApi } from '../api/sessions'
import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
import type { TraceSession as TraceSessionData } from '../types/trace'
import { clearTraceCallCache } from '../lib/trace/callCache'
import { resetTraceSectionState } from '../components/trace/detail/Section'
import type { MessageEntry } from '../types/session'
import type { TraceCallRecord, TraceSession as TraceSessionData } from '../types/trace'
vi.mock('../api/sessions', () => ({
sessionsApi: {
getTrace: vi.fn(),
getMessages: vi.fn(),
getTraceCall: vi.fn(),
},
}))
const SESSION_ID = 'session-live'
const requestPreview = JSON.stringify({
model: 'claude-sonnet-4-5',
system: 'You are helpful.',
messages: [{ role: 'user', content: 'Hello world' }],
tools: [{ name: 'Bash', description: 'Run shell commands', input_schema: { type: 'object' } }],
max_tokens: 4096,
})
function makeCall(overrides: Partial<TraceCallRecord> = {}): TraceCallRecord {
return {
id: 'call-1',
sessionId: SESSION_ID,
source: 'anthropic',
provider: { id: 'provider-main', name: 'Anthropic Direct', format: 'anthropic' },
model: 'claude-sonnet-4-5',
startedAt: '2026-06-09T10:00:01.000Z',
completedAt: '2026-06-09T10:00:03.000Z',
durationMs: 2000,
usage: { inputTokens: 1200, outputTokens: 847 },
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
headers: { 'content-type': 'application/json' },
body: {
contentType: 'json',
bytes: requestPreview.length,
sha256: 'a'.repeat(64),
preview: requestPreview,
truncated: true,
},
},
response: {
status: 200,
headers: { 'content-type': 'application/json' },
body: {
contentType: 'json',
bytes: 96,
sha256: 'b'.repeat(64),
preview: JSON.stringify({
model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'Hi (preview)' }],
stop_reason: 'end_turn',
usage: { input_tokens: 1200, output_tokens: 847 },
}),
truncated: true,
},
},
...overrides,
}
}
const fullCall = makeCall({
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
headers: { 'content-type': 'application/json' },
body: {
contentType: 'json',
bytes: requestPreview.length,
sha256: 'a'.repeat(64),
preview: requestPreview,
truncated: false,
},
},
response: {
status: 200,
headers: { 'content-type': 'application/json' },
body: {
contentType: 'json',
bytes: 128,
sha256: 'b'.repeat(64),
preview: JSON.stringify({
model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'Hi from the full record' }],
stop_reason: 'end_turn',
usage: { input_tokens: 1200, output_tokens: 847 },
}),
truncated: false,
},
},
})
const baseTrace: TraceSessionData = {
sessionId: 'session-live',
sessionId: SESSION_ID,
session: {
id: 'session-live',
id: SESSION_ID,
title: 'Trace API title',
projectPath: '/tmp',
workDir: '/tmp',
@ -25,63 +113,69 @@ const baseTrace: TraceSessionData = {
summary: {
apiCalls: 1,
failedCalls: 0,
totalDurationMs: 1200,
totalInputTokens: 0,
totalOutputTokens: 0,
models: [{ model: 'gpt-5.5', calls: 1 }],
updatedAt: '2026-06-09T10:10:00.000Z',
totalDurationMs: 2000,
totalInputTokens: 1200,
totalOutputTokens: 847,
models: [{ model: 'claude-sonnet-4-5', calls: 1 }],
updatedAt: '2026-06-09T10:00:06.000Z',
},
calls: [{
id: 'call-1',
sessionId: 'session-live',
source: 'anthropic' as const,
provider: { id: 'provider-sub2api', name: 'Sub2API-ChatGPT', format: 'anthropic' },
model: 'gpt-5.5',
startedAt: '2026-06-09T10:09:59.000Z',
completedAt: '2026-06-09T10:10:00.000Z',
durationMs: 1200,
request: {
method: 'POST',
url: 'https://sub2api.example/v1/messages',
headers: { authorization: '[redacted]' },
body: {
contentType: 'json' as const,
bytes: 26,
sha256: 'a'.repeat(64),
preview: '{"model":"gpt-5.5"}',
truncated: false,
},
},
response: {
status: 200,
headers: {},
body: {
contentType: 'json' as const,
bytes: 11,
sha256: 'b'.repeat(64),
preview: '{"ok":true}',
truncated: false,
},
},
}],
calls: [makeCall()],
}
const baseMessages: MessageEntry[] = [
{ id: 'msg-1', type: 'user', content: 'Hello world', timestamp: '2026-06-09T10:00:00.000Z' },
{
id: 'msg-2',
type: 'assistant',
content: [{ type: 'text', text: 'Hi there' }],
timestamp: '2026-06-09T10:00:04.000Z',
model: 'claude-sonnet-4-5',
},
{
id: 'msg-3',
type: 'tool_use',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls -la' } }],
timestamp: '2026-06-09T10:00:05.000Z',
},
{
id: 'msg-4',
type: 'tool_result',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'file.txt' }],
timestamp: '2026-06-09T10:00:06.000Z',
},
]
async function renderReady(pollIntervalMs = 60_000) {
render(<TraceSession sessionId={SESSION_ID} pollIntervalMs={pollIntervalMs} />)
await screen.findByTestId('trace-split-layout')
}
describe('TraceSession', () => {
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
writable: true,
value: vi.fn(),
})
clearTraceCallCache()
resetTraceSectionState()
window.localStorage.clear()
useSettingsStore.setState({ locale: 'en' })
vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: [] })
vi.mocked(sessionsApi.getTrace).mockResolvedValue(baseTrace)
vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: baseMessages })
vi.mocked(sessionsApi.getTraceCall).mockResolvedValue({ call: fullCall })
useSessionStore.setState({
sessions: [{
id: 'session-live',
id: SESSION_ID,
title: 'Live probe',
createdAt: '2026-06-09T10:00:00.000Z',
modifiedAt: '2026-06-09T10:10:00.000Z',
messageCount: 0,
messageCount: baseMessages.length,
projectPath: '/tmp',
workDir: '/tmp',
workDirExists: true,
}],
activeSessionId: 'session-live',
activeSessionId: SESSION_ID,
isLoading: false,
error: null,
})
@ -94,81 +188,239 @@ describe('TraceSession', () => {
useSettingsStore.setState({ locale: 'en' })
})
it('refreshes the trace snapshot while the page is open', async () => {
it('renders the two-pane layout with tree and detail', async () => {
await renderReady()
expect(screen.getByTestId('trace-header')).toBeInTheDocument()
expect(screen.getByTestId('trace-tree')).toBeInTheDocument()
expect(screen.getByTestId('trace-detail')).toBeInTheDocument()
expect(screen.getByTestId('trace-split-divider')).toBeInTheDocument()
// Session root is selected by default and shows the overview grid.
const detail = within(screen.getByTestId('trace-detail'))
expect(detail.getByTestId('trace-overview')).toBeInTheDocument()
expect(detail.getByText('LLM calls')).toBeInTheDocument()
// Timeline rows for messages, the model call, and the tool call.
const tree = within(screen.getByTestId('trace-tree'))
expect(tree.getByText('User message')).toBeInTheDocument()
expect(tree.getByText('claude-sonnet-4-5')).toBeInTheDocument()
expect(tree.getByText('Bash')).toBeInTheDocument()
})
it('groups timeline rows by turn with user message previews', async () => {
vi.mocked(sessionsApi.getMessages).mockResolvedValue({
messages: [
...baseMessages,
{ id: 'msg-5', type: 'user', content: 'Second question', timestamp: '2026-06-09T10:05:00.000Z' },
],
})
await renderReady()
const tree = within(screen.getByTestId('trace-tree'))
expect(tree.getByText('Turn 1')).toBeInTheDocument()
expect(tree.getByText('Turn 2')).toBeInTheDocument()
// Preview text shows in both the turn header and the user message row.
expect(tree.getAllByText('Hello world').length).toBeGreaterThan(0)
expect(tree.getAllByText('Second question').length).toBeGreaterThan(0)
// Collapsing a turn hides its rows.
expect(tree.getByText('Bash')).toBeInTheDocument()
fireEvent.click(tree.getAllByRole('button', { name: 'Toggle turn' })[0]!)
expect(tree.queryByText('Bash')).not.toBeInTheDocument()
})
it('selecting a tree row drives the detail panel', async () => {
await renderReady()
const tree = within(screen.getByTestId('trace-tree'))
fireEvent.click(tree.getByText('Bash'))
const detail = within(screen.getByTestId('trace-detail'))
expect(detail.getByRole('heading', { level: 2, name: 'Bash' })).toBeInTheDocument()
expect(detail.getByTestId('trace-tool-detail')).toBeInTheDocument()
expect(detail.getByText('Input')).toBeInTheDocument()
expect(detail.getByText('Result')).toBeInTheDocument()
expect(detail.getByText('file.txt')).toBeInTheDocument()
})
it('filters lifecycle noise events out of the tree but keeps error events', async () => {
vi.mocked(sessionsApi.getTrace).mockResolvedValue({
...baseTrace,
events: [
{
id: 'event-noise',
sessionId: SESSION_ID,
callId: 'call-1',
source: 'anthropic',
timestamp: '2026-06-09T10:00:01.100Z',
phase: 'api_call_started',
severity: 'info',
},
{
id: 'event-failed',
sessionId: SESSION_ID,
callId: 'call-1',
source: 'anthropic',
timestamp: '2026-06-09T10:00:02.000Z',
phase: 'api_call_failed',
severity: 'error',
message: 'network down',
},
],
})
await renderReady()
const tree = within(screen.getByTestId('trace-tree'))
expect(tree.getByText('API call failed')).toBeInTheDocument()
expect(tree.queryByText('API call started')).not.toBeInTheDocument()
})
it('loads the full call record on demand and renders semantic sections', async () => {
await renderReady()
fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5'))
await waitFor(() => expect(sessionsApi.getTraceCall).toHaveBeenCalledWith(SESSION_ID, 'call-1'))
const detail = within(screen.getByTestId('trace-detail'))
expect(await detail.findByText('Hi from the full record')).toBeInTheDocument()
// Section flow: Response (open) / Messages (open) / System prompt / Tools / Parameters / Raw.
expect(detail.getByRole('button', { name: /^Response/ })).toBeInTheDocument()
expect(detail.getByRole('button', { name: /^Messages/ })).toBeInTheDocument()
expect(detail.getByText('Hello world')).toBeInTheDocument()
expect(detail.getByText('end_turn')).toBeInTheDocument()
expect(detail.getByRole('button', { name: /^Tools/ })).toBeInTheDocument()
expect(detail.getByRole('button', { name: /^Parameters/ })).toBeInTheDocument()
expect(detail.getByRole('button', { name: 'Raw' })).toBeInTheDocument()
// System prompt is collapsed by default; expanding reveals the text.
expect(detail.queryByText('You are helpful.')).not.toBeInTheDocument()
fireEvent.click(detail.getByRole('button', { name: /^System prompt/ }))
expect(detail.getByText('You are helpful.')).toBeInTheDocument()
// Header badge row carries the usage brief.
expect(detail.getByText('1.2k → 847')).toBeInTheDocument()
})
it('falls back to Raw with a legacy notice when the body cannot be parsed', async () => {
const legacyCall = makeCall({
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
headers: {},
body: {
contentType: 'json',
bytes: 4096,
sha256: 'c'.repeat(64),
preview: '{"model":"claude-sonnet-4-5","messages":[{"role"',
truncated: true,
},
},
})
vi.mocked(sessionsApi.getTrace).mockResolvedValue({ ...baseTrace, calls: [legacyCall] })
vi.mocked(sessionsApi.getTraceCall).mockResolvedValue({ call: legacyCall })
await renderReady()
fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5'))
const detail = within(screen.getByTestId('trace-detail'))
expect(await detail.findByText('Legacy truncated record; the semantic view is unavailable. See Raw below.')).toBeInTheDocument()
// Raw section opens by default in fallback mode; semantic sections are skipped.
await waitFor(() => expect(detail.getByText('Request body')).toBeInTheDocument())
expect(detail.queryByRole('button', { name: /^Messages/ })).not.toBeInTheDocument()
})
it('applies poll updates and short-circuits identical snapshots', async () => {
vi.mocked(sessionsApi.getTrace)
.mockResolvedValueOnce(baseTrace)
.mockResolvedValueOnce({
.mockResolvedValueOnce(baseTrace)
.mockResolvedValueOnce(baseTrace)
.mockResolvedValue({
...baseTrace,
summary: {
...baseTrace.summary,
apiCalls: 2,
models: [{ model: 'gpt-5.5', calls: 2 }],
updatedAt: '2026-06-09T10:00:09.000Z',
models: [{ model: 'claude-sonnet-4-5', calls: 2 }],
},
calls: [
...baseTrace.calls,
{ ...baseTrace.calls[0]!, id: 'call-2', durationMs: 900 },
],
calls: [baseTrace.calls[0]!, makeCall({ id: 'call-2', startedAt: '2026-06-09T10:00:08.000Z' })],
})
await renderReady(20)
render(<TraceSession sessionId="session-live" pollIntervalMs={20} />)
// Select the model call; identical poll ticks must not reset the selection
// or re-trigger the on-demand detail fetch.
fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5'))
await waitFor(() => expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1))
await waitFor(() => expect(vi.mocked(sessionsApi.getTrace).mock.calls.length).toBeGreaterThanOrEqual(3))
await screen.findByText('gpt-5.5 x1')
expect(sessionsApi.getTrace).toHaveBeenCalledWith('session-live')
// The grown snapshot lands and renders both calls.
await screen.findByText('claude-sonnet-4-5 x2')
expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1)
const detail = within(screen.getByTestId('trace-detail'))
expect(detail.getByRole('heading', { level: 2, name: 'claude-sonnet-4-5' })).toBeInTheDocument()
})
await waitFor(() => expect(screen.getByText('gpt-5.5 x2')).toBeInTheDocument())
expect(vi.mocked(sessionsApi.getTrace).mock.calls.length).toBeGreaterThanOrEqual(2)
it('supports keyboard navigation in the tree', async () => {
await renderReady()
const tree = screen.getByRole('tree')
const detail = within(screen.getByTestId('trace-detail'))
// First ArrowDown lands on the turn header.
fireEvent.keyDown(tree, { key: 'ArrowDown' })
expect(detail.getByRole('heading', { level: 2, name: 'Hello world' })).toBeInTheDocument()
// Next ArrowDown moves to the first row inside the turn.
fireEvent.keyDown(tree, { key: 'ArrowDown' })
expect(detail.getByRole('heading', { level: 2, name: 'User message' })).toBeInTheDocument()
expect(screen.getByRole('treeitem', { name: /User message/ })).toHaveAttribute('aria-selected', 'true')
// ArrowUp returns to the turn header.
fireEvent.keyDown(tree, { key: 'ArrowUp' })
expect(detail.getByRole('heading', { level: 2, name: 'Hello world' })).toBeInTheDocument()
})
it('shows the error state with retry when the trace load fails', async () => {
vi.mocked(sessionsApi.getTrace)
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValue(baseTrace)
render(<TraceSession sessionId={SESSION_ID} pollIntervalMs={60_000} />)
expect(await screen.findByText('Failed to load trace')).toBeInTheDocument()
expect(screen.getByText('boom')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByTestId('trace-split-layout')).toBeInTheDocument()
})
it('shows the empty state when the session has no captured activity', async () => {
vi.mocked(sessionsApi.getTrace).mockResolvedValue({
...baseTrace,
summary: {
...baseTrace.summary,
apiCalls: 0,
totalDurationMs: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
models: [],
},
calls: [],
})
vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: [] })
render(<TraceSession sessionId={SESSION_ID} pollIntervalMs={60_000} />)
expect(await screen.findByText('No trace calls yet')).toBeInTheDocument()
expect(screen.queryByTestId('trace-split-layout')).not.toBeInTheDocument()
})
it('uses trace session metadata when the sidebar store has not loaded the session', async () => {
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
vi.mocked(sessionsApi.getTrace).mockResolvedValue(baseTrace)
render(<TraceSession sessionId="session-live" standalone pollIntervalMs={60_000} />)
render(<TraceSession sessionId={SESSION_ID} standalone pollIntervalMs={60_000} />)
expect(await screen.findByRole('heading', { level: 1, name: 'Trace API title' })).toBeInTheDocument()
})
it('renders lifecycle events as trace spans', async () => {
vi.mocked(sessionsApi.getTrace).mockResolvedValue({
...baseTrace,
events: [{
id: 'event-failed',
sessionId: 'session-live',
callId: 'call-1',
source: 'anthropic',
timestamp: '2026-06-09T10:10:00.100Z',
phase: 'api_call_failed',
severity: 'error',
message: 'network down',
}],
})
render(<TraceSession sessionId="session-live" pollIntervalMs={60_000} />)
expect((await screen.findAllByText('API call failed')).length).toBeGreaterThan(0)
expect(screen.getAllByText('network down').length).toBeGreaterThan(0)
expect(screen.getByText('Trace event failed')).toBeInTheDocument()
})
it('renders trace navigation and inspector labels in the active locale', async () => {
useSettingsStore.setState({ locale: 'zh' })
vi.mocked(sessionsApi.getTrace).mockResolvedValue(baseTrace)
render(<TraceSession sessionId="session-live" pollIntervalMs={60_000} />)
expect(await screen.findByText('运行树')).toBeInTheDocument()
expect(screen.getByPlaceholderText('搜索 Span')).toBeInTheDocument()
expect(screen.getByText('对话线程')).toBeInTheDocument()
expect(screen.getByText('诊断')).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '输入' })).toBeInTheDocument()
expect(screen.queryByText('Run tree')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Search spans')).not.toBeInTheDocument()
expect(screen.queryByText('Thread')).not.toBeInTheDocument()
expect(screen.queryByText('Session activity')).not.toBeInTheDocument()
fireEvent.click(screen.getAllByRole('button', { name: /gpt-5\.5/ })[0]!)
fireEvent.click(screen.getByRole('tab', { name: '输入' }))
expect(screen.getByText('请求正文')).toBeInTheDocument()
expect(screen.getByText('请求头')).toBeInTheDocument()
})
})

File diff suppressed because it is too large Load Diff

View File

@ -1436,3 +1436,11 @@ button, input, textarea, select, a, [role="button"] {
content-visibility: auto;
contain-intrinsic-size: auto 300px;
}
/* Same WebKit paint-skipping trick for trace tree rows (see .chat-render-item--cv
above). Tree rows are fixed-density 34px single-line items, so the intrinsic
size estimate is exact and scrolling never drifts. */
.trace-row-cv {
content-visibility: auto;
contain-intrinsic-size: auto 34px;
}

View File

@ -15,6 +15,13 @@ export type TraceProviderInfo = {
export type TraceCallStatus = 'pending' | 'ok' | 'error'
export type TraceEventSeverity = 'info' | 'warning' | 'error'
export type TraceCallUsage = {
inputTokens: number
outputTokens: number
cacheReadInputTokens?: number
cacheCreationInputTokens?: number
}
export type TraceCallRecord = {
id: string
sessionId: string
@ -26,6 +33,7 @@ export type TraceCallRecord = {
startedAt: string
completedAt?: string
durationMs?: number
usage?: TraceCallUsage
metadata?: Record<string, unknown>
request: {
method: string

View File

@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { createHash } from 'crypto'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
@ -7,6 +8,7 @@ import {
clearTraceCaptureStateForTests,
createTraceCallId,
createTraceBodySnapshot,
readResponseTraceSnapshot,
traceCaptureService,
updateTraceCaptureSettings,
} from '../services/traceCaptureService.js'
@ -53,7 +55,7 @@ describe('trace capture service', () => {
messages: [
{ role: 'user', content: 'explain the failed provider response' },
],
padding: 'x'.repeat(3000),
padding: 'x'.repeat(250_000),
}
await traceCaptureService.recordCall({
@ -96,12 +98,17 @@ describe('trace capture service', () => {
expect(trace.summary.apiCalls).toBe(1)
expect(trace.summary.failedCalls).toBe(0)
expect(trace.summary.totalDurationMs).toBe(47)
expect(trace.summary.totalInputTokens).toBe(31)
expect(trace.summary.totalOutputTokens).toBe(7)
expect(trace.summary.models).toEqual([{ model: 'deepseek-v4-pro', calls: 1 }])
expect(trace.calls[0].request.headers.Authorization).toBe('[redacted]')
expect(trace.calls[0].request.body.preview).toContain('explain the failed provider response')
expect(trace.calls[0].request.body.preview).not.toContain('sk-body-secret')
expect(trace.calls[0].request.body.preview.length).toBe(240_000)
expect(trace.calls[0].request.body.bytes).toBeGreaterThan(240_000)
expect(trace.calls[0].request.body.truncated).toBe(true)
expect(trace.calls[0].response.body.preview).toContain('chatcmpl-742')
expect(trace.calls[0].usage).toEqual({ inputTokens: 31, outputTokens: 7 })
})
test('builds stable body snapshots without throwing on non-json input', () => {
@ -113,6 +120,264 @@ describe('trace capture service', () => {
expect(snapshot.sha256).toMatch(/^[0-9a-f]{64}$/)
})
test('redacts secret token keys while preserving token-count fields', async () => {
await traceCaptureService.recordCall({
sessionId: 'session-redact-boundary',
source: 'anthropic',
model: 'claude-fable-5',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:00.050Z',
durationMs: 50,
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
body: {
model: 'claude-fable-5',
max_tokens: 4096,
access_token: 'super-secret-value',
refresh_token: 'another-secret-value',
},
},
response: {
status: 200,
body: {
id: 'msg-redact-boundary',
usage: { input_tokens: 12, output_tokens: 34 },
},
},
})
const trace = await traceCaptureService.getSessionTrace('session-redact-boundary')
const requestPreview = trace.calls[0].request.body.preview
expect(requestPreview).toContain('"max_tokens": 4096')
expect(requestPreview).not.toContain('super-secret-value')
expect(requestPreview).not.toContain('another-secret-value')
expect(trace.calls[0].response?.body.preview).toContain('"input_tokens": 12')
expect(trace.calls[0].usage).toEqual({ inputTokens: 12, outputTokens: 34 })
})
test('captures streamed response bodies up to 1MB before truncating', async () => {
const chunk = 'a'.repeat(64 * 1024)
const makeResponse = (chunkCount: number) => new Response(
new ReadableStream<Uint8Array>({
start(controller) {
for (let index = 0; index < chunkCount; index++) {
controller.enqueue(new TextEncoder().encode(chunk))
}
controller.close()
},
}),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
)
const midSized = await readResponseTraceSnapshot(makeResponse(6))
expect(midSized.bytes).toBe(6 * 64 * 1024)
expect(midSized.sha256).toBe(createHash('sha256').update(chunk.repeat(6)).digest('hex'))
expect(midSized.preview.length).toBe(240_000)
expect(midSized.truncated).toBe(true)
const oversized = await readResponseTraceSnapshot(makeResponse(17))
expect(oversized.bytes).toBe(1024 * 1024)
expect(oversized.truncated).toBe(true)
})
test('extracts per-call usage from non-streaming anthropic JSON responses', async () => {
await traceCaptureService.recordCall({
sessionId: 'session-usage-json',
source: 'anthropic',
model: 'claude-fable-5',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:01.000Z',
durationMs: 1000,
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
body: { model: 'claude-fable-5', messages: [{ role: 'user', content: 'usage me' }] },
},
response: {
status: 200,
body: {
id: 'msg-usage-json',
content: [{ type: 'text', text: 'ok' }],
usage: {
input_tokens: 1200,
output_tokens: 350,
cache_read_input_tokens: 800,
cache_creation_input_tokens: 45,
},
},
},
})
const trace = await traceCaptureService.getSessionTrace('session-usage-json')
expect(trace.calls[0].usage).toEqual({
inputTokens: 1200,
outputTokens: 350,
cacheReadInputTokens: 800,
cacheCreationInputTokens: 45,
})
expect(trace.summary.totalInputTokens).toBe(1200)
expect(trace.summary.totalOutputTokens).toBe(350)
})
test('extracts per-call usage from streaming SSE previews by merging message_start and message_delta', async () => {
const sseBody = [
'event: message_start',
'data: {"type":"message_start","message":{"id":"msg_stream","model":"claude-fable-5","usage":{"input_tokens":2500,"output_tokens":2,"cache_read_input_tokens":1800,"cache_creation_input_tokens":90}}}',
'',
'event: content_block_delta',
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}',
'',
'event: message_delta',
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":640}}',
'',
'event: message_stop',
'data: {"type":"message_stop"}',
'',
].join('\n')
await traceCaptureService.recordCall({
sessionId: 'session-usage-sse',
source: 'anthropic',
model: 'claude-fable-5',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:02.000Z',
durationMs: 2000,
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
body: { model: 'claude-fable-5', stream: true },
},
response: {
status: 200,
body: sseBody,
},
})
const trace = await traceCaptureService.getSessionTrace('session-usage-sse')
expect(trace.calls[0].usage).toEqual({
inputTokens: 2500,
outputTokens: 640,
cacheReadInputTokens: 1800,
cacheCreationInputTokens: 90,
})
expect(trace.summary.totalInputTokens).toBe(2500)
expect(trace.summary.totalOutputTokens).toBe(640)
})
test('extracts per-call usage from the anthropic side of proxy response wrappers', async () => {
await traceCaptureService.recordCall({
sessionId: 'session-usage-proxy',
source: 'proxy',
model: 'deepseek-v4-pro',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:01.500Z',
durationMs: 1500,
request: {
method: 'POST',
url: 'https://api.deepseek.com/v1/chat/completions',
body: {
anthropic: { model: 'deepseek-v4-pro' },
upstream: { model: 'deepseek-chat' },
},
},
response: {
status: 200,
body: {
upstream: { usage: { prompt_tokens: 999, completion_tokens: 111 } },
anthropic: {
id: 'msg-proxy-usage',
usage: {
input_tokens: 77,
output_tokens: 33,
cache_read_input_tokens: 5,
cache_creation_input_tokens: 0,
},
},
},
},
})
const trace = await traceCaptureService.getSessionTrace('session-usage-proxy')
expect(trace.calls[0].usage).toEqual({
inputTokens: 77,
outputTokens: 33,
cacheReadInputTokens: 5,
cacheCreationInputTokens: 0,
})
expect(trace.summary.totalInputTokens).toBe(77)
expect(trace.summary.totalOutputTokens).toBe(33)
})
test('omits usage when the response preview is missing, truncated or unparsable', async () => {
await traceCaptureService.recordCall({
id: 'call-usage-truncated',
sessionId: 'session-usage-missing',
source: 'anthropic',
model: 'claude-fable-5',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:01.000Z',
durationMs: 1000,
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
body: { model: 'claude-fable-5' },
},
response: {
status: 200,
bodySnapshot: createTraceBodySnapshot(
{ id: 'msg-truncated', usage: { input_tokens: 100, output_tokens: 50 } },
{ maxPreviewChars: 24 },
),
},
})
await traceCaptureService.recordCall({
id: 'call-usage-pending',
sessionId: 'session-usage-missing',
source: 'anthropic',
model: 'claude-fable-5',
status: 'pending',
startedAt: '2026-06-09T08:00:02.000Z',
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
body: { model: 'claude-fable-5' },
},
})
await traceCaptureService.recordCall({
id: 'call-usage-absent',
sessionId: 'session-usage-missing',
source: 'anthropic',
model: 'claude-fable-5',
startedAt: '2026-06-09T08:00:03.000Z',
completedAt: '2026-06-09T08:00:04.000Z',
durationMs: 1000,
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
body: { model: 'claude-fable-5' },
},
response: {
status: 200,
body: { ok: true },
},
})
const trace = await traceCaptureService.getSessionTrace('session-usage-missing')
expect(trace.calls).toHaveLength(3)
expect(trace.calls[0].response?.body.truncated).toBe(true)
for (const call of trace.calls) {
expect(call.usage).toBeUndefined()
}
expect(trace.summary.totalInputTokens).toBe(0)
expect(trace.summary.totalOutputTokens).toBe(0)
})
test('skips malformed trace jsonl entries when reading a session', async () => {
const traceDir = path.join(tmpDir, 'cc-haha', 'traces')
await fs.mkdir(traceDir, { recursive: true })
@ -391,6 +656,117 @@ describe('session trace API', () => {
expect(body.events).toEqual([])
})
test('trims call body previews in the session trace list response without touching stored data', async () => {
const recorded = await traceCaptureService.recordCall({
sessionId: 'session-trim-api',
source: 'anthropic',
model: 'claude-fable-5',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:01.000Z',
durationMs: 1000,
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
body: {
model: 'claude-fable-5',
messages: [{ role: 'user', content: 'find the trimmed call' }],
padding: 'y'.repeat(6000),
},
},
response: {
status: 200,
body: {
id: 'msg-trim-api',
content: [{ type: 'text', text: 'z'.repeat(6000) }],
usage: { input_tokens: 10, output_tokens: 20 },
},
},
})
const req = new Request('http://localhost:3456/api/sessions/session-trim-api/trace')
const res = await handleApiRequest(req, new URL(req.url))
const body = await res.json() as {
calls: Array<{
usage?: { inputTokens: number; outputTokens: number }
request: { body: { preview: string; truncated: boolean; bytes: number; sha256: string } }
response?: { body: { preview: string; truncated: boolean; bytes: number; sha256: string } }
}>
}
expect(res.status).toBe(200)
expect(body.calls).toHaveLength(1)
expect(body.calls[0].request.body.preview.length).toBe(2048)
expect(body.calls[0].request.body.truncated).toBe(true)
expect(body.calls[0].response?.body.preview.length).toBe(2048)
expect(body.calls[0].response?.body.truncated).toBe(true)
expect(body.calls[0].usage).toEqual({ inputTokens: 10, outputTokens: 20 })
const stored = await traceCaptureService.getSessionTrace('session-trim-api')
expect(stored.calls[0].request.body.preview.length).toBeGreaterThan(2048)
expect(stored.calls[0].request.body.truncated).toBe(false)
expect(stored.calls[0].response?.body.preview.length).toBeGreaterThan(2048)
expect(stored.calls[0].response?.body.truncated).toBe(false)
expect(body.calls[0].request.body.bytes).toBe(stored.calls[0].request.body.bytes)
expect(body.calls[0].request.body.sha256).toBe(stored.calls[0].request.body.sha256)
expect(body.calls[0].response?.body.bytes).toBe(stored.calls[0].response?.body.bytes)
expect(body.calls[0].response?.body.sha256).toBe(stored.calls[0].response?.body.sha256)
expect(recorded).not.toBeNull()
})
test('returns the full untrimmed call record from the trace call detail endpoint', async () => {
const recorded = await traceCaptureService.recordCall({
sessionId: 'session-call-detail',
source: 'anthropic',
model: 'claude-fable-5',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:01.000Z',
durationMs: 1000,
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
body: {
model: 'claude-fable-5',
messages: [{ role: 'user', content: 'full detail please' }],
padding: 'y'.repeat(6000),
},
},
response: {
status: 200,
body: {
id: 'msg-call-detail',
usage: { input_tokens: 64, output_tokens: 16 },
},
},
})
const req = new Request(`http://localhost:3456/api/sessions/session-call-detail/trace/calls/${recorded?.id}`)
const res = await handleApiRequest(req, new URL(req.url))
const body = await res.json() as {
call: {
id: string
usage?: { inputTokens: number; outputTokens: number }
request: { body: { preview: string; truncated: boolean } }
}
}
expect(res.status).toBe(200)
expect(body.call.id).toBe(recorded!.id)
expect(body.call.request.body.preview.length).toBeGreaterThan(2048)
expect(body.call.request.body.truncated).toBe(false)
expect(body.call.request.body.preview).toContain('full detail please')
expect(body.call.usage).toEqual({ inputTokens: 64, outputTokens: 16 })
})
test('returns 404 with an error payload when the trace call id is unknown', async () => {
const req = new Request('http://localhost:3456/api/sessions/session-call-detail/trace/calls/call-not-there')
const res = await handleApiRequest(req, new URL(req.url))
const body = await res.json() as { error: string; message: string }
expect(res.status).toBe(404)
expect(body.error).toBe('NOT_FOUND')
expect(body.message).toContain('call-not-there')
})
test('lists trace sessions with storage metadata and managed settings', async () => {
await traceCaptureService.recordCall({
sessionId: 'session-list-trace',
@ -540,3 +916,107 @@ describe('session trace API', () => {
}
})
})
describe('trace read cache', () => {
function buildTraceCallLine(id: string): string {
return `${JSON.stringify({
type: 'call',
record: {
id,
sessionId: 'session-cache-hit',
source: 'proxy',
status: 'ok',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:00.020Z',
durationMs: 20,
request: {
method: 'POST',
url: 'https://api.example.test/v1/chat/completions',
headers: {},
body: createTraceBodySnapshot({ model: 'gpt-5.5' }),
},
response: {
status: 200,
headers: {},
body: createTraceBodySnapshot({ ok: true }),
},
},
})}\n`
}
test('serves cached entries while file mtime and size are unchanged', async () => {
const traceDir = path.join(tmpDir, 'cc-haha', 'traces')
const filePath = path.join(traceDir, 'session-cache-hit.jsonl')
await fs.mkdir(traceDir, { recursive: true })
const lineA = buildTraceCallLine('call-aaa')
const lineB = buildTraceCallLine('call-bbb')
expect(Buffer.byteLength(lineA)).toBe(Buffer.byteLength(lineB))
const initialTime = new Date('2026-06-09T08:00:00.000Z')
await fs.writeFile(filePath, lineA)
await fs.utimes(filePath, initialTime, initialTime)
const first = await traceCaptureService.getSessionTrace('session-cache-hit')
expect(first.calls.map((call) => call.id)).toEqual(['call-aaa'])
// Same size + restored mtime: the cached parse result must be reused.
await fs.writeFile(filePath, lineB)
await fs.utimes(filePath, initialTime, initialTime)
const second = await traceCaptureService.getSessionTrace('session-cache-hit')
expect(second.calls.map((call) => call.id)).toEqual(['call-aaa'])
const laterTime = new Date('2026-06-09T08:00:05.000Z')
await fs.utimes(filePath, laterTime, laterTime)
const third = await traceCaptureService.getSessionTrace('session-cache-hit')
expect(third.calls.map((call) => call.id)).toEqual(['call-bbb'])
})
test('invalidates the cache when new entries are appended in process', async () => {
await traceCaptureService.recordCall({
id: 'call-cache-1',
sessionId: 'session-cache-append',
source: 'proxy',
model: 'gpt-5.5',
startedAt: '2026-06-09T08:00:00.000Z',
completedAt: '2026-06-09T08:00:00.010Z',
durationMs: 10,
request: {
method: 'POST',
url: 'https://api.example.test/v1/messages',
body: { model: 'gpt-5.5' },
},
response: {
status: 200,
body: { ok: true },
},
})
const first = await traceCaptureService.getSessionTrace('session-cache-append')
expect(first.calls.map((call) => call.id)).toEqual(['call-cache-1'])
await traceCaptureService.recordCall({
id: 'call-cache-2',
sessionId: 'session-cache-append',
source: 'proxy',
model: 'gpt-5.5',
startedAt: '2026-06-09T08:00:01.000Z',
completedAt: '2026-06-09T08:00:01.010Z',
durationMs: 10,
request: {
method: 'POST',
url: 'https://api.example.test/v1/messages',
body: { model: 'gpt-5.5' },
},
response: {
status: 200,
body: { ok: true },
},
})
const second = await traceCaptureService.getSessionTrace('session-cache-append')
expect(second.calls.map((call) => call.id)).toEqual(['call-cache-1', 'call-cache-2'])
})
})

View File

@ -7,7 +7,8 @@
* GET /api/sessions
* GET /api/sessions/:id
* GET /api/sessions/:id/messages
* GET /api/sessions/:id/trace trace
* GET /api/sessions/:id/trace tracebody preview
* GET /api/sessions/:id/trace/calls/:callId trace
* GET /api/sessions/:id/turn-checkpoints checkpoint
* GET /api/sessions/:id/turn-checkpoints/diff checkpoint diff
* POST /api/sessions
@ -40,7 +41,7 @@ import {
SessionBranchingError,
} from '../../utils/sessionBranching.js'
import { registerFilesystemAccessRoot } from '../services/filesystemAccessRoots.js'
import { traceCaptureService } from '../services/traceCaptureService.js'
import { traceCaptureService, trimTraceCallPreviews } from '../services/traceCaptureService.js'
const workspaceService = new WorkspaceService(
async (sessionId) => (
@ -119,7 +120,9 @@ export async function handleSessionsApi(
{ status: 405 }
)
}
return await getSessionTrace(sessionId)
return segments[4] === 'calls'
? await getSessionTraceCall(sessionId, segments[5])
: await getSessionTrace(sessionId)
}
if (subResource === 'git-info') {
@ -269,6 +272,7 @@ async function getSessionTrace(sessionId: string): Promise<Response> {
])
return Response.json({
...trace,
calls: trace.calls.map((call) => trimTraceCallPreviews(call)),
session: session
? {
id: session.id,
@ -280,6 +284,18 @@ async function getSessionTrace(sessionId: string): Promise<Response> {
})
}
async function getSessionTraceCall(sessionId: string, callId: string | undefined): Promise<Response> {
if (!callId || callId.trim().length === 0) {
throw ApiError.badRequest('callId is required')
}
const call = await traceCaptureService.getSessionTraceCall(sessionId, callId)
if (!call) {
throw ApiError.notFound(`Trace call not found: ${callId}`)
}
return Response.json({ call })
}
async function handleSessionWorkspaceRoute(
sessionId: string,
url: URL,

View File

@ -25,6 +25,7 @@ import { normalizeModelStringForAPI } from '../../utils/model/model.js'
import {
createTraceCallId,
createTraceBodySnapshot,
TRACE_STREAM_CAPTURE_BYTES,
traceCaptureService,
type TraceBodySnapshot,
type TraceProviderInfo,
@ -749,7 +750,7 @@ function captureTraceStream(
const captureChunk = (chunk: Uint8Array) => {
bytes += chunk.byteLength
if (bytes <= 256 * 1024) {
if (bytes <= TRACE_STREAM_CAPTURE_BYTES) {
captured += decoder.decode(chunk, { stream: true })
} else {
truncated = true

View File

@ -7,7 +7,10 @@ export {
readTraceCaptureSettings,
readResponseTraceSnapshot,
shouldCaptureApiTrace,
TRACE_LIST_PREVIEW_CHARS,
TRACE_STREAM_CAPTURE_BYTES,
traceCaptureService,
trimTraceCallPreviews,
updateTraceCaptureSettings,
} from '../../services/api/traceCapture.js'
export type {
@ -17,6 +20,7 @@ export type {
TraceCaptureSettings,
TraceCallStatus,
TraceCallRecord,
TraceCallUsage,
TraceEventRecord,
TraceEventSeverity,
TraceProviderInfo,

View File

@ -1,13 +1,17 @@
import { createHash, randomUUID } from 'crypto'
import { existsSync, readFileSync, statSync } from 'fs'
import { promises as fs } from 'fs'
import type { Stats } from 'fs'
import { dirname, join } from 'path'
import { getClaudeConfigHomeDir, isEnvDefinedFalsy, isEnvTruthy } from '../../utils/envUtils.js'
const TRACE_PREVIEW_CHARS = 2048
const TRACE_STREAM_CAPTURE_BYTES = 256 * 1024
const TRACE_PREVIEW_CHARS = 240_000
export const TRACE_STREAM_CAPTURE_BYTES = 1024 * 1024
export const TRACE_LIST_PREVIEW_CHARS = 2048
const TRACE_SETTINGS_KEY = 'traceCapture'
const SENSITIVE_KEY_RE = /authorization|api[-_]?key|secret|token|cookie|password|bearer/i
// `token(?!s)` keeps secret-bearing keys (token, access_token, api_token) redacted while
// letting token-count fields (input_tokens, max_tokens, prompt_tokens) through.
const SENSITIVE_KEY_RE = /authorization|api[-_]?key|secret|token(?!s)|cookie|password|bearer/i
export type TraceCaptureSettings = {
enabled: boolean
@ -32,6 +36,13 @@ export type TraceCallStatus = 'pending' | 'ok' | 'error'
export type TraceEventSeverity = 'info' | 'warning' | 'error'
export type TraceCallUsage = {
inputTokens: number
outputTokens: number
cacheReadInputTokens?: number
cacheCreationInputTokens?: number
}
export type TraceCallRecord = {
id: string
sessionId: string
@ -43,6 +54,7 @@ export type TraceCallRecord = {
startedAt: string
completedAt?: string
durationMs?: number
usage?: TraceCallUsage
metadata?: Record<string, unknown>
request: {
method: string
@ -171,7 +183,15 @@ type TraceFileEntry =
| { type: 'call'; record: TraceCallRecord }
| { type: 'event'; event: TraceEventRecord }
type TraceReadCacheEntry = {
mtimeMs: number
size: number
calls: TraceCallRecord[]
events: TraceEventRecord[]
}
const traceWriteQueues = new Map<string, Promise<void>>()
const traceReadCache = new Map<string, TraceReadCacheEntry>()
export function shouldCaptureApiTrace(): boolean {
if (isEnvDefinedFalsy(process.env.CC_HAHA_TRACE_API_CALLS)) return false
@ -238,8 +258,36 @@ export function createTraceBodySnapshot(
}
}
export function trimTraceCallPreviews(
call: TraceCallRecord,
maxPreviewChars = TRACE_LIST_PREVIEW_CHARS,
): TraceCallRecord {
const requestBody = trimBodySnapshotPreview(call.request.body, maxPreviewChars)
const responseBody = call.response
? trimBodySnapshotPreview(call.response.body, maxPreviewChars)
: undefined
if (requestBody === call.request.body && (!call.response || responseBody === call.response.body)) {
return call
}
return {
...call,
request: { ...call.request, body: requestBody },
...(call.response && responseBody ? { response: { ...call.response, body: responseBody } } : {}),
}
}
function trimBodySnapshotPreview(body: TraceBodySnapshot, maxPreviewChars: number): TraceBodySnapshot {
if (body.preview.length <= maxPreviewChars) return body
return {
...body,
preview: body.preview.slice(0, maxPreviewChars),
truncated: true,
}
}
export function clearTraceCaptureStateForTests(): void {
traceWriteQueues.clear()
traceReadCache.clear()
}
export function createTraceCallId(): string {
@ -320,6 +368,11 @@ class TraceCaptureService {
}
}
async getSessionTraceCall(sessionId: string, callId: string): Promise<TraceCallRecord | null> {
const { calls } = await readTraceEntries(sessionId)
return calls.find((call) => call.id === callId) ?? null
}
async listSessionTraces(options?: {
limit?: number
offset?: number
@ -559,6 +612,7 @@ async function appendTraceEntry(sessionId: string, entry: TraceFileEntry): Promi
const filePath = getTraceFilePath(sessionId)
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, 'utf-8')
traceReadCache.delete(filePath)
})
traceWriteQueues.set(sessionId, next)
try {
@ -595,11 +649,30 @@ async function listTraceFiles(storageDir: string): Promise<Array<{ name: string;
async function readTraceEntries(sessionId: string): Promise<{ calls: TraceCallRecord[]; events: TraceEventRecord[] }> {
const filePath = getTraceFilePath(sessionId)
let stat: Stats
try {
stat = await fs.stat(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
traceReadCache.delete(filePath)
return { calls: [], events: [] }
}
throw error
}
const cached = traceReadCache.get(filePath)
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
return { calls: cached.calls, events: cached.events }
}
let raw = ''
try {
raw = await fs.readFile(filePath, 'utf-8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { calls: [], events: [] }
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
traceReadCache.delete(filePath)
return { calls: [], events: [] }
}
throw error
}
@ -631,10 +704,19 @@ async function readTraceEntries(sessionId: string): Promise<{ calls: TraceCallRe
}
}
return {
calls: Array.from(callsById.values()).sort((a, b) => a.startedAt.localeCompare(b.startedAt)),
events: events.sort((a, b) => a.timestamp.localeCompare(b.timestamp)),
}
const calls = Array.from(callsById.values())
.sort((a, b) => a.startedAt.localeCompare(b.startedAt))
.map((call) => attachCallUsage(call))
const sortedEvents = events.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
traceReadCache.set(filePath, {
mtimeMs: stat.mtimeMs,
size: stat.size,
calls,
events: sortedEvents,
})
return { calls, events: sortedEvents }
}
function isTraceCallRecordLike(value: unknown): value is TraceCallRecord {
@ -669,9 +751,8 @@ function summarizeCalls(calls: TraceCallRecord[]): TraceSessionSummary {
if (call.status === 'error' || call.error || (call.response?.status ?? 200) >= 400) failedCalls += 1
if (typeof call.durationMs === 'number') totalDurationMs += call.durationMs
if (call.model) modelCounts.set(call.model, (modelCounts.get(call.model) ?? 0) + 1)
const usage = extractUsage(call.response?.body.preview)
totalInputTokens += usage.input
totalOutputTokens += usage.output
totalInputTokens += call.usage?.inputTokens ?? 0
totalOutputTokens += call.usage?.outputTokens ?? 0
updatedAt = call.completedAt ?? call.startedAt
}
@ -686,20 +767,139 @@ function summarizeCalls(calls: TraceCallRecord[]): TraceSessionSummary {
}
}
function extractUsage(preview: string | undefined): { input: number; output: number } {
if (!preview) return { input: 0, output: 0 }
const parsed = parseJsonOrText(preview)
if (!parsed || typeof parsed !== 'object') return { input: 0, output: 0 }
const usage = 'usage' in parsed && parsed.usage && typeof parsed.usage === 'object'
? parsed.usage as Record<string, unknown>
: parsed as Record<string, unknown>
const input = numberFromUnknown(usage.input_tokens) + numberFromUnknown(usage.prompt_tokens)
const output = numberFromUnknown(usage.output_tokens) + numberFromUnknown(usage.completion_tokens)
return { input, output }
function attachCallUsage(call: TraceCallRecord): TraceCallRecord {
const usage = extractTraceCallUsage(call)
return usage ? { ...call, usage } : call
}
function extractTraceCallUsage(call: TraceCallRecord): TraceCallUsage | undefined {
const preview = call.response?.body.preview
if (!preview) return undefined
try {
if (looksLikeSseText(preview)) {
return extractUsageFromSseText(preview)
}
const parsed = parseJsonOrText(preview)
if (!parsed || typeof parsed !== 'object') return undefined
return extractUsageFromJsonPayload(unwrapAnthropicResponsePayload(parsed))
} catch {
return undefined
}
}
function looksLikeSseText(preview: string): boolean {
for (const line of preview.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
return trimmed.startsWith('event:') || trimmed.startsWith('data:')
}
return false
}
function unwrapAnthropicResponsePayload(parsed: object): Record<string, unknown> {
// Proxy responses persist as `{ upstream, anthropic }`; usage lives on the anthropic copy.
const record = parsed as Record<string, unknown>
if (record.anthropic && typeof record.anthropic === 'object' && !Array.isArray(record.anthropic)) {
return record.anthropic as Record<string, unknown>
}
return record
}
function extractUsageFromJsonPayload(payload: Record<string, unknown>): TraceCallUsage | undefined {
const hasUsageObject = Boolean(payload.usage && typeof payload.usage === 'object' && !Array.isArray(payload.usage))
const usageSource = hasUsageObject
? payload.usage as Record<string, unknown>
: payload
const inputTokens = numberFromUnknown(usageSource.input_tokens) + numberFromUnknown(usageSource.prompt_tokens)
const outputTokens = numberFromUnknown(usageSource.output_tokens) + numberFromUnknown(usageSource.completion_tokens)
const cacheReadInputTokens = finiteNumberOrUndefined(usageSource.cache_read_input_tokens)
const cacheCreationInputTokens = finiteNumberOrUndefined(usageSource.cache_creation_input_tokens)
if (!hasUsageObject
&& inputTokens === 0
&& outputTokens === 0
&& cacheReadInputTokens === undefined
&& cacheCreationInputTokens === undefined) {
return undefined
}
return {
inputTokens,
outputTokens,
...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
}
}
type TraceUsageAccumulator = {
inputTokens?: number
outputTokens?: number
cacheReadInputTokens?: number
cacheCreationInputTokens?: number
}
function extractUsageFromSseText(preview: string): TraceCallUsage | undefined {
const accumulated: TraceUsageAccumulator = {}
for (const line of preview.split('\n')) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue
const payload = trimmed.slice('data:'.length).trim()
if (!payload || payload === '[DONE]') continue
let event: unknown
try {
event = JSON.parse(payload)
} catch {
continue
}
if (!event || typeof event !== 'object') continue
const record = event as Record<string, unknown>
if (record.type === 'message_start') {
const message = record.message
accumulateUsageFields(
accumulated,
message && typeof message === 'object' ? (message as Record<string, unknown>).usage : undefined,
)
} else if (record.type === 'message_delta') {
accumulateUsageFields(accumulated, record.usage)
}
}
if (accumulated.inputTokens === undefined
&& accumulated.outputTokens === undefined
&& accumulated.cacheReadInputTokens === undefined
&& accumulated.cacheCreationInputTokens === undefined) {
return undefined
}
return {
inputTokens: accumulated.inputTokens ?? 0,
outputTokens: accumulated.outputTokens ?? 0,
...(accumulated.cacheReadInputTokens !== undefined
? { cacheReadInputTokens: accumulated.cacheReadInputTokens }
: {}),
...(accumulated.cacheCreationInputTokens !== undefined
? { cacheCreationInputTokens: accumulated.cacheCreationInputTokens }
: {}),
}
}
function accumulateUsageFields(accumulated: TraceUsageAccumulator, usage: unknown): void {
if (!usage || typeof usage !== 'object' || Array.isArray(usage)) return
const record = usage as Record<string, unknown>
accumulated.inputTokens = finiteNumberOrUndefined(record.input_tokens) ?? accumulated.inputTokens
accumulated.outputTokens = finiteNumberOrUndefined(record.output_tokens) ?? accumulated.outputTokens
accumulated.cacheReadInputTokens = finiteNumberOrUndefined(record.cache_read_input_tokens)
?? accumulated.cacheReadInputTokens
accumulated.cacheCreationInputTokens = finiteNumberOrUndefined(record.cache_creation_input_tokens)
?? accumulated.cacheCreationInputTokens
}
function finiteNumberOrUndefined(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
function numberFromUnknown(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
return finiteNumberOrUndefined(value) ?? 0
}
function getTraceFilePath(sessionId: string): string {