mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
cf7e48e540
commit
d3d7566f0c
33
THIRD_PARTY_LICENSES.md
Normal file
33
THIRD_PARTY_LICENSES.md
Normal 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.
|
||||
```
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 } : {})
|
||||
|
||||
174
desktop/src/components/trace/TraceBadges.tsx
Normal file
174
desktop/src/components/trace/TraceBadges.tsx
Normal 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(' ')
|
||||
}
|
||||
}
|
||||
164
desktop/src/components/trace/TraceDetail.tsx
Normal file
164
desktop/src/components/trace/TraceDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
desktop/src/components/trace/TraceSplitLayout.tsx
Normal file
95
desktop/src/components/trace/TraceSplitLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
361
desktop/src/components/trace/TraceTree.tsx
Normal file
361
desktop/src/components/trace/TraceTree.tsx
Normal 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')
|
||||
}
|
||||
}
|
||||
338
desktop/src/components/trace/detail/LlmCallDetail.tsx
Normal file
338
desktop/src/components/trace/detail/LlmCallDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
196
desktop/src/components/trace/detail/MessageBlocks.tsx
Normal file
196
desktop/src/components/trace/detail/MessageBlocks.tsx
Normal 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)
|
||||
}
|
||||
}
|
||||
60
desktop/src/components/trace/detail/MessageDetail.tsx
Normal file
60
desktop/src/components/trace/detail/MessageDetail.tsx
Normal 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: [] }
|
||||
}
|
||||
72
desktop/src/components/trace/detail/Section.tsx
Normal file
72
desktop/src/components/trace/detail/Section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
desktop/src/components/trace/detail/SessionOverview.tsx
Normal file
132
desktop/src/components/trace/detail/SessionOverview.tsx
Normal 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
|
||||
}
|
||||
99
desktop/src/components/trace/detail/ToolDetail.tsx
Normal file
99
desktop/src/components/trace/detail/ToolDetail.tsx
Normal 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
|
||||
}
|
||||
@ -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',
|
||||
|
||||
|
||||
@ -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': 'さらに読み込む',
|
||||
|
||||
|
||||
@ -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': '더 불러오기',
|
||||
|
||||
|
||||
@ -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': '載入更多',
|
||||
|
||||
|
||||
@ -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': '加载更多',
|
||||
|
||||
|
||||
118
desktop/src/lib/trace/callCache.test.ts
Normal file
118
desktop/src/lib/trace/callCache.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
29
desktop/src/lib/trace/callCache.ts
Normal file
29
desktop/src/lib/trace/callCache.ts
Normal 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)
|
||||
}
|
||||
75
desktop/src/lib/trace/formatters.test.ts
Normal file
75
desktop/src/lib/trace/formatters.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
31
desktop/src/lib/trace/formatters.ts
Normal file
31
desktop/src/lib/trace/formatters.ts
Normal 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' })
|
||||
}
|
||||
204
desktop/src/lib/trace/requestParse.test.ts
Normal file
204
desktop/src/lib/trace/requestParse.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
213
desktop/src/lib/trace/requestParse.ts
Normal file
213
desktop/src/lib/trace/requestParse.ts
Normal 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)
|
||||
}
|
||||
180
desktop/src/lib/trace/sse.test.ts
Normal file
180
desktop/src/lib/trace/sse.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
418
desktop/src/lib/trace/sse.ts
Normal file
418
desktop/src/lib/trace/sse.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
18
desktop/src/lib/trace/types.ts
Normal file
18
desktop/src/lib/trace/types.ts
Normal 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
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 — 获取会话级模型调用 trace(body 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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user