diff --git a/desktop/electron/ipc/capabilities.test.ts b/desktop/electron/ipc/capabilities.test.ts index a4db3038..1ceff77d 100644 --- a/desktop/electron/ipc/capabilities.test.ts +++ b/desktop/electron/ipc/capabilities.test.ts @@ -21,6 +21,8 @@ describe('Electron IPC capabilities', () => { it('validates structured payloads before they reach ipcRenderer.invoke', () => { expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, 'https://example.com')).toBe(true) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, { url: 'https://example.com' })).toBe(false) + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '4673a448-9e2c-475e-898d-9aa0ee2d1ab7')).toBe(true) + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '../escape')).toBe(false) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, {})).toBe(false) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowStartDragging, undefined)).toBe(true) diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts index 857e31a7..55783673 100644 --- a/desktop/electron/ipc/capabilities.ts +++ b/desktop/electron/ipc/capabilities.ts @@ -12,6 +12,12 @@ const booleanPayload: Validator = value => typeof value === 'boolean' const hasOnlyKeys = (value: Record, allowedKeys: string[]) => Object.keys(value).every(key => allowedKeys.includes(key)) +const sessionIdPayload: Validator = value => + typeof value === 'string' + && value.length > 0 + && value.length <= 200 + && /^[A-Za-z0-9._:-]+$/.test(value) + const commandInvoke: Validator = value => isRecord(value) && typeof value.command === 'string' @@ -80,6 +86,7 @@ export const ELECTRON_IPC_VALIDATORS = { [ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke, [ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload, [ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload, + [ELECTRON_IPC_CHANNELS.traceOpenWindow]: sessionIdPayload, [ELECTRON_IPC_CHANNELS.dialogOpen]: optionalRecord, [ELECTRON_IPC_CHANNELS.dialogSave]: optionalRecord, [ELECTRON_IPC_CHANNELS.updateCheck]: updateCheckOptions, diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts index 58613476..a0bbe147 100644 --- a/desktop/electron/ipc/channels.ts +++ b/desktop/electron/ipc/channels.ts @@ -4,6 +4,7 @@ export const ELECTRON_IPC_CHANNELS = { commandInvoke: 'desktop:command:invoke', shellOpen: 'desktop:shell:open', shellOpenPath: 'desktop:shell:open-path', + traceOpenWindow: 'desktop:trace:open-window', dialogOpen: 'desktop:dialog:open', dialogSave: 'desktop:dialog:save', updateCheck: 'desktop:update:check', diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 7928028c..7be70e2b 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -50,6 +50,7 @@ let serverRuntime: ElectronServerRuntime | null = null let updaterService: ElectronUpdaterService | null = null let terminalService: ElectronTerminalService | null = null let previewService: ElectronPreviewService | null = null +const traceWindows = new Map() let isQuitting = false let trayController: TrayController | null = null @@ -84,6 +85,56 @@ function rendererEntry() { }) } +async function loadRendererEntry( + window: BrowserWindow, + query?: Record, +) { + const entry = rendererEntry() + if (/^https?:\/\//.test(entry)) { + const url = new URL(entry) + for (const [key, value] of Object.entries(query ?? {})) { + url.searchParams.set(key, value) + } + await window.loadURL(url.toString()) + } else { + await window.loadFile(entry, query ? { query } : undefined) + } +} + +async function openTraceWindow(sessionId: string) { + const existing = traceWindows.get(sessionId) + if (existing && !existing.isDestroyed()) { + showMainWindow(existing, app) + return + } + + const traceWindow = new BrowserWindow({ + width: 1180, + height: 780, + minWidth: 860, + minHeight: 560, + title: 'Trace', + autoHideMenuBar: true, + show: false, + webPreferences: { + preload: preloadPath(), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + traceWindows.set(sessionId, traceWindow) + traceWindow.on('closed', () => { + traceWindows.delete(sessionId) + }) + installMainWindowNavigationGuards(traceWindow.webContents, { openExternal: openExternalUrl }) + await loadRendererEntry(traceWindow, { + traceWindow: '1', + traceSessionId: sessionId, + }) + showMainWindow(traceWindow, app) +} + function getServerRuntime() { serverRuntime ??= new ElectronServerRuntime({ desktopRoot: unpackedRoot(), @@ -208,6 +259,7 @@ function registerIpcHandlers() { registerHandler(ELECTRON_IPC_CHANNELS.commandInvoke, (_event, payload) => handleCommandInvoke(payload)) registerHandler(ELECTRON_IPC_CHANNELS.shellOpen, (_event, payload) => openExternalUrl(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(String(payload))) + registerHandler(ELECTRON_IPC_CHANNELS.traceOpenWindow, (_event, payload) => openTraceWindow(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.dialogOpen, (event, payload) => openDialog(currentWindow(event), payload as Parameters[1])) registerHandler(ELECTRON_IPC_CHANNELS.dialogSave, (event, payload) => @@ -339,12 +391,7 @@ async function createMainWindow() { writeWindowSmokeSnapshot(mainWindow, 'after-create') - const entry = rendererEntry() - if (/^https?:\/\//.test(entry)) { - await mainWindow.loadURL(entry) - } else { - await mainWindow.loadFile(entry) - } + await loadRendererEntry(mainWindow) restoreWindowMaximized(mainWindow, restoredState) showMainWindow(mainWindow, app) diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 3c3a592b..a19bb741 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -2,6 +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' type SessionsResponse = { sessions: SessionListItem[]; total: number } type MessagesResponse = { @@ -320,6 +321,10 @@ export const sessionsApi = { return api.get(`/api/sessions/${sessionId}/messages`) }, + getTrace(sessionId: string) { + return api.get(`/api/sessions/${sessionId}/trace`) + }, + create(input?: string | CreateSessionRequest) { const body = typeof input === 'string' ? (input ? { workDir: input } : {}) diff --git a/desktop/src/api/traces.ts b/desktop/src/api/traces.ts new file mode 100644 index 00000000..410df483 --- /dev/null +++ b/desktop/src/api/traces.ts @@ -0,0 +1,21 @@ +import { api } from './client' +import type { TraceCaptureSettings, TraceSessionList } from '../types/trace' + +export const tracesApi = { + list(options?: { limit?: number; offset?: number; query?: string }) { + const params = new URLSearchParams() + if (options?.limit !== undefined) params.set('limit', String(options.limit)) + if (options?.offset !== undefined) params.set('offset', String(options.offset)) + if (options?.query) params.set('q', options.query) + const suffix = params.toString() ? `?${params}` : '' + return api.get(`/api/traces${suffix}`) + }, + + getSettings() { + return api.get('/api/traces/settings') + }, + + updateSettings(settings: Partial>) { + return api.put('/api/traces/settings', settings) + }, +} diff --git a/desktop/src/components/layout/AppShell.test.tsx b/desktop/src/components/layout/AppShell.test.tsx index e086166f..c1219135 100644 --- a/desktop/src/components/layout/AppShell.test.tsx +++ b/desktop/src/components/layout/AppShell.test.tsx @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ connectToSession: vi.fn(), setActiveTab: vi.fn(), openTab: vi.fn(), + openTraceTab: vi.fn(), tabState: { activeTabId: null as string | null, tabs: [] as Array<{ sessionId: string; title: string; type: string; status: string }>, @@ -51,6 +52,7 @@ vi.mock('../../stores/tabStore', () => { activeTabId: mocks.tabState.activeTabId, tabs: mocks.tabState.tabs, openTab: mocks.openTab, + openTraceTab: mocks.openTraceTab, setActiveTab: mocks.setActiveTab, }) useTabStore.setState = (next: { activeTabId?: string | null }) => { @@ -100,6 +102,14 @@ vi.mock('./H5ConnectionView', () => ({ ), })) +vi.mock('../../pages/TraceSession', () => ({ + TraceSession: ({ sessionId, standalone }: { sessionId: string; standalone?: boolean }) => ( +
+ trace:{sessionId} +
+ ), +})) + vi.mock('../shared/Toast', () => ({ ToastContainer: () => null, })) @@ -119,6 +129,7 @@ describe('AppShell boot flow', () => { mocks.fetchAll.mockResolvedValue(undefined) mocks.restoreTabs.mockResolvedValue(undefined) mocks.openTab.mockReset() + mocks.openTraceTab.mockReset() mocks.setActiveTab.mockImplementation((sessionId: string) => { mocks.tabState.activeTabId = sessionId }) @@ -127,6 +138,7 @@ describe('AppShell boot flow', () => { useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) useUIStore.setState({ sidebarOpen: true }) Reflect.deleteProperty(window, 'desktopHost') + window.history.pushState({}, '', '/') }) it('renders the desktop chrome after server and settings bootstrap', async () => { @@ -181,6 +193,32 @@ describe('AppShell boot flow', () => { }) }) + it('opens a trace tab from a session-scoped trace deep link', async () => { + window.history.pushState({}, '', '/?traceSessionId=session-deep-link') + + render() + + await screen.findByText('sidebar loaded') + await waitFor(() => { + expect(mocks.openTraceTab).toHaveBeenCalledWith( + 'session-deep-link', + 'Trace: session-', + ) + }) + expect(mocks.connectToSession).not.toHaveBeenCalled() + }) + + it('renders a dedicated trace window shell from traceWindow deep links', async () => { + window.history.pushState({}, '', '/?traceWindow=1&traceSessionId=session-window') + + render() + + expect(await screen.findByTestId('trace-session')).toHaveTextContent('trace:session-window') + expect(screen.getByTestId('trace-session')).toHaveAttribute('data-standalone', 'true') + expect(screen.queryByText('sidebar loaded')).not.toBeInTheDocument() + expect(mocks.restoreTabs).not.toHaveBeenCalled() + }) + it('routes native menu navigation through the desktop host', async () => { let navigate: ((target: string) => void) | undefined const unlisten = vi.fn() diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index f371cb0a..6f227e09 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type HTMLAttributes } from 'react' +import { useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react' import { Sidebar } from './Sidebar' import { ContentRouter } from './ContentRouter' import { ToastContainer } from '../shared/Toast' @@ -23,6 +23,9 @@ import { useTranslation } from '../../i18n' import { H5ConnectionView } from './H5ConnectionView' import { useMobileViewport } from '../../hooks/useMobileViewport' import type { Tab } from '../../stores/tabStore' +import { getTraceLaunchRequest } from '../../lib/traceLaunch' +import { TraceList } from '../../pages/TraceList' +import { TraceSession } from '../../pages/TraceSession' function isChatTab(tab: Tab | undefined) { return tab?.type === 'session' @@ -39,6 +42,7 @@ export function AppShell() { const [bootstrapNonce, setBootstrapNonce] = useState(0) const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) const t = useTranslation() + const traceLaunch = useMemo(() => getTraceLaunchRequest(), []) const desktopRuntime = isDesktopRuntime() const isMobileShell = useMobileViewport() && !desktopRuntime const tabs = useTabStore((s) => s.tabs) @@ -84,8 +88,17 @@ export function AppShell() { } void (async () => { + if (traceLaunch.windowMode) return + await useTabStore.getState().restoreTabs() if (cancelled) return + if (traceLaunch.sessionId) { + useTabStore.getState().openTraceTab( + traceLaunch.sessionId, + `Trace: ${traceLaunch.sessionId.slice(0, 8)}`, + ) + return + } const { activeTabId: activeId, tabs } = useTabStore.getState() const activeTab = tabs.find((tab) => tab.sessionId === activeId) if (activeId && activeTab?.type === 'session') { @@ -111,7 +124,7 @@ export function AppShell() { return () => { cancelled = true } - }, [bootstrapNonce, fetchSettings, desktopRuntime]) + }, [bootstrapNonce, fetchSettings, desktopRuntime, traceLaunch]) // Listen for macOS native menu navigation events (About / Settings) useEffect(() => { @@ -194,6 +207,19 @@ export function AppShell() { ) } + if (traceLaunch.windowMode) { + return ( +
+ {traceLaunch.sessionId ? ( + + ) : ( + + )} + +
+ ) + } + return (
{isMobileShell && effectiveSidebarOpen ? ( diff --git a/desktop/src/components/layout/ContentRouter.test.tsx b/desktop/src/components/layout/ContentRouter.test.tsx index a4a218e8..5947f6e6 100644 --- a/desktop/src/components/layout/ContentRouter.test.tsx +++ b/desktop/src/components/layout/ContentRouter.test.tsx @@ -26,6 +26,14 @@ vi.mock('../../pages/TerminalSettings', () => ({ ), })) +vi.mock('../../pages/TraceSession', () => ({ + TraceSession: ({ sessionId }: { sessionId: string }) =>
trace:{sessionId}
, +})) + +vi.mock('../../pages/TraceList', () => ({ + TraceList: () =>
, +})) + import { ContentRouter } from './ContentRouter' import { useTabStore } from '../../stores/tabStore' @@ -95,4 +103,39 @@ describe('ContentRouter terminal tabs', () => { expect(useTabStore.getState().activeTabId).not.toBe('__terminal__1') expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === useTabStore.getState().activeTabId)?.terminalCwd).toBe('/tmp/project') }) + + it('renders trace tabs without mounting the chat session surface', () => { + useTabStore.setState({ + tabs: [{ + sessionId: '__trace__session-1', + title: 'Trace', + type: 'trace', + status: 'idle', + traceSessionId: 'session-1', + }], + activeTabId: '__trace__session-1', + }) + + render() + + expect(screen.getByTestId('trace-session')).toHaveTextContent('trace:session-1') + expect(screen.queryByTestId('active-session')).not.toBeInTheDocument() + }) + + it('renders the trace list tab without mounting the chat session surface', () => { + useTabStore.setState({ + tabs: [{ + sessionId: '__traces__', + title: 'Trace', + type: 'traces', + status: 'idle', + }], + activeTabId: '__traces__', + }) + + render() + + expect(screen.getByTestId('trace-list')).toBeInTheDocument() + expect(screen.queryByTestId('active-session')).not.toBeInTheDocument() + }) }) diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 19446e64..0dd3958f 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -5,6 +5,8 @@ import { ActiveSession } from '../../pages/ActiveSession' import { ScheduledTasks } from '../../pages/ScheduledTasks' import { Settings } from '../../pages/Settings' import { TerminalSettings } from '../../pages/TerminalSettings' +import { TraceList } from '../../pages/TraceList' +import { TraceSession } from '../../pages/TraceSession' export function ContentRouter() { const activeTabId = useTabStore((s) => s.activeTabId) @@ -19,6 +21,11 @@ export function ContentRouter() { page = } else if (activeTabType === 'scheduled') { page = + } else if (activeTabType === 'trace') { + const traceSessionId = tabs.find((t) => t.sessionId === activeTabId)?.traceSessionId + page = traceSessionId ? : + } else if (activeTabType === 'traces') { + page = } else if (activeTabType !== 'terminal') { page = } diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 0e468ac8..689e75e7 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -5,7 +5,7 @@ import { useUIStore } from '../../stores/uiStore' import { useTranslation, type TranslationKey } from '../../i18n' import { ConfirmDialog } from '../shared/ConfirmDialog' import type { SessionListItem } from '../../types/session' -import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore' +import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, TRACE_LIST_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' import { useOpenTargetStore } from '../../stores/openTargetStore' import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences' @@ -686,6 +686,21 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { {t('sidebar.scheduled')} )} + {!isMobile && ( + { + useTabStore.getState().openTracesTab(t('sidebar.traces')) + closeMobileDrawer() + }} + icon={account_tree} + > + {t('sidebar.traces')} + + )}
{expanded ? ( diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 5b2e087e..c7282f60 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -4,6 +4,8 @@ import { SCHEDULED_TAB_ID, SETTINGS_TAB_ID, TERMINAL_TAB_PREFIX, + TRACE_LIST_TAB_ID, + TRACE_TAB_PREFIX, useTabStore, type Tab, } from '../../stores/tabStore' @@ -40,7 +42,9 @@ function isSessionTabId(tabId: string | null) { if (!tabId) return false return tabId !== SETTINGS_TAB_ID && tabId !== SCHEDULED_TAB_ID && - !tabId.startsWith(TERMINAL_TAB_PREFIX) + tabId !== TRACE_LIST_TAB_ID && + !tabId.startsWith(TERMINAL_TAB_PREFIX) && + !tabId.startsWith(TRACE_TAB_PREFIX) } export function TabBar() { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 096aad3c..39f2393b 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -16,10 +16,12 @@ export const en = { 'common.active': 'ACTIVE', 'common.error': 'Error', 'common.copyFailed': 'Copy failed.', + 'common.copied': 'Copied', // ─── Sidebar ────────────────────────────────────── 'sidebar.newSession': 'New session', 'sidebar.scheduled': 'Scheduled', + 'sidebar.traces': 'Trace', 'sidebar.terminal': 'Terminal', 'sidebar.settings': 'Settings', 'sidebar.searchPlaceholder': 'Search sessions...', @@ -969,6 +971,11 @@ export const en = { 'settings.general.notificationsOpenSettings': 'Open Settings', 'settings.general.notificationsTestTitle': 'Claude Code Haha notifications are enabled', 'settings.general.notificationsTestBody': 'Permission prompts and completed agent replies will now use system notifications.', + 'settings.general.traceTitle': 'Agent trace', + 'settings.general.traceDescription': 'Collect local model request traces for stuck sessions, failed calls, and unexpected waits.', + 'settings.general.traceEnabled': 'Collect agent traces', + 'settings.general.traceHintOn': 'New sessions write compact requests, responses, and status events to the local cc-haha traces directory.', + 'settings.general.traceHintOff': 'No new traces will be written. Existing records remain available in the trace list.', 'settings.general.chatSendBehaviorTitle': 'Message Sending', 'settings.general.chatSendBehaviorDescription': 'Choose how the desktop chat composer submits messages.', 'settings.general.chatSendBehaviorEnter': 'Enter sends', @@ -1649,6 +1656,143 @@ export const en = { 'session.timeMinutes': '{n}m ago', 'session.timeHours': '{n}h ago', 'session.timeDays': '{n}d ago', + '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', + 'trace.models': 'Models', + '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', + 'trace.filter.llm': 'LLM', + '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', + 'trace.message.user': 'User message', + 'trace.message.assistant': 'Assistant message', + 'trace.message.system': 'System message', + 'trace.message.toolRequest': 'Assistant tool request', + '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', + 'trace.diagnosis.pendingModel': 'Waiting for model response', + 'trace.diagnosis.pendingTool': 'Waiting for tool result', + 'trace.diagnosis.waitingForAgent': 'User message has not entered an agent step', + 'trace.diagnosis.empty': 'No diagnostic events', + 'trace.diagnosis.healthy': 'No blocking point detected', + 'trace.event.apiCallStarted': 'API call started', + 'trace.event.apiCallCompleted': 'API call completed', + 'trace.event.apiCallFailed': 'API call failed', + 'trace.event.responseCaptureFailed': 'Response capture failed', + 'trace.event.upstreamFetchStarted': 'Upstream fetch started', + 'trace.event.upstreamFetchCompleted': 'Upstream fetch completed', + 'trace.event.upstreamFetchFailed': 'Upstream fetch failed', + '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', // ─── App Shell ────────────────────────────────────── 'app.serverFailed': 'Local server failed to start', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 667a19b7..f47eaa1c 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -18,10 +18,12 @@ export const jp: Record = { 'common.active': 'アクティブ', 'common.error': 'エラー', 'common.copyFailed': 'コピーに失敗しました。', + 'common.copied': 'コピーしました', // ─── Sidebar ────────────────────────────────────── 'sidebar.newSession': '新しいセッション', 'sidebar.scheduled': 'スケジュール', + 'sidebar.traces': 'Trace', 'sidebar.terminal': 'ターミナル', 'sidebar.settings': '設定', 'sidebar.searchPlaceholder': 'セッションを検索...', @@ -971,6 +973,11 @@ export const jp: Record = { 'settings.general.notificationsOpenSettings': '設定を開く', 'settings.general.notificationsTestTitle': 'Claude Code Haha の通知が有効になりました', 'settings.general.notificationsTestBody': '権限の確認と完了したエージェントの応答に、これからシステム通知が使用されます。', + 'settings.general.traceTitle': 'Agent Trace', + 'settings.general.traceDescription': '停止、失敗、予期しない待機を調査するために、ローカルセッションのモデル要求トレースを収集します。', + 'settings.general.traceEnabled': 'Agent Trace を収集', + 'settings.general.traceHintOn': '新しいセッションは、簡潔な要求、応答、状態イベントをローカルの cc-haha traces ディレクトリに書き込みます。', + 'settings.general.traceHintOff': '新しい Trace は書き込まれません。既存の記録は Trace 一覧で引き続き確認できます。', 'settings.general.chatSendBehaviorTitle': 'メッセージの送信', 'settings.general.chatSendBehaviorDescription': 'デスクトップのチャット入力欄でメッセージを送信する方法を選択します。', 'settings.general.chatSendBehaviorEnter': 'Enter で送信', @@ -1651,6 +1658,143 @@ export const jp: Record = { 'session.timeMinutes': '{n} 分前', 'session.timeHours': '{n} 時間前', 'session.timeDays': '{n} 日前', + 'trace.open': 'Trace', + 'trace.title': 'モデル Trace', + 'trace.loadFailed': 'Trace の読み込みに失敗しました', + 'trace.noModel': 'モデル呼び出しなし', + 'trace.apiCalls': 'API 呼び出し', + 'trace.failedCalls': '失敗', + 'trace.duration': '所要時間', + 'trace.models': 'モデル', + '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': 'すべて', + 'trace.filter.llm': 'LLM', + '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': '待機中', + 'trace.message.user': 'ユーザーメッセージ', + 'trace.message.assistant': 'アシスタントメッセージ', + 'trace.message.system': 'システムメッセージ', + 'trace.message.toolRequest': 'アシスタントのツール要求', + 'trace.message.toolResult': 'ツール結果', + 'trace.toolError': 'ツールエラー', + 'trace.modelCall': 'モデル呼び出し', + 'trace.modelCalls': '{count} 件のモデル呼び出し', + 'trace.sessionActivity': 'セッションアクティビティ', + 'trace.emptyValue': '空', + 'trace.diagnosis.modelError': 'モデル呼び出しに失敗しました', + 'trace.diagnosis.toolError': 'ツール実行に失敗しました', + 'trace.diagnosis.eventError': 'トレースイベントに失敗しました', + 'trace.diagnosis.pendingModel': 'モデル応答を待機中', + 'trace.diagnosis.pendingTool': 'ツール結果を待機中', + 'trace.diagnosis.waitingForAgent': 'ユーザーメッセージはまだ agent ステップに入っていません', + 'trace.diagnosis.empty': '診断イベントがありません', + 'trace.diagnosis.healthy': 'ブロック箇所は検出されていません', + 'trace.event.apiCallStarted': 'API 呼び出し開始', + 'trace.event.apiCallCompleted': 'API 呼び出し完了', + 'trace.event.apiCallFailed': 'API 呼び出し失敗', + 'trace.event.responseCaptureFailed': 'レスポンスキャプチャ失敗', + 'trace.event.upstreamFetchStarted': 'アップストリーム要求開始', + 'trace.event.upstreamFetchCompleted': 'アップストリーム要求完了', + 'trace.event.upstreamFetchFailed': 'アップストリーム要求失敗', + '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': 'さらに読み込む', // ─── App Shell ────────────────────────────────────── 'app.serverFailed': 'ローカルサーバーの起動に失敗しました', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 827896a4..d69c7ba2 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -18,10 +18,12 @@ export const kr: Record = { 'common.active': '활성', 'common.error': '오류', 'common.copyFailed': '복사하지 못했습니다.', + 'common.copied': '복사됨', // ─── Sidebar ────────────────────────────────────── 'sidebar.newSession': '새 세션', 'sidebar.scheduled': '예약 작업', + 'sidebar.traces': 'Trace', 'sidebar.terminal': '터미널', 'sidebar.settings': '설정', 'sidebar.searchPlaceholder': '세션 검색...', @@ -971,6 +973,11 @@ export const kr: Record = { 'settings.general.notificationsOpenSettings': '설정 열기', 'settings.general.notificationsTestTitle': 'Claude Code Haha 알림이 사용 설정되었습니다', 'settings.general.notificationsTestBody': '이제 권한 확인과 완료된 에이전트 응답에 시스템 알림이 사용됩니다.', + 'settings.general.traceTitle': 'Agent Trace', + 'settings.general.traceDescription': '멈춤, 실패, 예상치 못한 대기를 조사하기 위해 로컬 세션의 모델 요청 트레이스를 수집합니다.', + 'settings.general.traceEnabled': 'Agent Trace 수집', + 'settings.general.traceHintOn': '새 세션은 간결한 요청, 응답, 상태 이벤트를 로컬 cc-haha traces 디렉터리에 기록합니다.', + 'settings.general.traceHintOff': '새 Trace는 기록되지 않습니다. 기존 기록은 Trace 목록에서 계속 볼 수 있습니다.', 'settings.general.chatSendBehaviorTitle': '메시지 전송', 'settings.general.chatSendBehaviorDescription': '데스크톱 채팅 입력창에서 메시지를 전송하는 방식을 선택합니다.', 'settings.general.chatSendBehaviorEnter': 'Enter로 전송', @@ -1651,6 +1658,143 @@ export const kr: Record = { 'session.timeMinutes': '{n}분 전', 'session.timeHours': '{n}시간 전', 'session.timeDays': '{n}일 전', + 'trace.open': 'Trace', + 'trace.title': '모델 Trace', + 'trace.loadFailed': 'Trace를 불러오지 못했습니다', + 'trace.noModel': '모델 호출 없음', + 'trace.apiCalls': 'API 호출', + 'trace.failedCalls': '실패', + 'trace.duration': '소요 시간', + 'trace.models': '모델', + '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': '전체', + 'trace.filter.llm': 'LLM', + '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': '대기 중', + 'trace.message.user': '사용자 메시지', + 'trace.message.assistant': '어시스턴트 메시지', + 'trace.message.system': '시스템 메시지', + 'trace.message.toolRequest': '어시스턴트 도구 요청', + 'trace.message.toolResult': '도구 결과', + 'trace.toolError': '도구 오류', + 'trace.modelCall': '모델 호출', + 'trace.modelCalls': '모델 호출 {count}회', + 'trace.sessionActivity': '세션 활동', + 'trace.emptyValue': '비어 있음', + 'trace.diagnosis.modelError': '모델 호출 실패', + 'trace.diagnosis.toolError': '도구 실행 실패', + 'trace.diagnosis.eventError': '트레이스 이벤트 실패', + 'trace.diagnosis.pendingModel': '모델 응답 대기 중', + 'trace.diagnosis.pendingTool': '도구 결과 대기 중', + 'trace.diagnosis.waitingForAgent': '사용자 메시지가 아직 agent 단계에 들어가지 않았습니다', + 'trace.diagnosis.empty': '진단 이벤트가 없습니다', + 'trace.diagnosis.healthy': '차단 지점이 감지되지 않았습니다', + 'trace.event.apiCallStarted': 'API 호출 시작', + 'trace.event.apiCallCompleted': 'API 호출 완료', + 'trace.event.apiCallFailed': 'API 호출 실패', + 'trace.event.responseCaptureFailed': '응답 캡처 실패', + 'trace.event.upstreamFetchStarted': '업스트림 요청 시작', + 'trace.event.upstreamFetchCompleted': '업스트림 요청 완료', + 'trace.event.upstreamFetchFailed': '업스트림 요청 실패', + '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': '더 불러오기', // ─── App Shell ────────────────────────────────────── 'app.serverFailed': '로컬 서버를 시작하지 못했습니다', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 5eb07029..fa49b3ea 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -18,10 +18,12 @@ export const zh: Record = { 'common.active': '已啟用', 'common.error': '錯誤', 'common.copyFailed': '複製失敗。', + 'common.copied': '已複製', // ─── Sidebar ────────────────────────────────────── 'sidebar.newSession': '新建會話', 'sidebar.scheduled': '定時任務', + 'sidebar.traces': 'Trace', 'sidebar.terminal': '終端', 'sidebar.settings': '設定', 'sidebar.searchPlaceholder': '搜尋會話...', @@ -971,6 +973,11 @@ export const zh: Record = { 'settings.general.notificationsOpenSettings': '開啟系統設定', 'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已啟用', 'settings.general.notificationsTestBody': '後續授權確認和 Agent 回覆完成都會透過系統通知提醒。', + 'settings.general.traceTitle': 'Agent Trace', + 'settings.general.traceDescription': '收集本地會話的模型請求鏈路,用於排查卡住、失敗和異常等待。', + 'settings.general.traceEnabled': '收集 Agent Trace', + 'settings.general.traceHintOn': '新會話會把精簡請求、回應、狀態事件寫入本機 cc-haha traces 目錄。', + 'settings.general.traceHintOff': '關閉後不會寫入新的 Trace;已有記錄仍可在 Trace 列表裡查看。', 'settings.general.chatSendBehaviorTitle': '訊息傳送方式', 'settings.general.chatSendBehaviorDescription': '選擇桌面端對話輸入框如何傳送訊息。', 'settings.general.chatSendBehaviorEnter': 'Enter 傳送', @@ -1651,6 +1658,143 @@ export const zh: Record = { 'session.timeMinutes': '{n}分鐘前', 'session.timeHours': '{n}小時前', 'session.timeDays': '{n}天前', + 'trace.open': 'Trace', + 'trace.title': '模型 Trace', + 'trace.loadFailed': 'Trace 載入失敗', + 'trace.noModel': '暫無模型呼叫', + 'trace.apiCalls': 'API 呼叫', + 'trace.failedCalls': '失敗', + 'trace.duration': '耗時', + 'trace.models': '模型', + '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': '全部', + 'trace.filter.llm': 'LLM', + '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': '等待中', + 'trace.message.user': '使用者訊息', + 'trace.message.assistant': '助手訊息', + 'trace.message.system': '系統訊息', + 'trace.message.toolRequest': '助手請求工具', + 'trace.message.toolResult': '工具結果', + 'trace.toolError': '工具錯誤', + 'trace.modelCall': '模型呼叫', + 'trace.modelCalls': '{count} 次模型呼叫', + 'trace.sessionActivity': '會話活動', + 'trace.emptyValue': '空', + 'trace.diagnosis.modelError': '模型呼叫失敗', + 'trace.diagnosis.toolError': '工具執行失敗', + 'trace.diagnosis.eventError': '鏈路事件失敗', + 'trace.diagnosis.pendingModel': '正在等待模型回應', + 'trace.diagnosis.pendingTool': '正在等待工具結果', + 'trace.diagnosis.waitingForAgent': '使用者訊息尚未進入 agent 步驟', + 'trace.diagnosis.empty': '沒有可診斷事件', + 'trace.diagnosis.healthy': '未發現阻塞點', + 'trace.event.apiCallStarted': 'API 呼叫開始', + 'trace.event.apiCallCompleted': 'API 呼叫完成', + 'trace.event.apiCallFailed': 'API 呼叫失敗', + 'trace.event.responseCaptureFailed': '回應捕獲失敗', + 'trace.event.upstreamFetchStarted': '上游請求開始', + 'trace.event.upstreamFetchCompleted': '上游請求完成', + 'trace.event.upstreamFetchFailed': '上游請求失敗', + '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': '載入更多', // ─── 應用外殼 ────────────────────────────────────── 'app.serverFailed': '本地服務啟動失敗', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index a9d5661f..17cfbe15 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -18,10 +18,12 @@ export const zh: Record = { 'common.active': '已激活', 'common.error': '错误', 'common.copyFailed': '复制失败。', + 'common.copied': '已复制', // ─── Sidebar ────────────────────────────────────── 'sidebar.newSession': '新建会话', 'sidebar.scheduled': '定时任务', + 'sidebar.traces': 'Trace', 'sidebar.terminal': '终端', 'sidebar.settings': '设置', 'sidebar.searchPlaceholder': '搜索会话...', @@ -971,6 +973,11 @@ export const zh: Record = { 'settings.general.notificationsOpenSettings': '打开系统设置', 'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已启用', 'settings.general.notificationsTestBody': '后续授权确认和 Agent 回复完成都会通过系统通知提醒。', + 'settings.general.traceTitle': 'Agent Trace', + 'settings.general.traceDescription': '收集本地会话的模型请求链路,用于排查卡住、失败和异常等待。', + 'settings.general.traceEnabled': '收集 Agent Trace', + 'settings.general.traceHintOn': '新会话会把精简请求、响应、状态事件写入本机 cc-haha traces 目录。', + 'settings.general.traceHintOff': '关闭后不会写入新的 Trace;已有记录仍可在 Trace 列表里查看。', 'settings.general.chatSendBehaviorTitle': '消息发送方式', 'settings.general.chatSendBehaviorDescription': '选择桌面端对话输入框如何发送消息。', 'settings.general.chatSendBehaviorEnter': 'Enter 发送', @@ -1651,6 +1658,143 @@ export const zh: Record = { 'session.timeMinutes': '{n}分钟前', 'session.timeHours': '{n}小时前', 'session.timeDays': '{n}天前', + 'trace.open': 'Trace', + 'trace.title': '模型 Trace', + 'trace.loadFailed': 'Trace 加载失败', + 'trace.noModel': '暂无模型调用', + 'trace.apiCalls': 'API 调用', + 'trace.failedCalls': '失败', + 'trace.duration': '耗时', + 'trace.models': '模型', + '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': '全部', + 'trace.filter.llm': 'LLM', + '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': '等待中', + 'trace.message.user': '用户消息', + 'trace.message.assistant': '助手消息', + 'trace.message.system': '系统消息', + 'trace.message.toolRequest': '助手请求工具', + 'trace.message.toolResult': '工具结果', + 'trace.toolError': '工具错误', + 'trace.modelCall': '模型调用', + 'trace.modelCalls': '{count} 次模型调用', + 'trace.sessionActivity': '会话活动', + 'trace.emptyValue': '空', + 'trace.diagnosis.modelError': '模型调用失败', + 'trace.diagnosis.toolError': '工具执行失败', + 'trace.diagnosis.eventError': '链路事件失败', + 'trace.diagnosis.pendingModel': '正在等待模型响应', + 'trace.diagnosis.pendingTool': '正在等待工具结果', + 'trace.diagnosis.waitingForAgent': '用户消息尚未进入 agent 步骤', + 'trace.diagnosis.empty': '没有可诊断事件', + 'trace.diagnosis.healthy': '未发现阻塞点', + 'trace.event.apiCallStarted': 'API 调用开始', + 'trace.event.apiCallCompleted': 'API 调用完成', + 'trace.event.apiCallFailed': 'API 调用失败', + 'trace.event.responseCaptureFailed': '响应捕获失败', + 'trace.event.upstreamFetchStarted': '上游请求开始', + 'trace.event.upstreamFetchCompleted': '上游请求完成', + 'trace.event.upstreamFetchFailed': '上游请求失败', + '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': '加载更多', // ─── 应用外壳 ────────────────────────────────────── 'app.serverFailed': '本地服务启动失败', diff --git a/desktop/src/lib/desktopHost/browserHost.ts b/desktop/src/lib/desktopHost/browserHost.ts index d5ae0ead..47194ab6 100644 --- a/desktop/src/lib/desktopHost/browserHost.ts +++ b/desktop/src/lib/desktopHost/browserHost.ts @@ -5,6 +5,7 @@ import type { DesktopHostUnlisten, NotificationPermissionState, } from './types' +import { buildTraceWindowUrl } from '../traceLaunch' const browserCapabilities: DesktopHostCapabilities = { appMode: false, @@ -75,6 +76,15 @@ export const browserHost: DesktopHost = { unsupported('Opening system file paths') }, }, + trace: { + async openWindow(sessionId) { + if (typeof window !== 'undefined') { + window.open(buildTraceWindowUrl(sessionId), '_blank', 'noopener,noreferrer') + return + } + unsupported('Opening trace windows') + }, + }, dialogs: { async open() { unsupported('Native file dialogs') diff --git a/desktop/src/lib/desktopHost/electronHost.test.ts b/desktop/src/lib/desktopHost/electronHost.test.ts index 52f6191e..ab2bbf65 100644 --- a/desktop/src/lib/desktopHost/electronHost.test.ts +++ b/desktop/src/lib/desktopHost/electronHost.test.ts @@ -60,6 +60,18 @@ describe('electron desktop host', () => { }) }) + it('opens dedicated trace windows through a narrow IPC channel', async () => { + const invoke = vi.fn().mockResolvedValue(undefined) + const host = createElectronHost({ + invoke, + subscribe: vi.fn(), + }) + + await host.trace?.openWindow('session-123') + + expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.traceOpenWindow, 'session-123') + }) + it('keeps event subscriptions behind named event channels', async () => { const unlisten = vi.fn() const subscribe = vi.fn().mockResolvedValue(unlisten) diff --git a/desktop/src/lib/desktopHost/electronHost.ts b/desktop/src/lib/desktopHost/electronHost.ts index 336659aa..3db16684 100644 --- a/desktop/src/lib/desktopHost/electronHost.ts +++ b/desktop/src/lib/desktopHost/electronHost.ts @@ -91,6 +91,9 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost { open: target => invoke(ELECTRON_IPC_CHANNELS.shellOpen, target), openPath: path => invoke(ELECTRON_IPC_CHANNELS.shellOpenPath, path), }, + trace: { + openWindow: sessionId => invoke(ELECTRON_IPC_CHANNELS.traceOpenWindow, sessionId), + }, dialogs: { open: options => invoke(ELECTRON_IPC_CHANNELS.dialogOpen, options), save: options => invoke(ELECTRON_IPC_CHANNELS.dialogSave, options), diff --git a/desktop/src/lib/desktopHost/types.ts b/desktop/src/lib/desktopHost/types.ts index 17e50b5e..2a6500c7 100644 --- a/desktop/src/lib/desktopHost/types.ts +++ b/desktop/src/lib/desktopHost/types.ts @@ -168,6 +168,9 @@ export type DesktopHost = { open(target: string): Promise openPath(path: string): Promise } + trace?: { + openWindow(sessionId: string): Promise + } dialogs: { open(options?: DialogOpenOptions): Promise save(options?: DialogSaveOptions): Promise diff --git a/desktop/src/lib/traceLaunch.test.ts b/desktop/src/lib/traceLaunch.test.ts new file mode 100644 index 00000000..6f52f4da --- /dev/null +++ b/desktop/src/lib/traceLaunch.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { buildTraceWindowUrl, getTraceLaunchRequest } from './traceLaunch' + +describe('trace launch URLs', () => { + it('parses dedicated trace window requests by session id', () => { + expect(getTraceLaunchRequest('?traceWindow=1&traceSessionId=session-123')).toEqual({ + sessionId: 'session-123', + windowMode: true, + }) + }) + + it('accepts sessionId as a shorter deep-link alias', () => { + expect(getTraceLaunchRequest('?sessionId=session-456')).toEqual({ + sessionId: 'session-456', + windowMode: false, + }) + }) + + it('builds a same-app trace window URL without dropping existing connection params', () => { + expect(buildTraceWindowUrl( + 'session-789', + 'http://127.0.0.1:5173/?serverUrl=http%3A%2F%2F127.0.0.1%3A3456', + )).toBe( + 'http://127.0.0.1:5173/?serverUrl=http%3A%2F%2F127.0.0.1%3A3456&traceWindow=1&traceSessionId=session-789', + ) + }) +}) diff --git a/desktop/src/lib/traceLaunch.ts b/desktop/src/lib/traceLaunch.ts new file mode 100644 index 00000000..532e2091 --- /dev/null +++ b/desktop/src/lib/traceLaunch.ts @@ -0,0 +1,30 @@ +export type TraceLaunchRequest = { + sessionId: string | null + windowMode: boolean +} + +function normalizeTraceSessionId(value: string | null): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function isTruthyParam(value: string | null): boolean { + return value === '1' || value === 'true' || value === 'yes' +} + +export function getTraceLaunchRequest(search = window.location.search): TraceLaunchRequest { + const params = new URLSearchParams(search) + return { + sessionId: normalizeTraceSessionId( + params.get('traceSessionId') ?? params.get('sessionId'), + ), + windowMode: isTruthyParam(params.get('traceWindow')), + } +} + +export function buildTraceWindowUrl(sessionId: string, currentHref = window.location.href): string { + const url = new URL(currentHref) + url.searchParams.set('traceWindow', '1') + url.searchParams.set('traceSessionId', sessionId) + return url.toString() +} diff --git a/desktop/src/lib/traceViewModel.test.ts b/desktop/src/lib/traceViewModel.test.ts new file mode 100644 index 00000000..d0bf0eea --- /dev/null +++ b/desktop/src/lib/traceViewModel.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest' +import { buildTraceViewModel } from './traceViewModel' +import type { MessageEntry } from '../types/session' +import type { TraceSession } from '../types/trace' + +const trace: TraceSession = { + sessionId: 'session-live', + session: null, + summary: { + apiCalls: 2, + failedCalls: 0, + totalDurationMs: 1700, + totalInputTokens: 12, + totalOutputTokens: 18, + models: [{ model: 'gpt-5.5', calls: 2 }], + updatedAt: '2026-06-09T10:00:04.000Z', + }, + calls: [ + { + id: 'call-1', + sessionId: 'session-live', + source: 'anthropic', + provider: { id: 'provider-sub2api', name: 'Sub2API-ChatGPT', format: 'anthropic' }, + model: 'gpt-5.5', + startedAt: '2026-06-09T10:00:01.000Z', + completedAt: '2026-06-09T10:00:02.000Z', + durationMs: 1000, + request: { + method: 'POST', + url: 'https://sub2api.example/v1/messages', + headers: {}, + body: { contentType: 'json', bytes: 20, sha256: 'a', preview: '{"model":"gpt-5.5"}', truncated: false }, + }, + response: { + status: 200, + headers: {}, + body: { contentType: 'json', bytes: 11, sha256: 'b', preview: '{"ok":true}', truncated: false }, + }, + }, + { + id: 'call-2', + sessionId: 'session-live', + source: 'anthropic', + provider: { id: 'provider-sub2api', name: 'Sub2API-ChatGPT', format: 'anthropic' }, + model: 'gpt-5.5', + startedAt: '2026-06-09T10:00:04.000Z', + completedAt: '2026-06-09T10:00:04.700Z', + durationMs: 700, + request: { + method: 'POST', + url: 'https://sub2api.example/v1/messages', + headers: {}, + body: { contentType: 'json', bytes: 20, sha256: 'c', preview: '{"model":"gpt-5.5"}', truncated: false }, + }, + response: { + status: 200, + headers: {}, + body: { contentType: 'json', bytes: 11, sha256: 'd', preview: '{"ok":true}', truncated: false }, + }, + }, + ], + events: [ + { + id: 'event-1', + sessionId: 'session-live', + callId: 'call-1', + source: 'anthropic', + provider: { id: 'provider-sub2api', name: 'Sub2API-ChatGPT', format: 'anthropic' }, + model: 'gpt-5.5', + timestamp: '2026-06-09T10:00:01.100Z', + phase: 'api_call_started', + severity: 'info', + metadata: { url: 'https://sub2api.example/v1/messages' }, + }, + ], +} + +const messages: MessageEntry[] = [ + { + id: 'user-1', + type: 'user', + content: 'Run ls and summarize the result', + timestamp: '2026-06-09T10:00:00.000Z', + }, + { + id: 'assistant-1', + type: 'tool_use', + content: [ + { type: 'text', text: 'I will inspect the directory.' }, + { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls -la /tmp' } }, + ], + timestamp: '2026-06-09T10:00:02.000Z', + }, + { + id: 'result-1', + type: 'tool_result', + content: [ + { type: 'tool_result', tool_use_id: 'tool-1', content: 'total 8', is_error: false }, + ], + timestamp: '2026-06-09T10:00:03.000Z', + }, +] + +describe('traceViewModel', () => { + it('builds a turn-centered tree with llm and paired tool spans', () => { + const viewModel = buildTraceViewModel(trace, messages) + + expect(viewModel.turns).toHaveLength(1) + expect(viewModel.spansById.get('turn:0')?.childIds).toEqual(expect.arrayContaining([ + 'message:user-1', + 'message:assistant-1', + 'tool:tool-1', + 'llm:call-1', + 'llm:call-2', + ])) + + const tool = viewModel.spansById.get('tool:tool-1') + expect(tool).toMatchObject({ + kind: 'tool', + status: 'ok', + title: 'Bash', + subtitle: 'ls -la /tmp', + }) + expect(tool?.childIds[0]).toMatch(/^tool_result:/) + expect(viewModel.spansById.get(tool?.childIds[0] ?? '')).toMatchObject({ + kind: 'tool_result', + status: 'ok', + output: 'total 8', + }) + expect(viewModel.spansById.get('event:event-1')).toMatchObject({ + kind: 'event', + parentId: 'llm:call-1', + status: 'ok', + }) + expect(viewModel.spansById.get('llm:call-1')?.childIds).toContain('event:event-1') + }) + + 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' }) + expect(viewModel.spansById.get('turn:0')).toMatchObject({ status: 'pending' }) + expect(viewModel.diagnosis).toMatchObject({ + status: 'attention', + reason: 'pending_tool', + focusSpanId: 'tool:tool-1', + pendingToolCalls: 1, + }) + }) + + it('diagnoses event-only errors as blocked', () => { + const viewModel = buildTraceViewModel({ + sessionId: 'session-event-error', + session: null, + summary: { + apiCalls: 0, + failedCalls: 0, + totalDurationMs: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + models: [], + updatedAt: null, + }, + calls: [], + events: [{ + id: 'event-failed', + sessionId: 'session-event-error', + timestamp: '2026-06-09T10:00:02.000Z', + phase: 'upstream_fetch_failed', + severity: 'error', + message: 'network down', + }], + }, []) + + expect(viewModel.spansById.get('event:event-failed')).toMatchObject({ + kind: 'event', + status: 'error', + title: 'Upstream Fetch Failed', + }) + expect(viewModel.diagnosis).toMatchObject({ + status: 'blocked', + reason: 'event_error', + focusSpanId: 'event:event-failed', + }) + }) +}) diff --git a/desktop/src/lib/traceViewModel.ts b/desktop/src/lib/traceViewModel.ts new file mode 100644 index 00000000..13af399a --- /dev/null +++ b/desktop/src/lib/traceViewModel.ts @@ -0,0 +1,634 @@ +import type { MessageEntry } from '../types/session' +import type { TraceCallRecord, TraceEventRecord, TraceSession as TraceSessionData } from '../types/trace' + +export type TraceSpanKind = 'session' | 'turn' | 'message' | 'llm' | 'tool' | 'tool_result' | 'event' +export type TraceSpanStatus = 'ok' | 'error' | 'pending' + +export type TraceSpan = { + id: string + parentId: string | null + childIds: string[] + kind: TraceSpanKind + status: TraceSpanStatus + title: string + subtitle: string + timestamp: string + completedAt?: string + durationMs?: number + turnIndex?: number + message?: MessageEntry + call?: TraceCallRecord + event?: TraceEventRecord + toolUseId?: string + toolName?: string + input?: unknown + output?: unknown + isSidechain?: boolean + raw: unknown +} + +export type TraceDiagnosisReason = + | 'empty' + | 'model_error' + | 'tool_error' + | 'event_error' + | 'pending_model' + | 'pending_tool' + | 'waiting_for_agent' + | 'healthy' + +export type TraceDiagnosis = { + status: 'empty' | 'healthy' | 'attention' | 'blocked' + reason: TraceDiagnosisReason + focusSpanId?: string + evidenceSpanIds: string[] + lastActivityAt: string + errorCount: number + pendingModelCalls: number + pendingToolCalls: number + modelCalls: number + toolCalls: number +} + +export type TraceTurn = { + id: string + index: number + title: string + timestamp: string + spanIds: string[] + userSpanId?: string +} + +export type TraceViewModel = { + rootId: string + spans: TraceSpan[] + spansById: Map + turns: TraceTurn[] + orderedSpanIds: string[] + diagnosis: TraceDiagnosis + fullRaw: unknown +} + +type ToolUseBlock = { + type: 'tool_use' + id?: string + name?: string + input?: unknown +} + +type ToolResultBlock = { + type: 'tool_result' + tool_use_id?: string + content?: unknown + is_error?: boolean +} + +type TextBlock = { + type?: string + text?: string +} + +type MutableSpan = Omit & { childIds: string[] } + +export function buildTraceViewModel( + trace: TraceSessionData, + messages: MessageEntry[], +): TraceViewModel { + const spans = new Map() + const turns: TraceTurn[] = [] + const rootId = 'session:root' + const traceEvents = trace.events ?? [] + const fallbackTimestamp = earliestTimestamp(trace.calls, messages, traceEvents) ?? new Date().toISOString() + const hasPendingCalls = trace.calls.some((call) => getCallStatus(call) === 'pending') + const hasTraceErrors = trace.summary.failedCalls > 0 || traceEvents.some((event) => event.severity === 'error') + + addSpan(spans, { + id: rootId, + parentId: null, + kind: 'session', + status: hasTraceErrors ? 'error' : hasPendingCalls ? 'pending' : 'ok', + title: trace.session?.title ?? trace.sessionId, + subtitle: `${trace.summary.apiCalls} model calls`, + timestamp: fallbackTimestamp, + completedAt: trace.summary.updatedAt ?? undefined, + durationMs: trace.summary.totalDurationMs, + raw: { + sessionId: trace.sessionId, + summary: trace.summary, + session: trace.session, + events: traceEvents, + }, + }) + + const userTurnStarts = messages.filter((message) => message.type === 'user' && !hasToolResultBlocks(message.content)) + if (userTurnStarts.length === 0) { + turns.push(createTurn(0, fallbackTimestamp, 'Session activity')) + } else { + userTurnStarts.forEach((message, index) => { + turns.push(createTurn(index, message.timestamp, createTurnTitle(index, message.content))) + }) + } + + for (const turn of turns) { + addSpan(spans, { + id: turn.id, + parentId: rootId, + kind: 'turn', + status: 'ok', + title: turn.title, + subtitle: `Turn ${turn.index + 1}`, + timestamp: turn.timestamp, + turnIndex: turn.index, + raw: { index: turn.index, timestamp: turn.timestamp, title: turn.title }, + }) + } + + const toolSpanIds = new Map() + const resultBlocksByToolUseId = new Map() + const deferredToolResultSpans: Array<{ parentId: string; message: MessageEntry; block: ToolResultBlock }> = [] + + for (const message of messages) { + const turn = findTurnForTimestamp(turns, message.timestamp) + const turnId = turn.id + + if (message.type === 'tool_result') { + const resultBlocks = extractToolResultBlocks(message.content) + if (resultBlocks.length === 0) { + const spanId = `message:${message.id}` + addMessageSpan(spans, spanId, turnId, turn.index, message) + turn.spanIds.push(spanId) + continue + } + + for (const block of resultBlocks) { + if (block.tool_use_id) { + const existing = resultBlocksByToolUseId.get(block.tool_use_id) ?? [] + existing.push(block) + resultBlocksByToolUseId.set(block.tool_use_id, existing) + } + const toolParentId = block.tool_use_id ? toolSpanIds.get(block.tool_use_id) : undefined + if (toolParentId) { + const spanId = `tool_result:${message.id}:${block.tool_use_id ?? resultBlocks.indexOf(block)}` + addToolResultSpan(spans, spanId, toolParentId, turn.index, message, block) + turn.spanIds.push(spanId) + } else { + deferredToolResultSpans.push({ parentId: turnId, message, block }) + } + } + continue + } + + const text = extractTextContent(message.content) + if (message.type !== 'tool_use' || text.trim()) { + const spanId = `message:${message.id}` + addMessageSpan(spans, spanId, turnId, turn.index, message) + turn.spanIds.push(spanId) + if (message.type === 'user' && !hasToolResultBlocks(message.content)) { + turn.userSpanId = spanId + } + } + + if (message.type === 'tool_use') { + for (const block of extractToolUseBlocks(message.content)) { + const toolId = block.id ?? `${message.id}:${block.name ?? 'tool'}` + const parentToolId = message.parentToolUseId ? toolSpanIds.get(message.parentToolUseId) : undefined + const parentId = parentToolId ?? turnId + const spanId = `tool:${toolId}` + toolSpanIds.set(toolId, spanId) + const resultBlocks = block.id ? resultBlocksByToolUseId.get(block.id) ?? [] : [] + addSpan(spans, { + id: spanId, + parentId, + kind: 'tool', + status: resultBlocks.some((result) => result.is_error) ? 'error' : resultBlocks.length > 0 ? 'ok' : 'pending', + title: block.name ?? 'Tool call', + subtitle: summarizeToolInput(block.input), + timestamp: message.timestamp, + turnIndex: turn.index, + message, + toolUseId: toolId, + toolName: block.name, + input: block.input, + output: resultBlocks.map((result) => result.content), + isSidechain: message.isSidechain, + raw: block, + }) + turn.spanIds.push(spanId) + } + } + } + + for (const item of deferredToolResultSpans) { + const turn = findTurnForTimestamp(turns, item.message.timestamp) + const spanId = `tool_result:${item.message.id}:${item.block.tool_use_id ?? turn.spanIds.length}` + addToolResultSpan(spans, spanId, item.parentId, turn.index, item.message, item.block) + turn.spanIds.push(spanId) + } + + for (const call of trace.calls) { + const turn = findTurnForTimestamp(turns, call.startedAt) + const spanId = `llm:${call.id}` + addSpan(spans, { + id: spanId, + parentId: turn.id, + kind: 'llm', + status: getCallStatus(call), + title: call.model ?? call.provider?.name ?? 'Model call', + subtitle: call.provider?.name ?? call.source, + timestamp: call.startedAt, + completedAt: call.completedAt, + durationMs: call.durationMs, + turnIndex: turn.index, + call, + raw: call, + }) + turn.spanIds.push(spanId) + } + + for (const event of traceEvents) { + const turn = findTurnForTimestamp(turns, event.timestamp) + const callParentId = event.callId ? `llm:${event.callId}` : undefined + const parentId = callParentId && spans.has(callParentId) ? callParentId : turn.id + const spanId = `event:${event.id}` + addSpan(spans, { + id: spanId, + parentId, + kind: 'event', + status: getEventStatus(event), + title: event.title ?? formatTraceEventPhase(event.phase), + subtitle: event.message ?? previewTraceValue(event.metadata), + timestamp: event.timestamp, + turnIndex: turn.index, + event, + raw: event, + }) + turn.spanIds.push(spanId) + } + + for (const turn of turns) { + const childStatuses = turn.spanIds + .map((spanId) => spans.get(spanId)?.status) + .filter(Boolean) + const turnSpan = spans.get(turn.id) + if (turnSpan) { + turnSpan.status = childStatuses.includes('error') + ? 'error' + : childStatuses.includes('pending') + ? 'pending' + : 'ok' + turnSpan.subtitle = `${turn.spanIds.length} spans` + } + } + + const orderedSpanIds = orderSpans(spans, rootId) + const spanList = orderedSpanIds.map((id) => spans.get(id)).filter(Boolean) as TraceSpan[] + return { + rootId, + spans: spanList, + spansById: new Map(spanList.map((span) => [span.id, span])), + turns, + orderedSpanIds, + diagnosis: buildDiagnosis(spanList, turns, rootId, fallbackTimestamp), + fullRaw: { + session: trace.session, + summary: trace.summary, + calls: trace.calls, + events: traceEvents, + messages, + }, + } +} + +function buildDiagnosis( + spans: TraceSpan[], + turns: TraceTurn[], + rootId: string, + fallbackTimestamp: string, +): TraceDiagnosis { + const meaningfulSpans = spans.filter((span) => span.id !== rootId) + const errorSpans = meaningfulSpans.filter((span) => span.status === 'error') + const pendingModels = meaningfulSpans.filter((span) => span.kind === 'llm' && span.status === 'pending') + const pendingTools = meaningfulSpans.filter((span) => span.kind === 'tool' && span.status === 'pending') + const toolErrors = errorSpans.filter((span) => span.kind === 'tool' || span.kind === 'tool_result') + const modelErrors = errorSpans.filter((span) => span.kind === 'llm') + const eventErrors = errorSpans.filter((span) => span.kind === 'event') + const lastSpan = meaningfulSpans + .slice() + .sort((a, b) => compareSpanTime(a, b)) + .at(-1) + const lastTurn = turns.at(-1) + const lastTurnSpans = lastTurn + ? lastTurn.spanIds.map((spanId) => spans.find((span) => span.id === spanId)).filter(Boolean) as TraceSpan[] + : [] + const lastTurnHasUser = lastTurnSpans.some((span) => span.message?.type === 'user') + const lastTurnHasAgentWork = lastTurnSpans.some((span) => + span.kind === 'llm' || + span.kind === 'tool' || + span.kind === 'tool_result' || + span.message?.type === 'assistant' || + span.message?.type === 'tool_use' + ) + + if (meaningfulSpans.length === 0) { + return { + status: 'empty', + reason: 'empty', + evidenceSpanIds: [], + lastActivityAt: fallbackTimestamp, + errorCount: 0, + pendingModelCalls: 0, + pendingToolCalls: 0, + modelCalls: 0, + toolCalls: 0, + } + } + + if (modelErrors.length > 0) { + return createDiagnosis('blocked', 'model_error', modelErrors, spans, fallbackTimestamp) + } + if (toolErrors.length > 0) { + return createDiagnosis('blocked', 'tool_error', toolErrors, spans, fallbackTimestamp) + } + if (eventErrors.length > 0) { + return createDiagnosis('blocked', 'event_error', eventErrors, spans, fallbackTimestamp) + } + if (pendingModels.length > 0) { + return createDiagnosis('attention', 'pending_model', pendingModels, spans, fallbackTimestamp) + } + if (pendingTools.length > 0) { + return createDiagnosis('attention', 'pending_tool', pendingTools, spans, fallbackTimestamp) + } + if (lastTurnHasUser && !lastTurnHasAgentWork) { + const evidence = lastTurnSpans.length > 0 ? lastTurnSpans : lastSpan ? [lastSpan] : [] + return createDiagnosis('attention', 'waiting_for_agent', evidence, spans, fallbackTimestamp) + } + + return createDiagnosis('healthy', 'healthy', lastSpan ? [lastSpan] : [], spans, fallbackTimestamp) +} + +function createDiagnosis( + status: TraceDiagnosis['status'], + reason: TraceDiagnosisReason, + evidence: TraceSpan[], + spans: TraceSpan[], + fallbackTimestamp: string, +): TraceDiagnosis { + const errors = spans.filter((span) => span.status === 'error') + const pendingModels = spans.filter((span) => span.kind === 'llm' && span.status === 'pending') + const pendingTools = spans.filter((span) => span.kind === 'tool' && span.status === 'pending') + const lastActivityAt = spans + .slice() + .sort((a, b) => compareSpanTime(a, b)) + .at(-1)?.timestamp ?? fallbackTimestamp + return { + status, + reason, + focusSpanId: evidence[0]?.id, + evidenceSpanIds: evidence.map((span) => span.id), + lastActivityAt, + errorCount: errors.length, + pendingModelCalls: pendingModels.length, + pendingToolCalls: pendingTools.length, + modelCalls: spans.filter((span) => span.kind === 'llm').length, + toolCalls: spans.filter((span) => span.kind === 'tool').length, + } +} + +export function extractTextContent(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return formatUnknown(content) + return content + .flatMap((block) => { + if (!block || typeof block !== 'object') return [] + const record = block as TextBlock + if (typeof record.text === 'string') return [record.text] + if ('content' in record && typeof (record as { content?: unknown }).content === 'string') { + return [(record as { content: string }).content] + } + return [] + }) + .join('\n') +} + +export function previewTraceValue(value: unknown, maxChars = 180): string { + const text = extractTextContent(value).replace(/\s+/g, ' ').trim() + if (!text) return 'empty' + return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text +} + +export function formatTraceJson(value: unknown): string { + if (typeof value === 'string') { + const parsed = parseJson(value) + return parsed === null ? value : JSON.stringify(parsed, null, 2) + } + return formatUnknown(value) +} + +export function getTraceValueLanguage(value: unknown, fallback: 'json' | 'text' = 'json'): 'json' | 'text' { + if (typeof value !== 'string') return 'json' + return parseJson(value) === null ? fallback : 'json' +} + +function addMessageSpan( + spans: Map, + id: string, + parentId: string, + turnIndex: number, + message: MessageEntry, +) { + addSpan(spans, { + id, + parentId, + kind: 'message', + status: 'ok', + title: titleForMessage(message), + subtitle: previewTraceValue(message.content), + timestamp: message.timestamp, + turnIndex, + message, + raw: message, + }) +} + +function addToolResultSpan( + spans: Map, + id: string, + parentId: string, + turnIndex: number, + message: MessageEntry, + block: ToolResultBlock, +) { + addSpan(spans, { + id, + parentId, + kind: 'tool_result', + status: block.is_error ? 'error' : 'ok', + title: block.is_error ? 'Tool error' : 'Tool result', + subtitle: previewTraceValue(block.content), + timestamp: message.timestamp, + turnIndex, + message, + toolUseId: block.tool_use_id, + output: block.content, + raw: block, + }) + const parent = spans.get(parentId) + if (parent?.kind === 'tool') { + parent.status = block.is_error ? 'error' : 'ok' + const existingOutput = Array.isArray(parent.output) ? parent.output : parent.output === undefined ? [] : [parent.output] + parent.output = [...existingOutput, block.content] + } +} + +function addSpan(spans: Map, span: Omit) { + if (spans.has(span.id)) return + spans.set(span.id, { ...span, childIds: [] }) + if (span.parentId) { + const parent = spans.get(span.parentId) + if (parent && !parent.childIds.includes(span.id)) { + parent.childIds.push(span.id) + } + } +} + +function orderSpans(spans: Map, rootId: string): string[] { + const result: string[] = [] + const visit = (id: string) => { + const span = spans.get(id) + if (!span) return + result.push(id) + span.childIds.sort((a, b) => compareSpanTime(spans.get(a), spans.get(b))) + for (const childId of span.childIds) visit(childId) + } + visit(rootId) + return result +} + +function compareSpanTime(a?: MutableSpan, b?: MutableSpan): number { + if (!a || !b) return 0 + const diff = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + if (Number.isFinite(diff) && diff !== 0) return diff + return a.id.localeCompare(b.id) +} + +function createTurn(index: number, timestamp: string, title: string): TraceTurn { + return { + id: `turn:${index}`, + index, + title, + timestamp, + spanIds: [], + } +} + +function createTurnTitle(index: number, content: unknown): string { + const preview = previewTraceValue(content, 54) + return preview === 'empty' ? `Turn ${index + 1}` : preview +} + +function findTurnForTimestamp(turns: TraceTurn[], timestamp: string): TraceTurn { + const time = new Date(timestamp).getTime() + if (!Number.isFinite(time)) return turns[0]! + let current = turns[0]! + for (const turn of turns) { + const turnTime = new Date(turn.timestamp).getTime() + if (Number.isFinite(turnTime) && turnTime <= time) { + current = turn + } + } + return current +} + +function earliestTimestamp( + calls: TraceCallRecord[], + messages: MessageEntry[], + events: TraceEventRecord[] = [], +): string | null { + const timestamps = [ + ...calls.map((call) => call.startedAt), + ...messages.map((message) => message.timestamp), + ...events.map((event) => event.timestamp), + ] + .map((value) => ({ value, time: new Date(value).getTime() })) + .filter((entry) => Number.isFinite(entry.time)) + .sort((a, b) => a.time - b.time) + return timestamps[0]?.value ?? null +} + +function hasToolResultBlocks(content: unknown): boolean { + return extractToolResultBlocks(content).length > 0 +} + +function extractToolUseBlocks(content: unknown): ToolUseBlock[] { + if (!Array.isArray(content)) return [] + return content.filter((block): block is ToolUseBlock => + !!block && + typeof block === 'object' && + (block as { type?: unknown }).type === 'tool_use' + ) +} + +function extractToolResultBlocks(content: unknown): ToolResultBlock[] { + if (!Array.isArray(content)) return [] + return content.filter((block): block is ToolResultBlock => + !!block && + typeof block === 'object' && + (block as { type?: unknown }).type === 'tool_result' + ) +} + +function titleForMessage(message: MessageEntry): string { + switch (message.type) { + case 'user': return 'User message' + case 'assistant': return message.model ? `Assistant · ${message.model}` : 'Assistant message' + case 'system': return 'System message' + case 'tool_use': return 'Assistant tool request' + case 'tool_result': return 'Tool result' + default: return message.type + } +} + +function summarizeToolInput(input: unknown): string { + if (input && typeof input === 'object' && !Array.isArray(input)) { + const record = input as Record + const primary = record.command ?? record.file_path ?? record.path ?? record.query ?? record.description + if (typeof primary === 'string' && primary.trim()) return primary.trim() + } + return previewTraceValue(input, 140) +} + +function getCallStatus(call: TraceCallRecord): TraceSpanStatus { + if (call.status === 'error' || call.error || (call.response?.status ?? 200) >= 400) return 'error' + if (call.status === 'pending' || !call.response) return 'pending' + return 'ok' +} + +function getEventStatus(event: TraceEventRecord): TraceSpanStatus { + return event.severity === 'error' ? 'error' : 'ok' +} + +function formatTraceEventPhase(phase: string): string { + return phase + .split(/[_\s-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +function formatUnknown(value: unknown): string { + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +function parseJson(value: string): unknown | null { + const trimmed = value.trim() + if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return null + try { + return JSON.parse(trimmed) + } catch { + return null + } +} diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index e7f63bdd..c781fb7f 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -186,6 +186,67 @@ describe('ActiveSession task polling', () => { expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default') }) + it('opens the current session trace in a separate live window', () => { + const sessionId = 'trace-window-session' + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Trace Window Session', + createdAt: '2026-05-07T00:00:00.000Z', + modifiedAt: '2026-05-07T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: sessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId, title: 'Trace Window Session', type: 'session', status: 'idle' }], + activeTabId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + try { + render() + fireEvent.click(screen.getByLabelText(/独立|separate/i)) + + expect(openSpy).toHaveBeenCalledWith( + expect.stringContaining(`traceSessionId=${sessionId}`), + '_blank', + 'noopener,noreferrer', + ) + expect(openSpy.mock.calls[0]?.[0]).toContain('traceWindow=1') + } finally { + openSpy.mockRestore() + } + }) + it('renders the current goal as a lightweight header strip without a page-level panel', () => { const sessionId = 'goal-visible-session' diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index ffbe29e5..9f79672d 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -1,9 +1,10 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { Target } from 'lucide-react' +import { ExternalLink, RadioTower, Target } from 'lucide-react' import { SCHEDULED_TAB_ID, SETTINGS_TAB_ID, TERMINAL_TAB_PREFIX, + TRACE_TAB_PREFIX, useTabStore, type TabType, } from '../stores/tabStore' @@ -31,6 +32,8 @@ import type { ActiveGoalState } from '../types/chat' import { useMobileViewport } from '../hooks/useMobileViewport' import { isDesktopRuntime } from '../lib/desktopRuntime' import { publicAssetPath } from '../lib/publicAsset' +import { getDesktopHost } from '../lib/desktopHost' +import { buildTraceWindowUrl } from '../lib/traceLaunch' const TASK_POLL_INTERVAL_MS = 1000 const WORKSPACE_RESIZE_STEP = 32 @@ -44,7 +47,8 @@ function isSessionTabState(activeTabId: string | null, activeTabType: TabType | if (activeTabType) return false return activeTabId !== SETTINGS_TAB_ID && activeTabId !== SCHEDULED_TAB_ID && - !activeTabId.startsWith(TERMINAL_TAB_PREFIX) + !activeTabId.startsWith(TERMINAL_TAB_PREFIX) && + !activeTabId.startsWith(TRACE_TAB_PREFIX) } function getSessionTerminalCwd(session: SessionListItem | undefined) { @@ -360,6 +364,22 @@ export function ActiveSession() { return t('session.timeDays', { n: Math.floor(diff / 86400000) }) }, [session?.modifiedAt, t]) + const openTrace = () => { + if (!activeTabId) return + const title = session?.title || t('session.untitled') + useTabStore.getState().openTraceTab(activeTabId, `${t('trace.title')}: ${title}`) + } + + const openTraceWindow = () => { + if (!activeTabId) return + const host = getDesktopHost() + if (host.trace) { + void host.trace.openWindow(activeTabId) + return + } + window.open(buildTraceWindowUrl(activeTabId), '_blank', 'noopener,noreferrer') + } + if (!activeTabId) return null return ( @@ -450,15 +470,36 @@ export function ActiveSession() { } >
-

- {session?.title || t('session.untitled')} -

+
+

+ {session?.title || t('session.untitled')} +

+ + +
+
+

{t('settings.general.traceTitle')}

+

{t('settings.general.traceDescription')}

+ +
+

{t('settings.general.notificationsTitle')}

{t('settings.general.notificationsDescription')}

diff --git a/desktop/src/pages/TraceList.test.tsx b/desktop/src/pages/TraceList.test.tsx new file mode 100644 index 00000000..9774c5ab --- /dev/null +++ b/desktop/src/pages/TraceList.test.tsx @@ -0,0 +1,132 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TraceList } from './TraceList' +import { tracesApi } from '../api/traces' +import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore' +import { useSettingsStore } from '../stores/settingsStore' +import type { TraceSessionList } from '../types/trace' + +vi.mock('../api/traces', () => ({ + tracesApi: { + list: vi.fn(), + }, +})) + +const traceList: TraceSessionList = { + total: 1, + storageDir: '/tmp/cc-haha/traces', + settings: { + enabled: true, + storageDir: '/tmp/cc-haha/traces', + }, + traces: [{ + sessionId: 'session-trace-list', + session: { + id: 'session-trace-list', + title: 'Debug stuck agent', + projectPath: '/tmp/project', + workDir: '/tmp/project', + }, + summary: { + apiCalls: 3, + failedCalls: 1, + totalDurationMs: 4715, + totalInputTokens: 0, + totalOutputTokens: 0, + models: [{ model: 'gpt-5.5', calls: 2 }], + updatedAt: '2026-06-09T15:03:40.010Z', + }, + fileSize: 2048, + fileUpdatedAt: '2026-06-09T15:03:40.010Z', + }], +} + +const secondTraceList: TraceSessionList = { + ...traceList, + traces: [{ + ...traceList.traces[0]!, + sessionId: 'session-trace-second-page', + session: { + id: 'session-trace-second-page', + title: 'Second trace session', + projectPath: '/tmp/second-project', + workDir: '/tmp/second-project', + }, + summary: { + ...traceList.traces[0]!.summary, + apiCalls: 1, + failedCalls: 0, + models: [{ model: 'gpt-5.5', calls: 1 }], + }, + }], +} + +describe('TraceList', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + useTabStore.setState({ tabs: [], activeTabId: null }) + vi.mocked(tracesApi.list).mockResolvedValue(traceList) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + useTabStore.setState({ tabs: [], activeTabId: null }) + useSettingsStore.setState({ locale: 'en' }) + }) + + it('renders trace session rows and opens a session trace tab', async () => { + render() + + expect(await screen.findByText('Debug stuck agent')).toBeInTheDocument() + expect(screen.getByText('/tmp/cc-haha/traces')).toBeInTheDocument() + expect(screen.getByText('gpt-5.5 x2')).toBeInTheDocument() + expect(tracesApi.list).toHaveBeenCalledWith({ limit: 50, offset: 0, query: '' }) + + fireEvent.click(screen.getAllByRole('button', { name: 'Trace' })[0]!) + + expect(useTabStore.getState().activeTabId).toBe('__trace__session-trace-list') + expect(useTabStore.getState().tabs.find((tab) => tab.type === 'trace')?.traceSessionId).toBe('session-trace-list') + }) + + it('opens General settings from the trace settings button', async () => { + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Trace settings' })) + + expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID) + expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === SETTINGS_TAB_ID)?.type).toBe('settings') + }) + + it('loads additional trace pages instead of fetching all rows at once', async () => { + vi.mocked(tracesApi.list) + .mockResolvedValueOnce({ ...traceList, total: 2 }) + .mockResolvedValueOnce({ ...secondTraceList, total: 2 }) + + render() + + expect(await screen.findByText('Debug stuck agent')).toBeInTheDocument() + expect(screen.getByText('Showing 1 of 2')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Load more' })) + + expect(await screen.findByText('Second trace session')).toBeInTheDocument() + expect(screen.getByText('Showing 2 of 2')).toBeInTheDocument() + expect(tracesApi.list).toHaveBeenNthCalledWith(1, { limit: 50, offset: 0, query: '' }) + expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 1, query: '' }) + }) + + it('sends title search text to the trace list API', async () => { + render() + + await screen.findByText('Debug stuck agent') + fireEvent.change(screen.getByPlaceholderText('Search title, session ID, or project path'), { + target: { value: 'stuck agent' }, + }) + + await waitFor(() => { + expect(tracesApi.list).toHaveBeenLastCalledWith({ limit: 50, offset: 0, query: 'stuck agent' }) + }) + }) +}) diff --git a/desktop/src/pages/TraceList.tsx b/desktop/src/pages/TraceList.tsx new file mode 100644 index 00000000..20c9f34f --- /dev/null +++ b/desktop/src/pages/TraceList.tsx @@ -0,0 +1,335 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { AlertTriangle, CheckCircle2, 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' + +type TraceListState = + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'ready'; data: TraceSessionList } + +const POLL_MS = 5_000 +const PAGE_SIZE = 50 +const SEARCH_DEBOUNCE_MS = 250 + +export function TraceList() { + const t = useTranslation() + const [state, setState] = useState({ status: 'loading' }) + const [queryInput, setQueryInput] = useState('') + const [query, setQuery] = useState('') + const [isLoadingMore, setIsLoadingMore] = useState(false) + const host = getDesktopHost() + + useEffect(() => { + const timer = window.setTimeout(() => { + setQuery(queryInput.trim()) + }, SEARCH_DEBOUNCE_MS) + return () => window.clearTimeout(timer) + }, [queryInput]) + + const load = useCallback(async (options?: { + append?: boolean + limit?: number + offset?: number + silent?: boolean + }) => { + const append = options?.append === true + const offset = options?.offset ?? 0 + const limit = options?.limit ?? PAGE_SIZE + try { + if (append) { + setIsLoadingMore(true) + } else if (!options?.silent) { + setState({ status: 'loading' }) + } + const data = await tracesApi.list({ limit, offset, query }) + setState((previous) => { + if (!append || previous.status !== 'ready') { + return { status: 'ready', data } + } + return { + status: 'ready', + data: { + ...data, + traces: [...previous.data.traces, ...data.traces], + }, + } + }) + } catch (error) { + setState({ + status: 'error', + message: error instanceof Error ? error.message : t('trace.list.loadFailed'), + }) + } finally { + if (append) setIsLoadingMore(false) + } + }, [query, t]) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (state.status !== 'ready' || !state.data.settings.enabled) return + const timer = window.setInterval(() => { + void load({ + limit: Math.max(PAGE_SIZE, state.data.traces.length), + silent: true, + }) + }, POLL_MS) + return () => window.clearInterval(timer) + }, [load, state]) + + const summary = useMemo(() => { + if (state.status !== 'ready') return { apiCalls: 0, failedCalls: 0, models: 0 } + const modelNames = new Set() + let apiCalls = 0 + let failedCalls = 0 + for (const item of state.data.traces) { + apiCalls += item.summary.apiCalls + failedCalls += item.summary.failedCalls + for (const model of item.summary.models) modelNames.add(model.model) + } + return { apiCalls, failedCalls, models: modelNames.size } + }, [state]) + + return ( +
+
+
+
+
+
+

{t('trace.list.title')}

+ {state.status === 'ready' && ( +

+ {state.data.storageDir} +

+ )} +
+
+ + +
+
+ +
+ + + 0 ? 'danger' : 'default'} /> + +
+
+ +
+
+
+
+
+ + {state.status === 'loading' && ( +
{t('common.loading')}
+ )} + {state.status === 'error' && ( +
+ {state.message} +
+ )} + {state.status === 'ready' && ( + void load({ + append: true, + offset: state.data.traces.length, + silent: true, + })} + onOpenWindow={(sessionId) => { + if (host.trace) void host.trace.openWindow(sessionId) + }} + /> + )} +
+
+ ) +} + +function TraceRows({ + loadingMore, + onLoadMore, + traces, + total, + onOpenWindow, +}: { + loadingMore: boolean + onLoadMore: () => void + traces: TraceSessionListItem[] + total: number + onOpenWindow: (sessionId: string) => void +}) { + const t = useTranslation() + + if (traces.length === 0) { + return ( +
+
+
+
+ ) + } + + return ( +
+
+
{t('trace.list.session')}
+
{t('trace.apiCalls')}
+
{t('trace.failedCalls')}
+
{t('trace.duration')}
+
{t('trace.models')}
+
{t('trace.list.actions')}
+
+ {traces.map((trace) => ( + + ))} +
+ {t('trace.list.loadedCount', { shown: traces.length, total })} + {traces.length < total && ( + + )} +
+
+ ) +} + +function TraceRow({ + trace, + onOpenWindow, +}: { + trace: TraceSessionListItem + onOpenWindow: (sessionId: string) => void +}) { + 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 + + return ( +
+
+ +
+
{trace.summary.apiCalls}
+
{trace.summary.failedCalls}
+
{formatDuration(trace.summary.totalDurationMs)}
+
{modelLabel}
+
+ + +
+
+ {t('trace.list.fileSize')}: {formatBytes(trace.fileSize)} +
+
+ ) +} + +function Metric({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function openTrace(sessionId: string, title: string, t: ReturnType) { + useTabStore.getState().openTraceTab(sessionId, `${t('trace.title')}: ${title}`) +} + +function openTraceSettings(t: ReturnType) { + useUIStore.getState().setPendingSettingsTab('general') + useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings') +} + +function formatDuration(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return '-' + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +function formatUpdatedAt(value: string | null): string { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '-' + return date.toLocaleString() +} diff --git a/desktop/src/pages/TraceSession.test.tsx b/desktop/src/pages/TraceSession.test.tsx new file mode 100644 index 00000000..1cc68705 --- /dev/null +++ b/desktop/src/pages/TraceSession.test.tsx @@ -0,0 +1,174 @@ +import { cleanup, fireEvent, render, screen, waitFor } 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' + +vi.mock('../api/sessions', () => ({ + sessionsApi: { + getTrace: vi.fn(), + getMessages: vi.fn(), + }, +})) + +const baseTrace: TraceSessionData = { + sessionId: 'session-live', + session: { + id: 'session-live', + title: 'Trace API title', + projectPath: '/tmp', + workDir: '/tmp', + }, + 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', + }, + 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, + }, + }, + }], +} + +describe('TraceSession', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: [] }) + useSessionStore.setState({ + sessions: [{ + id: 'session-live', + title: 'Live probe', + createdAt: '2026-06-09T10:00:00.000Z', + modifiedAt: '2026-06-09T10:10:00.000Z', + messageCount: 0, + projectPath: '/tmp', + workDir: '/tmp', + workDirExists: true, + }], + activeSessionId: 'session-live', + isLoading: false, + error: null, + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useSettingsStore.setState({ locale: 'en' }) + }) + + it('refreshes the trace snapshot while the page is open', async () => { + vi.mocked(sessionsApi.getTrace) + .mockResolvedValueOnce(baseTrace) + .mockResolvedValueOnce({ + ...baseTrace, + summary: { + ...baseTrace.summary, + apiCalls: 2, + models: [{ model: 'gpt-5.5', calls: 2 }], + }, + calls: [ + ...baseTrace.calls, + { ...baseTrace.calls[0]!, id: 'call-2', durationMs: 900 }, + ], + }) + + render() + + await screen.findByText('gpt-5.5 x1') + expect(sessionsApi.getTrace).toHaveBeenCalledWith('session-live') + + await waitFor(() => expect(screen.getByText('gpt-5.5 x2')).toBeInTheDocument()) + expect(vi.mocked(sessionsApi.getTrace).mock.calls.length).toBeGreaterThanOrEqual(2) + }) + + 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() + + 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() + + 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() + + 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() + }) +}) diff --git a/desktop/src/pages/TraceSession.tsx b/desktop/src/pages/TraceSession.tsx new file mode 100644 index 00000000..4827ed25 --- /dev/null +++ b/desktop/src/pages/TraceSession.tsx @@ -0,0 +1,1525 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { + AlertTriangle, + Bot, + CheckCircle2, + Clock3, + Copy, + Database, + ExternalLink, + FileJson2, + GitBranch, + MessageSquareText, + RadioTower, + RefreshCw, + Search, + Server, + Wrench, +} from 'lucide-react' +import { sessionsApi } from '../api/sessions' +import { useSessionStore } from '../stores/sessionStore' +import { useTranslation } from '../i18n' +import type { MessageEntry } from '../types/session' +import type { TraceBodySnapshot, TraceSession as TraceSessionData } from '../types/trace' +import { getDesktopHost } from '../lib/desktopHost' +import { buildTraceWindowUrl } from '../lib/traceLaunch' +import { formatBytes as formatBytesValue } from '../lib/formatBytes' +import { CodeViewer } from '../components/chat/CodeViewer' +import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer' +import { CopyButton } from '../components/shared/CopyButton' +import { + buildTraceViewModel, + extractTextContent, + formatTraceJson, + getTraceValueLanguage, + previewTraceValue, + type TraceSpan, + type TraceSpanKind, + type TraceSpanStatus, + type TraceTurn, + type TraceViewModel, +} from '../lib/traceViewModel' + +type LoadState = + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'ready'; trace: TraceSessionData; messages: MessageEntry[] } + +type SpanFilter = 'all' | 'llm' | 'tool' | 'error' +type InspectorTab = 'overview' | 'input' | 'output' | 'metadata' | 'raw' +type RawScope = 'span' | 'full' +type TraceTranslator = ReturnType + +const TRACE_POLL_INTERVAL_MS = 1500 +const MESSAGE_PREVIEW_CHARS = 5000 + +export function TraceSession({ + sessionId, + standalone = false, + pollIntervalMs = TRACE_POLL_INTERVAL_MS, +}: { + sessionId: string + standalone?: boolean + pollIntervalMs?: number +}) { + const t = useTranslation() + const sessionTitle = useSessionStore((s) => s.sessions.find((session) => session.id === sessionId)?.title) + const [state, setState] = useState({ status: 'loading' }) + const [selectedId, setSelectedId] = useState(null) + const [inspectorTab, setInspectorTab] = useState('overview') + const [rawScope, setRawScope] = useState('span') + const [filter, setFilter] = useState('all') + const [query, setQuery] = useState('') + const [refreshNonce, setRefreshNonce] = useState(0) + const [lastLoadedAt, setLastLoadedAt] = useState(null) + const [refreshing, setRefreshing] = useState(false) + + useEffect(() => { + let cancelled = false + + const load = async (silent: boolean) => { + if (!silent) setState({ status: 'loading' }) + if (silent) setRefreshing(true) + try { + const trace = await sessionsApi.getTrace(sessionId) + if (!isTraceSessionData(trace)) { + throw new Error(t('trace.snapshotEmpty')) + } + const messageResponse = await sessionsApi.getMessages(sessionId).catch(() => ({ messages: [] })) + if (cancelled) return + setState({ status: 'ready', trace, messages: messageResponse.messages }) + setLastLoadedAt(new Date().toISOString()) + } catch (error) { + if (cancelled) return + if (!silent) { + setState({ status: 'error', message: error instanceof Error ? error.message : String(error) }) + } + } finally { + if (!cancelled) setRefreshing(false) + } + } + + setSelectedId(null) + setInspectorTab('overview') + void load(false) + const interval = window.setInterval(() => { + void load(true) + }, pollIntervalMs) + + return () => { + cancelled = true + window.clearInterval(interval) + } + }, [sessionId, refreshNonce, pollIntervalMs, t]) + + const refresh = () => setRefreshNonce((value) => value + 1) + + const openWindow = () => { + const host = getDesktopHost() + if (host.trace) { + void host.trace.openWindow(sessionId) + return + } + window.open(buildTraceWindowUrl(sessionId), '_blank', 'noopener,noreferrer') + } + + const readyState = state.status === 'ready' ? state : null + const viewModel = useMemo( + () => readyState ? buildTraceViewModel(readyState.trace, readyState.messages) : null, + [readyState], + ) + const selected = selectedId && viewModel ? viewModel.spansById.get(selectedId) : null + const activeSpan = viewModel + ? selected ?? viewModel.spansById.get(viewModel.rootId) ?? viewModel.spans[0] ?? null + : null + const visibleSpanIds = useMemo( + () => viewModel ? filterSpanIds(viewModel, filter, query) : new Set(), + [filter, query, viewModel], + ) + + useEffect(() => { + if (!viewModel) return + if (!activeSpan && viewModel.rootId) { + setSelectedId(viewModel.rootId) + return + } + if (selectedId && viewModel.spansById.has(selectedId)) return + const diagnosisFocus = viewModel.diagnosis.focusSpanId + ? viewModel.spansById.get(viewModel.diagnosis.focusSpanId) + : undefined + const firstError = viewModel.spans.find((span) => span.status === 'error') + const firstUseful = diagnosisFocus ?? firstError ?? viewModel.spans.find((span) => span.kind === 'llm') ?? viewModel.spansById.get(viewModel.rootId) + if (firstUseful) setSelectedId(firstUseful.id) + }, [activeSpan, selectedId, viewModel]) + + if (state.status === 'loading') { + return ( + + ) + } + + if (state.status === 'error') { + return ( +
+ +
+
+
+ + {t('trace.loadFailed')} +
+

{state.message}

+ +
+
+
+ ) + } + + const { trace, messages } = state + const resolvedTitle = sessionTitle ?? trace.session?.title ?? t('session.untitled') + if (!viewModel) { + return + } + + const hasTraceContent = viewModel.spans.length > 1 || trace.calls.length > 0 || messages.length > 0 + + return ( +
+ + { + setSelectedId(spanId) + setInspectorTab('overview') + }} + /> + {hasTraceContent && activeSpan ? ( +
+ { + setSelectedId(spanId) + setInspectorTab('overview') + }} + /> + { + setSelectedId(spanId) + setInspectorTab('overview') + }} + /> + +
+ ) : ( + + )} +
+ ) +} + +function TraceHeader({ + sessionId, + title, + trace, + standalone, + onOpenWindow, + onRefresh, + refreshing = false, + updatedAt, +}: { + sessionId: string + title: string + trace?: TraceSessionData + standalone?: boolean + onOpenWindow?: () => void + onRefresh?: () => void + refreshing?: boolean + updatedAt?: string | null +}) { + const t = useTranslation() + const summary = trace?.summary + const modelLabel = summary?.models.map((model) => `${model.model} x${model.calls}`).join(', ') || t('trace.noModel') + + return ( +
+
+
+
+ + {t('trace.title')} + + + {t('trace.live')} + + + {t('trace.sessionTrace')} + +
+

+ {title} +

+
+ {sessionId} + {updatedAt ? {t('trace.updatedAt')}: {formatTime(updatedAt)} : null} +
+
+
+ {summary ? ( +
+ + 0 ? 'danger' : 'default'} /> + + + +
+ ) : null} +
+ } + displayCopiedLabel={} + className="inline-flex h-8 w-8 items-center justify-center rounded-[8px] border border-[var(--color-border)] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:text-[var(--color-text-primary)] active:scale-[0.98]" + /> + + + + {!standalone ? ( + + + + ) : null} +
+
+
+
+ ) +} + +function isTraceSessionData(value: unknown): value is TraceSessionData { + return !!value && + typeof value === 'object' && + 'sessionId' in value && + 'summary' in value && + Array.isArray((value as { calls?: unknown }).calls) +} + +function TraceDiagnosisBar({ + viewModel, + onSelect, +}: { + viewModel: TraceViewModel + onSelect: (spanId: string) => void +}) { + const t = useTranslation() + const diagnosis = viewModel.diagnosis + const focusSpan = diagnosis.focusSpanId ? viewModel.spansById.get(diagnosis.focusSpanId) : undefined + const evidenceSpans = diagnosis.evidenceSpanIds + .map((spanId) => viewModel.spansById.get(spanId)) + .filter((span): span is TraceSpan => !!span) + .slice(0, 4) + const toneClass = diagnosis.status === 'blocked' + ? 'border-[var(--color-error)]/30 bg-[var(--color-error-container)]/30' + : diagnosis.status === 'attention' + ? 'border-[var(--color-warning)]/30 bg-[var(--color-warning-container)]/25' + : 'border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]' + + return ( +
+
+
+
+ + + {t('trace.diagnosis')} + + + + {diagnosisReasonLabel(diagnosis.reason, t)} + +
+
+ + 0 ? 'danger' : 'default'} /> + 0 ? 'danger' : 'default'} /> + 0 ? 'danger' : 'default'} /> + {focusSpan ? ( + + ) : null} +
+
+ {evidenceSpans.length > 0 ? ( +
+ {evidenceSpans.map((span) => ( + + ))} +
+ ) : null} +
+
+ ) +} + +function DiagnosisChip({ + label, + value, + tone = 'default', +}: { + label: string + value: string + tone?: 'default' | 'danger' +}) { + return ( + + {label} + {value} + + ) +} + +function diagnosisStatusToSpanStatus(status: TraceViewModel['diagnosis']['status']): TraceSpanStatus { + if (status === 'blocked') return 'error' + if (status === 'attention') return 'pending' + return 'ok' +} + +function TraceRunTree({ + viewModel, + visibleSpanIds, + selectedId, + filter, + query, + onFilterChange, + onQueryChange, + onSelect, +}: { + viewModel: TraceViewModel + visibleSpanIds: Set + selectedId: string + filter: SpanFilter + query: string + onFilterChange: (filter: SpanFilter) => void + onQueryChange: (query: string) => void + onSelect: (spanId: string) => void +}) { + const t = useTranslation() + const rows = viewModel.orderedSpanIds + .map((id) => viewModel.spansById.get(id)) + .filter((span): span is TraceSpan => !!span && visibleSpanIds.has(span.id)) + const depthById = useMemo(() => computeDepths(viewModel), [viewModel]) + + return ( + + ) +} + +function TreeSpanRow({ + span, + depth, + selected, + onSelect, +}: { + span: TraceSpan + depth: number + selected: boolean + onSelect: () => void +}) { + const t = useTranslation() + return ( + + ) +} + +function TraceThread({ + turns, + viewModel, + selectedId, + onSelect, +}: { + turns: TraceTurn[] + viewModel: TraceViewModel + selectedId: string + onSelect: (spanId: string) => void +}) { + const t = useTranslation() + return ( +
+
+
+
+
+
+ + {t('trace.thread')} +
+
+ {t('trace.threadStats', { turns: turns.length, spans: viewModel.spans.length })} +
+
+
+ {t('trace.turnCount', { count: turns.length })} + {t('trace.spanCount', { count: viewModel.spans.length })} +
+
+
+
+
+ {turns.map((turn) => ( + + ))} +
+
+
+
+ ) +} + +function TraceTurnBlock({ + turn, + viewModel, + selectedId, + onSelect, +}: { + turn: TraceTurn + viewModel: TraceViewModel + selectedId: string + onSelect: (spanId: string) => void +}) { + const t = useTranslation() + const displaySpans = turn.spanIds + .map((spanId) => viewModel.spansById.get(spanId)) + .filter((span): span is TraceSpan => !!span && (span.parentId === turn.id || span.kind === 'message' || span.kind === 'llm' || span.kind === 'event')) + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) + const stats = summarizeTurn(displaySpans) + + return ( +
+ +
+ {displaySpans.map((span) => ( + onSelect(span.id)} + /> + ))} +
+
+ ) +} + +function ThreadSpanCard({ + span, + viewModel, + selected, + onSelect, +}: { + span: TraceSpan + viewModel: TraceViewModel + selected: boolean + onSelect: () => void +}) { + const t = useTranslation() + if (span.kind === 'message') { + return + } + + if (span.kind === 'tool') { + const childSpans = span.childIds + .map((id) => viewModel.spansById.get(id)) + .filter((child): child is TraceSpan => !!child) + return ( + + ) + } + + return ( + + ) +} + +function MessageSpanCard({ span, selected, onSelect }: { span: TraceSpan; selected: boolean; onSelect: () => void }) { + const t = useTranslation() + const message = span.message + const text = message ? createTextPreview(extractTextContent(message.content), MESSAGE_PREVIEW_CHARS).text : '' + const isUser = message?.type === 'user' + const isAssistant = message?.type === 'assistant' + + return ( + + ) +} + +function TraceInspector({ + span, + viewModel, + activeTab, + rawScope, + onTabChange, + onRawScopeChange, +}: { + span: TraceSpan + viewModel: TraceViewModel + activeTab: InspectorTab + rawScope: RawScope + onTabChange: (tab: InspectorTab) => void + onRawScopeChange: (scope: RawScope) => void +}) { + const t = useTranslation() + return ( + + ) +} + +function OverviewPanel({ span, viewModel }: { span: TraceSpan; viewModel: TraceViewModel }) { + const t = useTranslation() + const call = span.call + const children = span.childIds.map((id) => viewModel.spansById.get(id)).filter(Boolean) as TraceSpan[] + return ( +
+
+ + + + +
+ {call ? ( +
+ + + + +
+ ) : null} + {call?.error ? ( + + {call?.error?.message ?? t('trace.spanFailed')} + + ) : null} + {span.kind === 'session' ? ( + + + + ) : null} + {children.length > 0 ? ( + +
+ {children.map((child) => ( +
+ +
+
{spanTitle(child, t)}
+
{spanSubtitle(child, t)}
+
+ +
+ ))} +
+
+ ) : null} +
+ ) +} + +function InputPanel({ span }: { span: TraceSpan }) { + const t = useTranslation() + if (span.call) { + return ( +
+ + +
+ ) + } + if (span.kind === 'tool') { + return + } + if (span.message) { + return + } + if (span.event) { + return + } + return +} + +function OutputPanel({ span, viewModel }: { span: TraceSpan; viewModel: TraceViewModel }) { + const t = useTranslation() + if (span.call) { + if (span.call.error) { + return {span.call.error.message} + } + return span.call.response ? ( +
+ + +
+ ) : ( + + ) + } + if (span.kind === 'tool') { + const outputs = span.childIds + .map((id) => viewModel.spansById.get(id)) + .filter((child): child is TraceSpan => !!child && child.kind === 'tool_result') + .map((child) => child.output) + return outputs.length > 0 + ? + : + } + if (span.kind === 'tool_result') { + return + } + if (span.event) { + return + } + return +} + +function MetadataPanel({ span }: { span: TraceSpan }) { + const call = span.call + const metadata = { + id: span.id, + parentId: span.parentId, + kind: span.kind, + status: span.status, + timestamp: span.timestamp, + completedAt: span.completedAt, + durationMs: span.durationMs, + turnIndex: span.turnIndex, + toolUseId: span.toolUseId, + toolName: span.toolName, + isSidechain: span.isSidechain, + event: span.event + ? { + id: span.event.id, + phase: span.event.phase, + severity: span.event.severity, + callId: span.event.callId, + source: span.event.source, + provider: span.event.provider, + model: span.event.model, + message: span.event.message, + } + : undefined, + provider: call?.provider, + model: call?.model, + request: call + ? { + method: call.request.method, + url: call.request.url, + bodyBytes: call.request.body.bytes, + bodySha256: call.request.body.sha256, + bodyTruncated: call.request.body.truncated, + } + : undefined, + response: call?.response + ? { + status: call.response.status, + bodyBytes: call.response.body.bytes, + bodySha256: call.response.body.sha256, + bodyTruncated: call.response.body.truncated, + } + : undefined, + } + const t = useTranslation() + return +} + +function RawPanel({ + span, + viewModel, + rawScope, + onRawScopeChange, +}: { + span: TraceSpan + viewModel: TraceViewModel + rawScope: RawScope + onRawScopeChange: (scope: RawScope) => void +}) { + const t = useTranslation() + const raw = rawScope === 'span' ? span.raw : viewModel.fullRaw + return ( +
+
+ {(['span', 'full'] as const).map((scope) => ( + + ))} +
+ +
+ ) +} + +function BodyPreview({ title, body }: { title: string; body: TraceBodySnapshot }) { + const t = useTranslation() + const language = body.contentType === 'json' ? 'json' : 'text' + const code = body.contentType === 'json' ? formatTraceJson(body.preview) : body.preview + return ( + + {formatBytesValue(body.bytes)}{body.truncated ? ` · ${t('trace.truncatedShort')}` : ''} + + } + > + {code ? ( + + ) : ( + + )} + + ) +} + +function DataPreview({ + title, + value, + language, + maxLines = 28, +}: { + title: string + value: unknown + language?: 'json' | 'text' + maxLines?: number +}) { + const t = useTranslation() + const code = language === 'text' ? String(value ?? '') : formatTraceJson(value) + const detectedLanguage = language ?? getTraceValueLanguage(value) + return ( + + {code && code !== 'undefined' ? ( + + ) : ( + + )} + + ) +} + +function MessagePreview({ title, message }: { title: string; message: MessageEntry }) { + const t = useTranslation() + const text = extractTextContent(message.content) + const preview = createTextPreview(text, MESSAGE_PREVIEW_CHARS) + const language = getTraceValueLanguage(message.content, 'text') + if (text.trim() && language === 'text') { + return ( + {t('trace.truncatedShort')} : null}> +
+ +
+
+ ) + } + return +} + +function SummaryRows({ span, viewModel }: { span: TraceSpan; viewModel: TraceViewModel }) { + const t = useTranslation() + const llm = viewModel.spans.filter((item) => item.kind === 'llm').length + const tools = viewModel.spans.filter((item) => item.kind === 'tool').length + const errors = viewModel.spans.filter((item) => item.status === 'error').length + return ( +
+ + + + + 0 ? 'danger' : 'default'} /> + +
+ ) +} + +function DataSection({ title, right, children }: { title: string; right?: ReactNode; children: ReactNode }) { + return ( +
+
+ {title} + {right} +
+ {children} +
+ ) +} + +function EmptyPanel({ label, compact = false }: { label: string; compact?: boolean }) { + return ( +
+ {label} +
+ ) +} + +function InlineNotice({ tone, title, children }: { tone: 'danger' | 'default'; title: string; children: ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ) +} + +function IconAction({ label, onClick, children }: { label: string; onClick?: () => void; children: ReactNode }) { + return ( + + ) +} + +function SpanIcon({ span }: { span: TraceSpan }) { + const className = span.status === 'error' + ? 'text-[var(--color-error)]' + : span.kind === 'llm' + ? 'text-[var(--color-brand)]' + : 'text-[var(--color-text-tertiary)]' + const icon = iconForKind(span.kind) + return {icon} +} + +function iconForKind(kind: TraceSpanKind): ReactNode { + switch (kind) { + case 'session': return + case 'turn': return + case 'llm': return + case 'tool': return + case 'tool_result': return + case 'message': return + case 'event': return + default: return + } +} + +function StatusGlyph({ status }: { status: TraceSpanStatus }) { + if (status === 'error') return + if (status === 'pending') return + return +} + +function StatusPill({ status }: { status: TraceSpanStatus }) { + const t = useTranslation() + const icon = status === 'ok' + ? + : status === 'error' + ? + : + return ( + + {icon} + {statusLabel(status, t)} + + ) +} + +function Metric({ label, value, tone = 'default', wide = false }: { label: string; value: string; tone?: 'default' | 'danger'; wide?: boolean }) { + return ( +
+
{label}
+
+ {value} +
+
+ ) +} + +function MetricBox({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function MiniStat({ value, label, tone = 'default' }: { value: number; label: string; tone?: 'default' | 'danger' }) { + return ( + 0 + ? 'border-[var(--color-error)]/25 text-[var(--color-error)]' + : 'border-[var(--color-border)] text-[var(--color-text-tertiary)]' + }`}> + {value} {label} + + ) +} + +function SummaryRow({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function TraceLoading({ sessionId, title, standalone }: { sessionId: string; title: string; standalone?: boolean }) { + return ( +
+ +
+
+
+
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ ))} +
+
+
+
+
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+
+
+
+
+
+
+
+ ) +} + +function TraceEmpty() { + const t = useTranslation() + return ( +
+
+ +

{t('trace.emptyTitle')}

+

{t('trace.emptyBody')}

+
+
+ ) +} + +function filterSpanIds(viewModel: TraceViewModel, filter: SpanFilter, query: string): Set { + const normalizedQuery = query.trim().toLowerCase() + const matched = new Set() + 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) { + 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 { + const depths = new Map() + 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 summarizeTurn(spans: TraceSpan[]): { llm: number; tools: number; errors: number } { + return spans.reduce( + (acc, span) => { + if (span.kind === 'llm') acc.llm += 1 + if (span.kind === 'tool') acc.tools += 1 + if (span.status === 'error') acc.errors += 1 + return acc + }, + { llm: 0, tools: 0, errors: 0 }, + ) +} + +function filterLabel(filter: SpanFilter, t: TraceTranslator): 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') + } +} + +function inspectorTabLabel(tab: InspectorTab, t: TraceTranslator): string { + switch (tab) { + case 'input': return t('trace.tab.input') + case 'output': return t('trace.tab.output') + case 'metadata': return t('trace.tab.metadata') + case 'raw': return t('trace.tab.raw') + default: return t('trace.tab.overview') + } +} + +function kindLabel(kind: TraceSpanKind, t: TraceTranslator): string { + switch (kind) { + case 'session': return t('trace.kind.session') + case 'turn': return t('trace.kind.turn') + case 'llm': return t('trace.kind.llm') + case 'tool': return t('trace.kind.tool') + case 'tool_result': return t('trace.kind.toolResult') + case 'message': return t('trace.kind.message') + case 'event': return t('trace.kind.event') + default: return kind + } +} + +function statusLabel(status: TraceSpanStatus, t: TraceTranslator): string { + switch (status) { + case 'error': return t('trace.status.error') + case 'pending': return t('trace.status.pending') + default: return t('trace.status.ok') + } +} + +function diagnosisReasonLabel(reason: TraceViewModel['diagnosis']['reason'], t: TraceTranslator): string { + switch (reason) { + case 'model_error': return t('trace.diagnosis.modelError') + case 'tool_error': return t('trace.diagnosis.toolError') + case 'event_error': return t('trace.diagnosis.eventError') + case 'pending_model': return t('trace.diagnosis.pendingModel') + case 'pending_tool': return t('trace.diagnosis.pendingTool') + case 'waiting_for_agent': return t('trace.diagnosis.waitingForAgent') + case 'empty': return t('trace.diagnosis.empty') + default: return t('trace.diagnosis.healthy') + } +} + +function spanTitle(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 span.message.model ? `${t('trace.message.assistant')} · ${span.message.model}` : 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 === 'tool_result') { + return span.status === 'error' ? t('trace.toolError') : t('trace.toolResult') + } + 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 === 'event' && span.event) { + return traceEventPhaseLabel(span.event.phase, t) + } + if (span.kind === 'turn') { + if (span.title === 'Session activity') return t('trace.sessionActivity') + const match = span.title.match(/^Turn (\d+)$/) + if (match) return t('trace.turnLabel', { index: match[1]! }) + } + return span.title +} + +function turnTitle(turn: TraceTurn, t: TraceTranslator): string { + if (turn.title === 'Session activity') return t('trace.sessionActivity') + const match = turn.title.match(/^Turn (\d+)$/) + if (match) return t('trace.turnLabel', { index: match[1]! }) + return turn.title +} + +function spanSubtitle(span: TraceSpan, t: TraceTranslator): string { + if (span.kind === 'session') { + return t('trace.modelCalls', { count: span.call ? 1 : getSessionModelCallCount(span) }) + } + if (span.kind === 'turn') { + return t('trace.turnLabel', { index: (span.turnIndex ?? 0) + 1 }) + } + if (span.kind === 'event' && span.event) { + return span.event.message ?? span.event.provider?.name ?? spanSubtitleFromEvent(span.event, t) + } + return span.subtitle === 'empty' ? t('trace.emptyValue') : span.subtitle +} + +function spanSubtitleFromEvent(event: NonNullable, t: TraceTranslator): string { + const status = event.metadata && typeof event.metadata.status === 'number' + ? `HTTP ${event.metadata.status}` + : undefined + return status ?? event.source ?? t('trace.kind.event') +} + +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(' ') + } +} + +function getSessionModelCallCount(span: TraceSpan): number { + const raw = span.raw as { summary?: { apiCalls?: unknown } } | undefined + return typeof raw?.summary?.apiCalls === 'number' ? raw.summary.apiCalls : 0 +} + +function createTextPreview(text: string, maxChars: number): { text: string; truncated: boolean } { + if (text.length <= maxChars) return { text, truncated: false } + return { text: `${text.slice(0, maxChars)}\n...`, truncated: true } +} + +function formatDuration(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return '0ms' + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s` +} + +function formatTime(value: string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 41fce78c..c1033f43 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -3,6 +3,7 @@ import { ApiError } from '../api/client' import { settingsApi } from '../api/settings' import { modelsApi } from '../api/models' import { h5AccessApi } from '../api/h5Access' +import { tracesApi } from '../api/traces' import { isThemeMode, type AppMode, @@ -23,6 +24,7 @@ import { type UpdateProxySettings, type WebSearchSettings, } from '../types/settings' +import type { TraceCaptureSettings } from '../types/trace' import { getDesktopHost } from '../lib/desktopHost' import type { Locale } from '../i18n' import { @@ -75,6 +77,7 @@ type SettingsStore = { webSearch: WebSearchSettings updateProxy: UpdateProxySettings network: NetworkSettings + traceCapture: TraceCaptureSettings h5Access: H5AccessSettings h5AccessDiagnostics: H5AccessDiagnostics | null h5AccessError: string | null @@ -103,6 +106,7 @@ type SettingsStore = { setWebSearch: (settings: WebSearchSettings) => Promise setUpdateProxy: (settings: UpdateProxySettings) => Promise setNetwork: (settings: NetworkSettings) => Promise + setTraceCaptureEnabled: (enabled: boolean) => Promise enableH5Access: () => Promise disableH5Access: () => Promise regenerateH5AccessToken: () => Promise @@ -155,6 +159,11 @@ const DEFAULT_OUTPUT_STYLE_OPTIONS: OutputStyleOption[] = [ }, ] +const DEFAULT_TRACE_CAPTURE_SETTINGS: TraceCaptureSettings = { + enabled: true, + storageDir: '', +} + export const useSettingsStore = create((set, get) => ({ permissionMode: 'default', currentModel: null, @@ -177,6 +186,7 @@ export const useSettingsStore = create((set, get) => ({ webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS, network: DEFAULT_NETWORK_SETTINGS, + traceCapture: DEFAULT_TRACE_CAPTURE_SETTINGS, h5Access: DEFAULT_H5_ACCESS_SETTINGS, h5AccessDiagnostics: null, h5AccessError: null, @@ -203,13 +213,14 @@ export const useSettingsStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { const previousH5Access = get().h5Access - const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessResult] = await Promise.all([ + const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessResult, traceCapture] = await Promise.all([ settingsApi.getPermissionMode(), modelsApi.list(), modelsApi.getCurrent(), modelsApi.getEffort(), settingsApi.getUser(), loadH5AccessSettings(previousH5Access), + loadTraceCaptureSettings(), ]) const theme = isThemeMode(userSettings.theme) ? userSettings.theme : 'white' useUIStore.getState().setTheme(theme) @@ -229,6 +240,7 @@ export const useSettingsStore = create((set, get) => ({ webSearch: normalizeWebSearchSettings(userSettings.webSearch), updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy), network: normalizeNetworkSettings(userSettings.network), + traceCapture, h5Access: h5AccessResult.settings, h5AccessDiagnostics: h5AccessResult.diagnostics, h5AccessError: h5AccessResult.error, @@ -447,6 +459,18 @@ export const useSettingsStore = create((set, get) => ({ } }, + setTraceCaptureEnabled: async (enabled) => { + const prev = get().traceCapture + set({ traceCapture: { ...prev, enabled } }) + try { + const next = await tracesApi.updateSettings({ enabled }) + set({ traceCapture: normalizeTraceCaptureSettings(next) }) + } catch (error) { + set({ traceCapture: prev }) + throw error + } + }, + enableH5Access: async () => { set({ h5AccessError: null }) try { @@ -624,6 +648,15 @@ function normalizeNetworkSettings( } } +function normalizeTraceCaptureSettings( + settings: TraceCaptureSettings | undefined, +): TraceCaptureSettings { + return { + enabled: settings?.enabled !== false, + storageDir: typeof settings?.storageDir === 'string' ? settings.storageDir : '', + } +} + function normalizeDesktopTerminalSettings( settings: Partial | undefined, ): DesktopTerminalSettings { @@ -648,6 +681,14 @@ function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5Ac } } +async function loadTraceCaptureSettings(): Promise { + try { + return normalizeTraceCaptureSettings(await tracesApi.getSettings()) + } catch { + return DEFAULT_TRACE_CAPTURE_SETTINGS + } +} + async function refreshH5DiagnosticsSilent( set: (partial: Partial) => void, ): Promise { diff --git a/desktop/src/stores/tabStore.test.ts b/desktop/src/stores/tabStore.test.ts index ea16f7b5..63649b12 100644 --- a/desktop/src/stores/tabStore.test.ts +++ b/desktop/src/stores/tabStore.test.ts @@ -1,10 +1,18 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import { useTabStore } from './tabStore' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sessionsApi } from '../api/sessions' +import { SETTINGS_TAB_ID, useTabStore } from './tabStore' + +vi.mock('../api/sessions', () => ({ + sessionsApi: { + list: vi.fn(async () => ({ sessions: [] })), + }, +})) describe('tabStore', () => { beforeEach(() => { useTabStore.setState({ tabs: [], activeTabId: null }) localStorage.clear() + vi.mocked(sessionsApi.list).mockResolvedValue({ sessions: [] } as never) }) it('refreshes an existing tab title when opening the same session again', () => { @@ -35,4 +43,30 @@ describe('tabStore', () => { ]) expect(useTabStore.getState().activeTabId).toBe(tabId) }) + + it('does not let async tab restore overwrite tabs opened while restore is in flight', async () => { + let resolveSessions: (value: unknown) => void = () => {} + vi.mocked(sessionsApi.list).mockReturnValueOnce(new Promise((resolve) => { + resolveSessions = resolve + }) as never) + localStorage.setItem('cc-haha-open-tabs', JSON.stringify({ + openTabs: [{ sessionId: 'session-1', title: 'Old Session', type: 'session' }], + activeTabId: 'session-1', + })) + + const restore = useTabStore.getState().restoreTabs() + useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') + resolveSessions({ sessions: [{ id: 'session-1', title: 'Old Session' }] }) + await restore + + expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID) + expect(useTabStore.getState().tabs).toEqual([ + { + sessionId: SETTINGS_TAB_ID, + title: 'Settings', + type: 'settings', + status: 'idle', + }, + ]) + }) }) diff --git a/desktop/src/stores/tabStore.ts b/desktop/src/stores/tabStore.ts index 54fadf4b..2ab3e6b5 100644 --- a/desktop/src/stores/tabStore.ts +++ b/desktop/src/stores/tabStore.ts @@ -7,9 +7,11 @@ const TAB_STORAGE_KEY = 'cc-haha-open-tabs' export const SETTINGS_TAB_ID = '__settings__' export const SCHEDULED_TAB_ID = '__scheduled__' +export const TRACE_LIST_TAB_ID = '__traces__' export const TERMINAL_TAB_PREFIX = '__terminal__' +export const TRACE_TAB_PREFIX = '__trace__' -export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' +export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' | 'trace' | 'traces' export type Tab = { sessionId: string @@ -18,10 +20,11 @@ export type Tab = { status: 'idle' | 'running' | 'error' terminalCwd?: string terminalRuntimeId?: string + traceSessionId?: string } type TabPersistence = { - openTabs: Array<{ sessionId: string; title: string; type?: TabType }> + openTabs: Array<{ sessionId: string; title: string; type?: TabType; traceSessionId?: string }> activeTabId: string | null } @@ -30,6 +33,8 @@ type TabStore = { activeTabId: string | null openTab: (sessionId: string, title: string, type?: TabType) => void + openTracesTab: (title?: string) => string + openTraceTab: (sessionId: string, title?: string) => string openTerminalTab: (cwd?: string, terminalRuntimeId?: string) => string closeTab: (sessionId: string) => void setActiveTab: (sessionId: string) => void @@ -71,6 +76,51 @@ export const useTabStore = create((set, get) => ({ get().saveTabs() }, + openTracesTab: (title = 'Traces') => { + const { tabs } = get() + const existing = tabs.find((tab) => tab.sessionId === TRACE_LIST_TAB_ID) + if (existing) { + set({ + tabs: tabs.map((tab) => ( + tab.sessionId === TRACE_LIST_TAB_ID + ? { ...tab, title, type: 'traces' } + : tab + )), + activeTabId: TRACE_LIST_TAB_ID, + }) + } else { + set({ + tabs: [...tabs, { sessionId: TRACE_LIST_TAB_ID, title, type: 'traces', status: 'idle' }], + activeTabId: TRACE_LIST_TAB_ID, + }) + } + get().saveTabs() + return TRACE_LIST_TAB_ID + }, + + openTraceTab: (sessionId, title = 'Trace') => { + const traceTabId = `${TRACE_TAB_PREFIX}${sessionId}` + const { tabs } = get() + const existing = tabs.find((tab) => tab.sessionId === traceTabId) + if (existing) { + set({ + tabs: tabs.map((tab) => ( + tab.sessionId === traceTabId + ? { ...tab, title, type: 'trace', traceSessionId: sessionId } + : tab + )), + activeTabId: traceTabId, + }) + } else { + set({ + tabs: [...tabs, { sessionId: traceTabId, title, type: 'trace', status: 'idle', traceSessionId: sessionId }], + activeTabId: traceTabId, + }) + } + get().saveTabs() + return traceTabId + }, + openTerminalTab: (cwd, terminalRuntimeId) => { const { tabs } = get() const nextIndex = Math.max( @@ -162,7 +212,12 @@ export const useTabStore = create((set, get) => ({ const { tabs, activeTabId } = get() const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal') const data: TabPersistence = { - openTabs: persistableTabs.map((t) => ({ sessionId: t.sessionId, title: t.title, type: t.type })), + openTabs: persistableTabs.map((t) => ({ + sessionId: t.sessionId, + title: t.title, + type: t.type, + ...(t.traceSessionId ? { traceSessionId: t.traceSessionId } : {}), + })), activeTabId: activeTabId && persistableTabs.some((tab) => tab.sessionId === activeTabId) ? activeTabId : (persistableTabs[0]?.sessionId ?? null), @@ -174,6 +229,7 @@ export const useTabStore = create((set, get) => ({ restoreTabs: async () => { try { + const restoreStartedWith = get() const raw = localStorage.getItem(TAB_STORAGE_KEY) if (!raw) return @@ -185,20 +241,38 @@ export const useTabStore = create((set, get) => ({ } const { sessions } = await sessionsApi.list({ limit: 200 }) + const current = get() + if ( + current.tabs !== restoreStartedWith.tabs || + current.activeTabId !== restoreStartedWith.activeTabId + ) { + return + } const existingIds = new Set(sessions.map((s) => s.id)) const validTabs: Tab[] = data.openTabs .filter((t) => { // Special tabs are always valid - if (t.type === 'settings' || t.type === 'scheduled') return true + if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'traces') return true + if (t.type === 'trace') return !!t.traceSessionId && existingIds.has(t.traceSessionId) if (t.type === 'terminal') return false // Session tabs must exist on server return existingIds.has(t.sessionId) }) .map((t) => { - if (t.type === 'settings' || t.type === 'scheduled') { + if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'traces') { return { sessionId: t.sessionId, title: t.title, type: t.type, status: 'idle' as const } } + if (t.type === 'trace' && t.traceSessionId) { + const sourceTitle = sessions.find((s) => s.id === t.traceSessionId)?.title || t.title + return { + sessionId: `${TRACE_TAB_PREFIX}${t.traceSessionId}`, + title: sourceTitle === t.title ? t.title : `Trace: ${sourceTitle}`, + type: 'trace' as const, + status: 'idle' as const, + traceSessionId: t.traceSessionId, + } + } return { sessionId: t.sessionId, title: sessions.find((s) => s.id === t.sessionId)?.title || t.title, diff --git a/desktop/src/types/trace.ts b/desktop/src/types/trace.ts new file mode 100644 index 00000000..cb7d15a1 --- /dev/null +++ b/desktop/src/types/trace.ts @@ -0,0 +1,111 @@ +export type TraceBodySnapshot = { + contentType: 'json' | 'text' | 'empty' + bytes: number + sha256: string + preview: string + truncated: boolean +} + +export type TraceProviderInfo = { + id: string | null + name: string + format: string +} + +export type TraceCallStatus = 'pending' | 'ok' | 'error' +export type TraceEventSeverity = 'info' | 'warning' | 'error' + +export type TraceCallRecord = { + id: string + sessionId: string + source: 'anthropic' | 'proxy' + querySource?: string + provider?: TraceProviderInfo + model?: string + status?: TraceCallStatus + startedAt: string + completedAt?: string + durationMs?: number + metadata?: Record + request: { + method: string + url: string + headers: Record + body: TraceBodySnapshot + } + response?: { + status: number + headers: Record + body: TraceBodySnapshot + } + error?: { + name: string + message: string + code?: string + stack?: string + cause?: string + } +} + +export type TraceEventRecord = { + id: string + sessionId: string + timestamp: string + phase: string + severity: TraceEventSeverity + callId?: string + source?: TraceCallRecord['source'] + provider?: TraceProviderInfo + model?: string + title?: string + message?: string + metadata?: Record +} + +export type TraceSessionSummary = { + apiCalls: number + failedCalls: number + totalDurationMs: number + totalInputTokens: number + totalOutputTokens: number + models: Array<{ model: string; calls: number }> + updatedAt: string | null +} + +export type TraceSession = { + sessionId: string + session?: { + id: string + title: string + projectPath: string + workDir: string | null + } | null + summary: TraceSessionSummary + calls: TraceCallRecord[] + events?: TraceEventRecord[] +} + +export type TraceCaptureSettings = { + enabled: boolean + storageDir: string +} + +export type TraceSessionListItem = { + sessionId: string + session: { + id: string + title: string + projectPath: string + workDir: string | null + } | null + summary: TraceSessionSummary + fileSize: number + fileUpdatedAt: string +} + +export type TraceSessionList = { + traces: TraceSessionListItem[] + total: number + storageDir: string + settings: TraceCaptureSettings +} diff --git a/src/query.ts b/src/query.ts index ef1251f2..ef0522e3 100644 --- a/src/query.ts +++ b/src/query.ts @@ -94,6 +94,7 @@ import { executePostSamplingHooks } from './utils/hooks/postSamplingHooks.js' import { executeStopFailureHooks } from './utils/hooks.js' import type { QuerySource } from './constants/querySource.js' import { createDumpPromptsFetch } from './services/api/dumpPrompts.js' +import { shouldCaptureApiTrace } from './services/api/traceCapture.js' import { StreamingToolExecutor } from './services/tools/StreamingToolExecutor.js' import { queryCheckpoint } from './utils/queryProfiler.js' import { runTools } from './services/tools/toolOrchestration.js' @@ -587,8 +588,11 @@ async function* queryLoop( // instead of all request bodies from the session (~500MB for long sessions). // Note: agentId is effectively constant during a query() call - it only changes // between queries (e.g., /clear command or session resume). - const dumpPromptsFetch = config.gates.isAnt - ? createDumpPromptsFetch(toolUseContext.agentId ?? config.sessionId) + const dumpPromptsFetch = config.gates.isAnt || shouldCaptureApiTrace() + ? createDumpPromptsFetch(toolUseContext.agentId ?? config.sessionId, { + traceSessionId: config.sessionId, + querySource, + }) : undefined // Block if we've hit the hard blocking limit (only applies when auto-compact is OFF) diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 33aa0312..1a73f287 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -7,6 +7,7 @@ import { DESKTOP_CLI_GRACEFUL_SHUTDOWN_TIMEOUT_MS, } from '../services/conversationService.js' import { ProviderService } from '../services/providerService.js' +import { updateTraceCaptureSettings } from '../services/traceCaptureService.js' import { resetTerminalShellEnvironmentCacheForTests } from '../../utils/terminalShellEnvironment.js' describe('ConversationService', () => { @@ -22,6 +23,10 @@ describe('ConversationService', () => { let originalDiagnosticsFile: string | undefined let originalAttributionHeader: string | undefined let originalResumeInterruptedTurn: string | undefined + let originalTraceApiCalls: string | undefined + let originalTraceProviderId: string | undefined + let originalTraceProviderName: string | undefined + let originalTraceProviderFormat: string | undefined let originalHome: string | undefined let originalPath: string | undefined let originalShell: string | undefined @@ -41,6 +46,10 @@ describe('ConversationService', () => { originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE originalAttributionHeader = process.env.CLAUDE_CODE_ATTRIBUTION_HEADER originalResumeInterruptedTurn = process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN + originalTraceApiCalls = process.env.CC_HAHA_TRACE_API_CALLS + originalTraceProviderId = process.env.CC_HAHA_TRACE_PROVIDER_ID + originalTraceProviderName = process.env.CC_HAHA_TRACE_PROVIDER_NAME + originalTraceProviderFormat = process.env.CC_HAHA_TRACE_PROVIDER_FORMAT originalHome = process.env.HOME originalPath = process.env.PATH originalShell = process.env.SHELL @@ -60,6 +69,10 @@ describe('ConversationService', () => { delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER delete process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN + delete process.env.CC_HAHA_TRACE_API_CALLS + delete process.env.CC_HAHA_TRACE_PROVIDER_ID + delete process.env.CC_HAHA_TRACE_PROVIDER_NAME + delete process.env.CC_HAHA_TRACE_PROVIDER_FORMAT process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1' resetTerminalShellEnvironmentCacheForTests() }) @@ -98,6 +111,18 @@ describe('ConversationService', () => { if (originalResumeInterruptedTurn === undefined) delete process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN else process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN = originalResumeInterruptedTurn + if (originalTraceApiCalls === undefined) delete process.env.CC_HAHA_TRACE_API_CALLS + else process.env.CC_HAHA_TRACE_API_CALLS = originalTraceApiCalls + + if (originalTraceProviderId === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_ID + else process.env.CC_HAHA_TRACE_PROVIDER_ID = originalTraceProviderId + + if (originalTraceProviderName === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_NAME + else process.env.CC_HAHA_TRACE_PROVIDER_NAME = originalTraceProviderName + + if (originalTraceProviderFormat === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_FORMAT + else process.env.CC_HAHA_TRACE_PROVIDER_FORMAT = originalTraceProviderFormat + if (originalHome === undefined) delete process.env.HOME else process.env.HOME = originalHome @@ -366,6 +391,68 @@ describe('ConversationService', () => { expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1') expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0') expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined() + expect(env.CC_HAHA_TRACE_PROVIDER_ID).toBeUndefined() + expect(env.CC_HAHA_TRACE_PROVIDER_NAME).toBeUndefined() + expect(env.CC_HAHA_TRACE_PROVIDER_FORMAT).toBeUndefined() + }) + + test('buildChildEnv injects trace provider metadata for desktop sdk session-scoped providers', async () => { + const providerService = new ProviderService() + const provider = await providerService.addProvider({ + presetId: 'custom', + name: 'Traceable Provider', + apiKey: 'provider-key', + baseUrl: 'https://traceable.example', + apiFormat: 'anthropic', + models: { + main: 'gpt-5.5', + haiku: '', + sonnet: '', + opus: '', + }, + }) + + const service = new ConversationService() as any + const env = (await service.buildChildEnv( + '/tmp', + 'ws://127.0.0.1:3456/sdk/test-session?token=test-token', + { providerId: provider.id }, + )) as Record + + expect(env.CC_HAHA_TRACE_API_CALLS).toBe('1') + expect(env.CC_HAHA_TRACE_PROVIDER_ID).toBe(provider.id) + expect(env.CC_HAHA_TRACE_PROVIDER_NAME).toBe('Traceable Provider') + expect(env.CC_HAHA_TRACE_PROVIDER_FORMAT).toBe('anthropic') + }) + + test('buildChildEnv does not inject trace env when managed trace capture is disabled', async () => { + await updateTraceCaptureSettings({ enabled: false }) + const providerService = new ProviderService() + const provider = await providerService.addProvider({ + presetId: 'custom', + name: 'Trace disabled provider', + apiKey: 'provider-key', + baseUrl: 'https://traceable.example', + apiFormat: 'anthropic', + models: { + main: 'gpt-5.5', + haiku: '', + sonnet: '', + opus: '', + }, + }) + + const service = new ConversationService() as any + const env = (await service.buildChildEnv( + '/tmp', + 'ws://127.0.0.1:3456/sdk/test-session?token=test-token', + { providerId: provider.id }, + )) as Record + + expect(env.CC_HAHA_TRACE_API_CALLS).toBeUndefined() + expect(env.CC_HAHA_TRACE_PROVIDER_ID).toBeUndefined() + expect(env.CC_HAHA_TRACE_PROVIDER_NAME).toBeUndefined() + expect(env.CC_HAHA_TRACE_PROVIDER_FORMAT).toBeUndefined() }) test('buildChildEnv uses the session-selected model for session-scoped providers', async () => { @@ -561,6 +648,7 @@ describe('ConversationService', () => { 'com.claude-code-haha.desktop', ) expect(env.CC_HAHA_DESKTOP_SERVER_URL).toBe('http://127.0.0.1:3456') + expect(env.CC_HAHA_TRACE_API_CALLS).toBe('1') expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBe('1') }) diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 570f3808..1d3e1644 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -3209,6 +3209,7 @@ describe('WebSocket Chat Integration', () => { return body.status?.permissionMode === 'default' }, `persisted bypass-to-default permission switch for ${sessionId}`) expect(startCalls).toHaveLength(1) + expect(conversationService.getSessionPermissionMode(sessionId)).toBe('default') expect(messages.slice(switchStartIndex).some((msg) => msg.type === 'error')).toBe(false) } finally { ws.close() diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 0167e211..68d82c7a 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -9,6 +9,7 @@ import * as os from 'os' import { ProviderService } from '../services/providerService.js' import { handleProvidersApi } from '../api/providers.js' import { handleProxyRequest } from '../proxy/handler.js' +import { clearTraceCaptureStateForTests, traceCaptureService } from '../services/traceCaptureService.js' import type { CreateProviderInput } from '../types/provider.js' // ─── Test helpers ───────────────────────────────────────────────────────────── @@ -20,9 +21,11 @@ async function setup() { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'provider-test-')) originalConfigDir = process.env.CLAUDE_CONFIG_DIR process.env.CLAUDE_CONFIG_DIR = tmpDir + clearTraceCaptureStateForTests() } async function teardown() { + clearTraceCaptureStateForTests() if (originalConfigDir !== undefined) { process.env.CLAUDE_CONFIG_DIR = originalConfigDir } else { @@ -944,6 +947,61 @@ describe('ProviderService', () => { }) describe('handleProxyRequest', () => { + test('records a session trace for proxied OpenAI Chat calls', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = mock(async () => { + return new Response(JSON.stringify({ + id: 'chatcmpl-trace', + object: 'chat.completion', + created: 0, + model: 'gpt-4', + choices: [{ index: 0, message: { role: 'assistant', content: 'trace ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 11, completion_tokens: 3, total_tokens: 14 }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json', 'x-request-id': 'req-trace' }, + }) + }) as typeof fetch + + try { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput({ apiFormat: 'openai_chat', name: 'Trace Provider' })) + await svc.activateProvider(provider.id) + + const req = new Request('http://localhost:3456/proxy/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Claude-Code-Session-Id': 'session-proxy-trace', + }, + body: JSON.stringify({ + model: 'gpt-4', + max_tokens: 64, + messages: [{ role: 'user', content: 'capture this call' }], + }), + }) + + const res = await handleProxyRequest(req, new URL(req.url)) + const trace = await traceCaptureService.getSessionTrace('session-proxy-trace') + + expect(res.status).toBe(200) + expect(trace.summary.apiCalls).toBe(1) + expect(trace.calls[0]).toMatchObject({ + source: 'proxy', + provider: { + id: provider.id, + name: 'Trace Provider', + format: 'openai_chat', + }, + model: 'gpt-4', + }) + expect(trace.calls[0].request.body.preview).toContain('capture this call') + expect(trace.calls[0].response.body.preview).toContain('chatcmpl-trace') + } finally { + globalThis.fetch = originalFetch + } + }) + test('injects Claude Code billing attribution with compat version and signed CCH', async () => { const originalFetch = globalThis.fetch const originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT diff --git a/src/server/__tests__/trace-capture.test.ts b/src/server/__tests__/trace-capture.test.ts new file mode 100644 index 00000000..9742be44 --- /dev/null +++ b/src/server/__tests__/trace-capture.test.ts @@ -0,0 +1,542 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { handleApiRequest } from '../router.js' +import { + clearTraceCaptureStateForTests, + createTraceCallId, + createTraceBodySnapshot, + traceCaptureService, + updateTraceCaptureSettings, +} from '../services/traceCaptureService.js' +import { sessionService } from '../services/sessionService.js' +import { createDumpPromptsFetch } from '../../services/api/dumpPrompts.js' + +let tmpDir: string +let originalConfigDir: string | undefined + +async function waitForTrace( + sessionId: string, + predicate: (trace: Awaited>) => boolean, +) { + for (let attempt = 0; attempt < 30; attempt++) { + const trace = await traceCaptureService.getSessionTrace(sessionId) + if (predicate(trace)) return trace + await new Promise((resolve) => setTimeout(resolve, 10)) + } + return traceCaptureService.getSessionTrace(sessionId) +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'trace-capture-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir + clearTraceCaptureStateForTests() +}) + +afterEach(async () => { + clearTraceCaptureStateForTests() + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +describe('trace capture service', () => { + test('stores session scoped API calls with redacted headers and capped bodies', async () => { + const body = { + model: 'deepseek-v4-pro', + api_key: 'sk-body-secret', + messages: [ + { role: 'user', content: 'explain the failed provider response' }, + ], + padding: 'x'.repeat(3000), + } + + await traceCaptureService.recordCall({ + sessionId: 'session-trace-1', + source: 'proxy', + querySource: 'repl_main_thread', + provider: { + id: 'provider-deepseek', + name: 'DeepSeek', + format: 'openai_chat', + }, + model: 'deepseek-v4-pro', + startedAt: '2026-06-09T08:00:00.000Z', + completedAt: '2026-06-09T08:00:00.047Z', + durationMs: 47, + request: { + method: 'POST', + url: 'https://api.deepseek.com/v1/chat/completions', + headers: { + Authorization: 'Bearer sk-header-secret', + 'Content-Type': 'application/json', + }, + body, + }, + response: { + status: 200, + headers: { + 'x-request-id': 'req-742', + }, + body: { + id: 'chatcmpl-742', + choices: [{ message: { role: 'assistant', content: 'ok' } }], + usage: { prompt_tokens: 31, completion_tokens: 7 }, + }, + }, + }) + + const trace = await traceCaptureService.getSessionTrace('session-trace-1') + + expect(trace.summary.apiCalls).toBe(1) + expect(trace.summary.failedCalls).toBe(0) + expect(trace.summary.totalDurationMs).toBe(47) + 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.truncated).toBe(true) + expect(trace.calls[0].response.body.preview).toContain('chatcmpl-742') + }) + + test('builds stable body snapshots without throwing on non-json input', () => { + const snapshot = createTraceBodySnapshot('plain text response', { maxPreviewChars: 20 }) + + expect(snapshot.contentType).toBe('text') + expect(snapshot.preview).toBe('plain text response') + expect(snapshot.truncated).toBe(false) + expect(snapshot.sha256).toMatch(/^[0-9a-f]{64}$/) + }) + + 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 }) + await fs.writeFile(path.join(traceDir, 'session-corrupt.jsonl'), [ + 'not-json', + 'null', + '{}', + JSON.stringify({ + type: 'event', + event: { + id: 'event-valid', + sessionId: 'session-corrupt', + timestamp: '2026-06-09T08:00:00.001Z', + phase: 'api_call_started', + severity: 'info', + }, + }), + JSON.stringify({ + type: 'call', + record: { + id: 'call-valid', + sessionId: 'session-corrupt', + 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 }), + }, + }, + }), + ].join('\n')) + + const trace = await traceCaptureService.getSessionTrace('session-corrupt') + + expect(trace.calls.map((call) => call.id)).toEqual(['call-valid']) + expect(trace.events.map((event) => event.id)).toEqual(['event-valid']) + expect(trace.summary.apiCalls).toBe(1) + }) + + test('upserts pending calls and preserves lifecycle events', async () => { + const callId = createTraceCallId() + await traceCaptureService.recordCall({ + id: callId, + sessionId: 'session-trace-upsert', + source: 'anthropic', + model: 'gpt-5.5', + status: 'pending', + startedAt: '2026-06-09T08:00:00.000Z', + request: { + method: 'POST', + url: 'https://sub2api.example.test/v1/messages', + body: { model: 'gpt-5.5', messages: [{ role: 'user', content: 'pending' }] }, + }, + }) + await traceCaptureService.recordEvent({ + sessionId: 'session-trace-upsert', + callId, + phase: 'api_call_started', + source: 'anthropic', + model: 'gpt-5.5', + }) + await traceCaptureService.recordCall({ + id: callId, + sessionId: 'session-trace-upsert', + source: 'anthropic', + model: 'gpt-5.5', + startedAt: '2026-06-09T08:00:00.000Z', + completedAt: '2026-06-09T08:00:00.120Z', + durationMs: 120, + request: { + method: 'POST', + url: 'https://sub2api.example.test/v1/messages', + body: { model: 'gpt-5.5', messages: [{ role: 'user', content: 'pending' }] }, + }, + response: { + status: 200, + body: { id: 'msg-upsert' }, + }, + }) + + const trace = await traceCaptureService.getSessionTrace('session-trace-upsert') + + expect(trace.summary.apiCalls).toBe(1) + expect(trace.calls).toHaveLength(1) + expect(trace.calls[0].id).toBe(callId) + expect(trace.calls[0].status).toBe('ok') + expect(trace.events).toHaveLength(1) + expect(trace.events[0]).toMatchObject({ + phase: 'api_call_started', + callId, + source: 'anthropic', + }) + }) + + test('respects managed trace capture settings before writing new records', async () => { + await updateTraceCaptureSettings({ enabled: false }) + + const result = await traceCaptureService.recordCall({ + sessionId: 'session-trace-disabled', + source: 'proxy', + model: 'gpt-5.5', + request: { + method: 'POST', + url: 'https://api.example.test/v1/messages', + body: { model: 'gpt-5.5' }, + }, + }) + const trace = await traceCaptureService.getSessionTrace('session-trace-disabled') + const settingsFile = JSON.parse(await fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8')) as { + traceCapture?: { enabled?: boolean } + } + + expect(result).toBeNull() + expect(trace.summary.apiCalls).toBe(0) + expect(settingsFile.traceCapture?.enabled).toBe(false) + }) + + test('captures direct Anthropic-compatible provider calls from desktop fetch override', async () => { + const originalFetch = globalThis.fetch + const originalTraceEnv = process.env.CC_HAHA_TRACE_API_CALLS + const originalProviderId = process.env.CC_HAHA_TRACE_PROVIDER_ID + const originalProviderName = process.env.CC_HAHA_TRACE_PROVIDER_NAME + const originalProviderFormat = process.env.CC_HAHA_TRACE_PROVIDER_FORMAT + process.env.CC_HAHA_TRACE_API_CALLS = '1' + process.env.CC_HAHA_TRACE_PROVIDER_ID = 'provider-sub2api' + process.env.CC_HAHA_TRACE_PROVIDER_NAME = 'Sub2API-ChatGPT' + process.env.CC_HAHA_TRACE_PROVIDER_FORMAT = 'anthropic' + try { + globalThis.fetch = (async () => new Response( + JSON.stringify({ id: 'msg-direct-trace', content: [{ type: 'text', text: 'ok' }] }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + )) as typeof fetch + + const traceFetch = createDumpPromptsFetch('agent-direct', { + traceSessionId: 'session-direct-provider', + querySource: 'test_query', + }) + await traceFetch('https://sub2api.example.test/v1/messages', { + method: 'POST', + body: JSON.stringify({ model: 'gpt-5.5', messages: [{ role: 'user', content: 'trace me' }] }), + }) + + const trace = await waitForTrace( + 'session-direct-provider', + (snapshot) => Boolean(snapshot.calls[0]?.response) && snapshot.events.length >= 2, + ) + expect(trace.summary.apiCalls).toBe(1) + expect(trace.calls[0]).toMatchObject({ + source: 'anthropic', + model: 'gpt-5.5', + querySource: 'test_query', + provider: { + id: 'provider-sub2api', + name: 'Sub2API-ChatGPT', + format: 'anthropic', + }, + }) + expect(trace.calls[0].request.body.preview).toContain('trace me') + expect(trace.calls[0].response.body.preview).toContain('msg-direct-trace') + expect(trace.events.map((event) => event.phase)).toEqual(['api_call_started', 'api_call_completed']) + } finally { + globalThis.fetch = originalFetch + if (originalTraceEnv === undefined) delete process.env.CC_HAHA_TRACE_API_CALLS + else process.env.CC_HAHA_TRACE_API_CALLS = originalTraceEnv + if (originalProviderId === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_ID + else process.env.CC_HAHA_TRACE_PROVIDER_ID = originalProviderId + if (originalProviderName === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_NAME + else process.env.CC_HAHA_TRACE_PROVIDER_NAME = originalProviderName + if (originalProviderFormat === undefined) delete process.env.CC_HAHA_TRACE_PROVIDER_FORMAT + else process.env.CC_HAHA_TRACE_PROVIDER_FORMAT = originalProviderFormat + } + }) + + test('captures direct provider fetch failures without changing thrown behavior', async () => { + const originalFetch = globalThis.fetch + const originalTraceEnv = process.env.CC_HAHA_TRACE_API_CALLS + process.env.CC_HAHA_TRACE_API_CALLS = '1' + try { + globalThis.fetch = (async () => { + throw new Error('network down for trace') + }) as typeof fetch + + const traceFetch = createDumpPromptsFetch('agent-direct-fail', { + traceSessionId: 'session-direct-provider-fail', + querySource: 'test_query', + }) + await expect(traceFetch('https://sub2api.example.test/v1/messages', { + method: 'POST', + body: JSON.stringify({ model: 'gpt-5.5', messages: [{ role: 'user', content: 'trace failure' }] }), + })).rejects.toThrow('network down for trace') + + const trace = await waitForTrace( + 'session-direct-provider-fail', + (snapshot) => Boolean(snapshot.calls[0]?.error) && snapshot.events.length >= 2, + ) + expect(trace.summary.apiCalls).toBe(1) + expect(trace.summary.failedCalls).toBe(1) + expect(trace.calls[0]).toMatchObject({ + source: 'anthropic', + model: 'gpt-5.5', + status: 'error', + error: { + name: 'Error', + message: 'network down for trace', + }, + }) + expect(trace.calls[0].request.body.preview).toContain('trace failure') + expect(trace.events.map((event) => event.phase)).toEqual(['api_call_started', 'api_call_failed']) + } finally { + globalThis.fetch = originalFetch + if (originalTraceEnv === undefined) delete process.env.CC_HAHA_TRACE_API_CALLS + else process.env.CC_HAHA_TRACE_API_CALLS = originalTraceEnv + } + }) + + test('passes session id to local provider proxy without duplicating client-side trace', async () => { + const originalFetch = globalThis.fetch + const originalTraceEnv = process.env.CC_HAHA_TRACE_API_CALLS + let seenHeader: string | null = null + process.env.CC_HAHA_TRACE_API_CALLS = '1' + try { + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + seenHeader = new Headers(init?.headers).get('x-claude-code-session-id') + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + + const traceFetch = createDumpPromptsFetch('agent-proxy', { + traceSessionId: 'session-local-proxy', + querySource: 'test_query', + }) + await traceFetch('http://127.0.0.1:3456/proxy/providers/provider-1/v1/messages', { + method: 'POST', + body: JSON.stringify({ model: 'deepseek-v4-pro', messages: [{ role: 'user', content: 'proxy trace' }] }), + }) + + expect(seenHeader).toBe('session-local-proxy') + const trace = await traceCaptureService.getSessionTrace('session-local-proxy') + expect(trace.summary.apiCalls).toBe(0) + } finally { + globalThis.fetch = originalFetch + if (originalTraceEnv === undefined) delete process.env.CC_HAHA_TRACE_API_CALLS + else process.env.CC_HAHA_TRACE_API_CALLS = originalTraceEnv + } + }) +}) + +describe('session trace API', () => { + test('returns an empty trace when no calls were captured for the session', async () => { + const req = new Request('http://localhost:3456/api/sessions/missing-session/trace') + const url = new URL(req.url) + + const res = await handleApiRequest(req, url) + const body = await res.json() as Awaited> & { session: unknown } + + expect(res.status).toBe(200) + expect(body.sessionId).toBe('missing-session') + expect(body.session).toBeNull() + expect(body.summary.apiCalls).toBe(0) + expect(body.calls).toEqual([]) + expect(body.events).toEqual([]) + }) + + test('lists trace sessions with storage metadata and managed settings', async () => { + await traceCaptureService.recordCall({ + sessionId: 'session-list-trace', + source: 'proxy', + model: 'gpt-5.5', + startedAt: '2026-06-09T08:00:00.000Z', + completedAt: '2026-06-09T08:00:00.015Z', + durationMs: 15, + request: { + method: 'POST', + url: 'https://api.example.test/v1/messages', + body: { model: 'gpt-5.5' }, + }, + response: { + status: 200, + body: { ok: true }, + }, + }) + + const req = new Request('http://localhost:3456/api/traces') + const res = await handleApiRequest(req, new URL(req.url)) + const body = await res.json() as { + traces: Array<{ sessionId: string; summary: { apiCalls: number }; fileSize: number }> + total: number + storageDir: string + settings: { enabled: boolean; storageDir: string } + } + + expect(res.status).toBe(200) + expect(body.total).toBe(1) + expect(body.traces[0].sessionId).toBe('session-list-trace') + expect(body.traces[0].summary.apiCalls).toBe(1) + expect(body.traces[0].fileSize).toBeGreaterThan(0) + expect(body.storageDir).toBe(path.join(tmpDir, 'cc-haha', 'traces')) + expect(body.settings).toEqual({ + enabled: true, + storageDir: path.join(tmpDir, 'cc-haha', 'traces'), + }) + }) + + test('searches trace sessions by session title and project path before paginating', async () => { + await traceCaptureService.recordCall({ + sessionId: 'session-title-alpha', + source: 'proxy', + model: 'gpt-5.5', + startedAt: '2026-06-09T08:00:00.000Z', + completedAt: '2026-06-09T08:00:00.015Z', + durationMs: 15, + request: { + method: 'POST', + url: 'https://api.example.test/v1/messages', + body: { model: 'gpt-5.5' }, + }, + response: { + status: 200, + body: { ok: true }, + }, + }) + await traceCaptureService.recordCall({ + sessionId: 'session-title-beta', + source: 'proxy', + model: 'gpt-5.5', + startedAt: '2026-06-09T08:00:01.000Z', + completedAt: '2026-06-09T08:00:01.015Z', + durationMs: 15, + request: { + method: 'POST', + url: 'https://api.example.test/v1/messages', + body: { model: 'gpt-5.5' }, + }, + response: { + status: 200, + body: { ok: true }, + }, + }) + + const originalGetSession = sessionService.getSession + sessionService.getSession = (async (sessionId: string) => { + if (sessionId === 'session-title-alpha') { + return { + id: sessionId, + title: 'Debug stuck checkout agent', + createdAt: '2026-06-09T08:00:00.000Z', + modifiedAt: '2026-06-09T08:00:00.015Z', + messageCount: 2, + projectPath: '/tmp/checkout', + projectRoot: '/tmp/checkout', + workDir: '/tmp/checkout', + workDirExists: true, + messages: [], + } + } + if (sessionId === 'session-title-beta') { + return { + id: sessionId, + title: 'Unrelated model run', + createdAt: '2026-06-09T08:00:01.000Z', + modifiedAt: '2026-06-09T08:00:01.015Z', + messageCount: 2, + projectPath: '/tmp/other', + projectRoot: '/tmp/other', + workDir: '/tmp/other', + workDirExists: true, + messages: [], + } + } + return null + }) as typeof sessionService.getSession + + try { + const titleReq = new Request('http://localhost:3456/api/traces?q=stuck%20agent&limit=10&offset=0') + const titleRes = await handleApiRequest(titleReq, new URL(titleReq.url)) + const titleBody = await titleRes.json() as { + traces: Array<{ sessionId: string; session: { title: string; projectPath: string } | null }> + total: number + } + + expect(titleRes.status).toBe(200) + expect(titleBody.total).toBe(1) + expect(titleBody.traces.map((trace) => trace.sessionId)).toEqual(['session-title-alpha']) + expect(titleBody.traces[0].session?.title).toBe('Debug stuck checkout agent') + + const pathReq = new Request('http://localhost:3456/api/traces?q=checkout&limit=10&offset=0') + const pathRes = await handleApiRequest(pathReq, new URL(pathReq.url)) + const pathBody = await pathRes.json() as { + traces: Array<{ sessionId: string; session: { projectPath: string } | null }> + total: number + } + + expect(pathRes.status).toBe(200) + expect(pathBody.total).toBe(1) + expect(pathBody.traces.map((trace) => trace.sessionId)).toEqual(['session-title-alpha']) + expect(pathBody.traces[0].session?.projectPath).toBe('/tmp/checkout') + + const missReq = new Request('http://localhost:3456/api/traces?q=missing-title&limit=10&offset=0') + const missRes = await handleApiRequest(missReq, new URL(missReq.url)) + const missBody = await missRes.json() as { + traces: Array<{ sessionId: string }> + total: number + } + + expect(missRes.status).toBe(200) + expect(missBody.total).toBe(0) + expect(missBody.traces).toEqual([]) + } finally { + sessionService.getSession = originalGetSession + } + }) +}) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index c4c4d318..5aa9714d 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -7,6 +7,7 @@ * GET /api/sessions — 列出会话 * GET /api/sessions/:id — 获取会话详情 * GET /api/sessions/:id/messages — 获取会话消息 + * GET /api/sessions/:id/trace — 获取会话级模型调用 trace * GET /api/sessions/:id/turn-checkpoints — 获取按轮次保留的 checkpoint 预览 * GET /api/sessions/:id/turn-checkpoints/diff — 获取绑定到指定 checkpoint 的 diff * POST /api/sessions — 创建新会话 @@ -39,6 +40,7 @@ import { SessionBranchingError, } from '../../utils/sessionBranching.js' import { registerFilesystemAccessRoot } from '../services/filesystemAccessRoots.js' +import { traceCaptureService } from '../services/traceCaptureService.js' const workspaceService = new WorkspaceService( async (sessionId) => ( @@ -110,6 +112,16 @@ export async function handleSessionsApi( return await getSessionMessages(sessionId) } + if (subResource === 'trace') { + if (req.method !== 'GET') { + return Response.json( + { error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` }, + { status: 405 } + ) + } + return await getSessionTrace(sessionId) + } + if (subResource === 'git-info') { if (req.method !== 'GET') { return Response.json( @@ -250,6 +262,24 @@ async function getSessionMessages(sessionId: string): Promise { return Response.json({ messages, taskNotifications }) } +async function getSessionTrace(sessionId: string): Promise { + const [trace, session] = await Promise.all([ + traceCaptureService.getSessionTrace(sessionId), + sessionService.getSession(sessionId).catch(() => null), + ]) + return Response.json({ + ...trace, + session: session + ? { + id: session.id, + title: session.title, + projectPath: session.projectPath, + workDir: session.workDir, + } + : null, + }) +} + async function handleSessionWorkspaceRoute( sessionId: string, url: URL, diff --git a/src/server/api/traces.ts b/src/server/api/traces.ts new file mode 100644 index 00000000..7de59d9f --- /dev/null +++ b/src/server/api/traces.ts @@ -0,0 +1,182 @@ +import { ApiError, errorResponse } from '../middleware/errorHandler.js' +import { sessionService } from '../services/sessionService.js' +import { + readTraceCaptureSettings, + traceCaptureService, + updateTraceCaptureSettings, + type TraceSessionFileItem, + type TraceSessionListItem, +} from '../services/traceCaptureService.js' + +export type TraceSessionListApiItem = TraceSessionListItem & { + session: { + id: string + title: string + projectPath: string + workDir: string + } | null +} + +function methodNotAllowed(method: string, route: string): ApiError { + return new ApiError(405, `Method ${method} not allowed on ${route}`, 'METHOD_NOT_ALLOWED') +} + +async function parseJsonBody(req: Request): Promise> { + try { + const body = await req.json() + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw ApiError.badRequest('Invalid JSON body') + } + return body as Record + } catch (error) { + if (error instanceof ApiError) throw error + throw ApiError.badRequest('Invalid JSON body') + } +} + +export async function handleTracesApi( + req: Request, + url: URL, + segments: string[], +): Promise { + try { + const sub = segments[2] + + switch (sub) { + case undefined: + if (req.method !== 'GET') throw methodNotAllowed(req.method, '/api/traces') + return await listTraces(url) + + case 'settings': + if (req.method === 'GET') { + return Response.json(await readTraceCaptureSettings()) + } + if (req.method === 'PUT') { + const body = await parseJsonBody(req) + const input: { enabled?: boolean } = {} + if (typeof body.enabled === 'boolean') input.enabled = body.enabled + return Response.json(await updateTraceCaptureSettings(input)) + } + throw methodNotAllowed(req.method, '/api/traces/settings') + + default: + throw ApiError.notFound(`Unknown traces endpoint: ${sub}`) + } + } catch (error) { + return errorResponse(error) + } +} + +async function listTraces(url: URL): Promise { + const limit = parseTraceListLimit(url.searchParams.get('limit')) + const offset = parseTraceListOffset(url.searchParams.get('offset')) + const query = url.searchParams.get('q')?.trim() || '' + + if (query) { + return listSearchedTraces(query, limit, offset) + } + + const result = await traceCaptureService.listSessionTraces({ limit, offset }) + const decorated = await Promise.all(result.traces.map(decorateTraceListItem)) + + return Response.json({ + ...result, + traces: decorated, + }) +} + +async function listSearchedTraces(query: string, limit: number, offset: number): Promise { + const fileResult = await traceCaptureService.listSessionTraceFiles() + const candidates = await Promise.all(fileResult.files.map(decorateTraceFileCandidate)) + const filtered = candidates.filter((item) => traceListItemMatchesQuery(item, query)) + const pageCandidates = filtered.slice(offset, offset + limit) + const pageSessionIds = pageCandidates.map((item) => item.sessionId) + if (pageSessionIds.length === 0) { + return Response.json({ + traces: [], + total: filtered.length, + storageDir: fileResult.storageDir, + settings: fileResult.settings, + }) + } + + const pageResult = await traceCaptureService.listSessionTraces({ + sessionIds: pageSessionIds, + limit: pageSessionIds.length, + offset: 0, + }) + const itemsBySessionId = new Map(pageResult.traces.map((item) => [item.sessionId, item])) + const traces = pageCandidates.flatMap((candidate) => { + const trace = itemsBySessionId.get(candidate.sessionId) + if (!trace) return [] + return [{ + ...trace, + session: candidate.session, + }] + }) + + return Response.json({ + ...pageResult, + total: filtered.length, + storageDir: fileResult.storageDir, + settings: fileResult.settings, + traces, + }) +} + +async function decorateTraceListItem(item: TraceSessionListItem): Promise { + const session = await sessionService.getSession(item.sessionId).catch(() => null) + return { + ...item, + session: session + ? { + id: session.id, + title: session.title, + projectPath: session.projectPath, + workDir: session.workDir, + } + : null, + } +} + +async function decorateTraceFileCandidate(item: TraceSessionFileItem): Promise> { + const session = await sessionService.getSession(item.sessionId).catch(() => null) + return { + sessionId: item.sessionId, + session: session + ? { + id: session.id, + title: session.title, + projectPath: session.projectPath, + workDir: session.workDir, + } + : null, + } +} + +function traceListItemMatchesQuery(item: Pick, query: string): boolean { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean) + if (terms.length === 0) return true + const haystack = [ + item.sessionId, + item.session?.title, + item.session?.projectPath, + item.session?.workDir, + ] + .filter((value): value is string => Boolean(value)) + .join('\n') + .toLowerCase() + return terms.every((term) => haystack.includes(term)) +} + +function parseTraceListLimit(value: string | null): number { + const parsed = Number.parseInt(value || '50', 10) + if (!Number.isFinite(parsed) || parsed <= 0) return 50 + return Math.min(Math.max(Math.round(parsed), 1), 200) +} + +function parseTraceListOffset(value: string | null): number { + const parsed = Number.parseInt(value || '0', 10) + if (!Number.isFinite(parsed) || parsed <= 0) return 0 + return parsed +} diff --git a/src/server/proxy/handler.ts b/src/server/proxy/handler.ts index 4cf64a10..2de7a74d 100644 --- a/src/server/proxy/handler.ts +++ b/src/server/proxy/handler.ts @@ -22,11 +22,42 @@ import type { AnthropicRequest } from './transform/types.js' import { getProxyFetchOptions } from '../../utils/proxy.js' import { getManualNetworkProxyUrl, loadNetworkSettings } from '../services/networkSettings.js' import { normalizeModelStringForAPI } from '../../utils/model/model.js' +import { + createTraceCallId, + createTraceBodySnapshot, + traceCaptureService, + type TraceBodySnapshot, + type TraceProviderInfo, +} from '../services/traceCaptureService.js' const providerService = new ProviderService() type ProxyFetchOptions = ReturnType type UpstreamRequestInit = RequestInit & ProxyFetchOptions +type ProxyTraceContext = { + sessionId: string + provider: TraceProviderInfo + anthropicRequest: AnthropicRequest +} + +const TRACE_RECORDED_ERROR_MARKER = Symbol('cc-haha-trace-recorded-error') + +function markTraceErrorRecorded(error: unknown): void { + if (error && typeof error === 'object') { + try { + Object.defineProperty(error, TRACE_RECORDED_ERROR_MARKER, { + value: true, + enumerable: false, + }) + } catch { + // Best effort only; proxy error handling must not depend on trace metadata. + } + } +} + +function wasTraceErrorRecorded(error: unknown): boolean { + return Boolean(error && typeof error === 'object' && (error as Record)[TRACE_RECORDED_ERROR_MARKER]) +} function createTimeoutController(timeoutMs: number): { signal: AbortSignal @@ -189,14 +220,26 @@ export async function handleProxyRequest(req: Request, url: URL): Promise {}) + } console.error('[Proxy] Upstream request failed:', err) return Response.json( { @@ -218,6 +261,7 @@ async function handleOpenaiChat( isStream: boolean, aiRequestTimeoutMs: number, proxyUrl: string | undefined, + traceContext: ProxyTraceContext | null, ): Promise { const deepSeekCompatible = shouldUseDeepSeekReasoningCompat(baseUrl) const transformed = anthropicToOpenaiChat(body, { @@ -227,33 +271,90 @@ async function handleOpenaiChat( }) const url = `${baseUrl}/v1/chat/completions` const proxyOptions = getProxyFetchOptions({ proxyUrl }) + const startedAtMs = Date.now() + const startedAt = new Date(startedAtMs).toISOString() + const traceCallId = traceContext + ? startProxyTraceCall({ + context: traceContext, + model: body.model, + upstreamUrl: url, + upstreamRequest: transformed, + startedAt, + }) + : undefined - const upstream = await fetchUpstreamWithTimeout(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), - ...proxyOptions, - }, aiRequestTimeoutMs, isStream) + let upstream: Response + try { + upstream = await fetchUpstreamWithTimeout(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), + ...proxyOptions, + }, aiRequestTimeoutMs, isStream) + } catch (err) { + if (traceContext) { + await recordProxyTrace({ + callId: traceCallId, + context: traceContext, + model: body.model, + upstreamUrl: url, + upstreamRequest: transformed, + startedAt, + startedAtMs, + error: err, + }) + markTraceErrorRecorded(err) + } + throw err + } if (!upstream.ok) { const errText = await upstream.text().catch(() => '') - return Response.json( - { - type: 'error', - error: { - type: 'api_error', - message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`, - }, + const errorBody = { + type: 'error', + error: { + type: 'api_error', + message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`, }, + } + if (traceContext) { + await recordProxyTrace({ + context: traceContext, + callId: traceCallId, + model: body.model, + upstreamUrl: url, + upstreamRequest: transformed, + startedAt, + startedAtMs, + responseStatus: upstream.status, + upstreamResponseBody: errText, + anthropicResponseBody: errorBody, + responseHeaders: upstream.headers, + }) + } + return Response.json( + errorBody, { status: upstream.status }, ) } if (isStream) { if (!upstream.body) { + if (traceContext) { + await recordProxyTrace({ + callId: traceCallId, + context: traceContext, + model: body.model, + upstreamUrl: url, + upstreamRequest: transformed, + startedAt, + startedAtMs, + error: new Error('Upstream returned no body for stream'), + }) + } return Response.json( { type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } }, { status: 502 }, @@ -261,7 +362,24 @@ async function handleOpenaiChat( } const upstreamBody = withStreamIdleTimeout(upstream.body, aiRequestTimeoutMs) const anthropicStream = openaiChatStreamToAnthropic(upstreamBody, body.model) - return new Response(anthropicStream, { + const tracedStream = traceContext + ? captureTraceStream(anthropicStream, async (bodySnapshot, error) => { + await recordProxyTrace({ + callId: traceCallId, + context: traceContext, + model: body.model, + upstreamUrl: url, + upstreamRequest: transformed, + startedAt, + startedAtMs, + responseStatus: 200, + responseBodySnapshot: bodySnapshot, + responseHeaders: upstream.headers, + ...(error ? { error } : {}), + }) + }) + : anthropicStream + return new Response(tracedStream, { status: 200, headers: { 'Content-Type': 'text/event-stream', @@ -274,6 +392,21 @@ async function handleOpenaiChat( // Non-streaming const responseBody = await upstream.json() const anthropicResponse = openaiChatToAnthropic(responseBody, body.model) + if (traceContext) { + await recordProxyTrace({ + callId: traceCallId, + context: traceContext, + model: body.model, + upstreamUrl: url, + upstreamRequest: transformed, + startedAt, + startedAtMs, + responseStatus: 200, + upstreamResponseBody: responseBody, + anthropicResponseBody: anthropicResponse, + responseHeaders: upstream.headers, + }) + } return Response.json(anthropicResponse) } @@ -295,37 +428,95 @@ async function handleOpenaiResponses( isStream: boolean, aiRequestTimeoutMs: number, proxyUrl: string | undefined, + traceContext: ProxyTraceContext | null, ): Promise { const transformed = anthropicToOpenaiResponses(body) const url = `${baseUrl}/v1/responses` const proxyOptions = getProxyFetchOptions({ proxyUrl }) + const startedAtMs = Date.now() + const startedAt = new Date(startedAtMs).toISOString() + const traceCallId = traceContext + ? startProxyTraceCall({ + context: traceContext, + model: body.model, + upstreamUrl: url, + upstreamRequest: transformed, + startedAt, + }) + : undefined - const upstream = await fetchUpstreamWithTimeout(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), - ...proxyOptions, - }, aiRequestTimeoutMs, isStream) + let upstream: Response + try { + upstream = await fetchUpstreamWithTimeout(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), + ...proxyOptions, + }, aiRequestTimeoutMs, isStream) + } catch (err) { + if (traceContext) { + await recordProxyTrace({ + callId: traceCallId, + context: traceContext, + model: body.model, + upstreamUrl: url, + upstreamRequest: transformed, + startedAt, + startedAtMs, + error: err, + }) + markTraceErrorRecorded(err) + } + throw err + } if (!upstream.ok) { const errText = await upstream.text().catch(() => '') - return Response.json( - { - type: 'error', - error: { - type: 'api_error', - message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`, - }, + const errorBody = { + type: 'error', + error: { + type: 'api_error', + message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`, }, + } + if (traceContext) { + await recordProxyTrace({ + context: traceContext, + callId: traceCallId, + model: body.model, + upstreamUrl: url, + upstreamRequest: transformed, + startedAt, + startedAtMs, + responseStatus: upstream.status, + upstreamResponseBody: errText, + anthropicResponseBody: errorBody, + responseHeaders: upstream.headers, + }) + } + return Response.json( + errorBody, { status: upstream.status }, ) } if (isStream) { if (!upstream.body) { + if (traceContext) { + await recordProxyTrace({ + callId: traceCallId, + context: traceContext, + model: body.model, + upstreamUrl: url, + upstreamRequest: transformed, + startedAt, + startedAtMs, + error: new Error('Upstream returned no body for stream'), + }) + } return Response.json( { type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } }, { status: 502 }, @@ -333,7 +524,24 @@ async function handleOpenaiResponses( } const upstreamBody = withStreamIdleTimeout(upstream.body, aiRequestTimeoutMs) const anthropicStream = openaiResponsesStreamToAnthropic(upstreamBody, body.model) - return new Response(anthropicStream, { + const tracedStream = traceContext + ? captureTraceStream(anthropicStream, async (bodySnapshot, error) => { + await recordProxyTrace({ + callId: traceCallId, + context: traceContext, + model: body.model, + upstreamUrl: url, + upstreamRequest: transformed, + startedAt, + startedAtMs, + responseStatus: 200, + responseBodySnapshot: bodySnapshot, + responseHeaders: upstream.headers, + ...(error ? { error } : {}), + }) + }) + : anthropicStream + return new Response(tracedStream, { status: 200, headers: { 'Content-Type': 'text/event-stream', @@ -346,5 +554,242 @@ async function handleOpenaiResponses( // Non-streaming const responseBody = await upstream.json() const anthropicResponse = openaiResponsesToAnthropic(responseBody, body.model) + if (traceContext) { + await recordProxyTrace({ + callId: traceCallId, + context: traceContext, + model: body.model, + upstreamUrl: url, + upstreamRequest: transformed, + startedAt, + startedAtMs, + responseStatus: 200, + upstreamResponseBody: responseBody, + anthropicResponseBody: anthropicResponse, + responseHeaders: upstream.headers, + }) + } return Response.json(anthropicResponse) } + +function buildProxyTraceContext( + req: Request, + config: { id: string; name: string; apiFormat: string }, + anthropicRequest: AnthropicRequest, +): ProxyTraceContext | null { + const sessionId = req.headers.get('x-claude-code-session-id')?.trim() + if (!sessionId) return null + return { + sessionId, + provider: { + id: config.id, + name: config.name, + format: config.apiFormat, + }, + anthropicRequest, + } +} + +function createProxyTraceRequestBody(context: ProxyTraceContext, upstreamRequest: unknown): Record { + return upstreamRequest + ? { + anthropic: context.anthropicRequest, + upstream: upstreamRequest, + } + : { + anthropic: context.anthropicRequest, + } +} + +function startProxyTraceCall({ + context, + model, + upstreamUrl, + upstreamRequest, + startedAt, +}: { + context: ProxyTraceContext + model: string + upstreamUrl: string + upstreamRequest: unknown + startedAt: string +}): string { + const callId = createTraceCallId() + void traceCaptureService.recordCall({ + id: callId, + sessionId: context.sessionId, + source: 'proxy', + provider: context.provider, + model, + status: 'pending', + startedAt, + request: { + method: 'POST', + url: upstreamUrl, + bodySnapshot: createTraceBodySnapshot({ + pending: true, + note: 'proxy request body captured on call completion', + }), + }, + metadata: { + phase: 'upstream_fetch_started', + }, + }) + void traceCaptureService.recordEvent({ + sessionId: context.sessionId, + callId, + source: 'proxy', + provider: context.provider, + model, + timestamp: startedAt, + phase: 'upstream_fetch_started', + severity: 'info', + title: 'Upstream fetch started', + metadata: { + url: upstreamUrl, + }, + }) + return callId +} + +async function recordProxyTrace({ + callId, + context, + model, + upstreamUrl, + upstreamRequest, + startedAt, + startedAtMs, + responseStatus, + upstreamResponseBody, + anthropicResponseBody, + responseBodySnapshot, + responseHeaders, + error, +}: { + callId?: string + context: ProxyTraceContext + model: string + upstreamUrl: string + upstreamRequest: unknown + startedAt: string + startedAtMs: number + responseStatus?: number + upstreamResponseBody?: unknown + anthropicResponseBody?: unknown + responseBodySnapshot?: TraceBodySnapshot + responseHeaders?: Headers + error?: unknown +}): Promise { + const completedAt = new Date().toISOString() + const requestBody = createProxyTraceRequestBody(context, upstreamRequest) + const responseBody = anthropicResponseBody === undefined && upstreamResponseBody === undefined + ? undefined + : { + ...(upstreamResponseBody !== undefined ? { upstream: upstreamResponseBody } : {}), + ...(anthropicResponseBody !== undefined ? { anthropic: anthropicResponseBody } : {}), + } + + await traceCaptureService.recordCall({ + ...(callId ? { id: callId } : {}), + sessionId: context.sessionId, + source: 'proxy', + provider: context.provider, + model, + startedAt, + completedAt, + durationMs: Date.now() - startedAtMs, + request: { + method: 'POST', + url: upstreamUrl, + body: requestBody, + }, + ...(responseStatus !== undefined + ? { + response: { + status: responseStatus, + headers: responseHeaders, + ...(responseBodySnapshot ? { bodySnapshot: responseBodySnapshot } : { body: responseBody }), + }, + } + : {}), + ...(error ? { error } : {}), + metadata: { + phase: error ? 'upstream_fetch_failed' : 'upstream_fetch_completed', + }, + }) + await traceCaptureService.recordEvent({ + sessionId: context.sessionId, + ...(callId ? { callId } : {}), + source: 'proxy', + provider: context.provider, + model, + timestamp: completedAt, + phase: error ? 'upstream_fetch_failed' : 'upstream_fetch_completed', + severity: error ? 'error' : responseStatus !== undefined && responseStatus >= 400 ? 'warning' : 'info', + title: error ? 'Upstream fetch failed' : 'Upstream fetch completed', + message: error instanceof Error ? error.message : error ? String(error) : undefined, + metadata: { + status: responseStatus, + url: upstreamUrl, + }, + }) +} + +function captureTraceStream( + stream: ReadableStream, + onComplete: (snapshot: TraceBodySnapshot, error?: unknown) => Promise, +): ReadableStream { + const decoder = new TextDecoder() + let captured = '' + let bytes = 0 + let truncated = false + let finalized = false + let reader: ReadableStreamDefaultReader | null = null + + const captureChunk = (chunk: Uint8Array) => { + bytes += chunk.byteLength + if (bytes <= 256 * 1024) { + captured += decoder.decode(chunk, { stream: true }) + } else { + truncated = true + } + } + + const finalize = async (error?: unknown) => { + if (finalized) return + finalized = true + captured += decoder.decode() + const snapshot = createTraceBodySnapshot(captured, { alreadyTruncated: truncated }) + await onComplete(snapshot, error).catch(() => {}) + } + + return new ReadableStream({ + async start(controller) { + reader = stream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captureChunk(value) + controller.enqueue(value) + } + await finalize() + controller.close() + } catch (err) { + await finalize(err) + controller.error(err) + } finally { + reader?.releaseLock() + reader = null + } + }, + async cancel(reason) { + const error = reason instanceof Error + ? reason + : new Error(reason ? `Stream cancelled: ${String(reason)}` : 'Stream cancelled') + await finalize(error) + await reader?.cancel(reason).catch(() => undefined) + }, + }) +} diff --git a/src/server/router.ts b/src/server/router.ts index 7d0473e4..8936b7e5 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -27,6 +27,7 @@ import { handleActivityStatsApi } from './api/activityStats.js' import { handleOpenTargetsApi } from './api/open-targets.js' import { handleMemoryApi } from './api/memory.js' import { handleDesktopUiApi } from './api/desktop-ui.js' +import { handleTracesApi } from './api/traces.js' export async function handleApiRequest(req: Request, url: URL): Promise { const path = url.pathname @@ -119,6 +120,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise null) if (!provider) return null return { + id: provider.id, + name: provider.name, baseUrl: provider.baseUrl, apiKey: provider.apiKey, apiFormat: provider.apiFormat ?? 'anthropic', diff --git a/src/server/services/traceCaptureService.ts b/src/server/services/traceCaptureService.ts new file mode 100644 index 00000000..8820d823 --- /dev/null +++ b/src/server/services/traceCaptureService.ts @@ -0,0 +1,29 @@ +export { + clearTraceCaptureStateForTests, + createTraceCallId, + createTraceBodySnapshot, + getTraceStorageDir, + isTraceCaptureEnabled, + readTraceCaptureSettings, + readResponseTraceSnapshot, + shouldCaptureApiTrace, + traceCaptureService, + updateTraceCaptureSettings, +} from '../../services/api/traceCapture.js' +export type { + RecordTraceCallInput, + RecordTraceEventInput, + TraceBodySnapshot, + TraceCaptureSettings, + TraceCallStatus, + TraceCallRecord, + TraceEventRecord, + TraceEventSeverity, + TraceProviderInfo, + TraceSession, + TraceSessionFileItem, + TraceSessionFileList, + TraceSessionList, + TraceSessionListItem, + TraceSessionSummary, +} from '../../services/api/traceCapture.js' diff --git a/src/services/api/dumpPrompts.ts b/src/services/api/dumpPrompts.ts index a3a0e2ff..1781b46c 100644 --- a/src/services/api/dumpPrompts.ts +++ b/src/services/api/dumpPrompts.ts @@ -5,6 +5,16 @@ import { dirname, join } from 'path' import { getSessionId } from 'src/bootstrap/state.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' +import { + createTraceCallId, + createTraceBodySnapshot, + readResponseTraceSnapshot, + shouldCaptureApiTrace, + traceCaptureService, +} from './traceCapture.js' +import type { TraceBodySnapshot, TraceProviderInfo } from './traceCapture.js' + +const TRACE_SESSION_HEADER = 'x-claude-code-session-id' function hashString(str: string): string { return createHash('sha256').update(str).digest('hex') @@ -45,6 +55,35 @@ export function clearAllDumpState(): void { dumpState.clear() } +function getRequestUrl(input: RequestInfo | URL): string { + return input instanceof Request ? input.url : String(input) +} + +function isLocalProviderProxyUrl(value: string): boolean { + try { + const url = new URL(value) + return ( + (url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '::1') && + url.pathname.startsWith('/proxy/') + ) + } catch { + return false + } +} + +function getTraceProviderFromEnv(): TraceProviderInfo | undefined { + const id = process.env.CC_HAHA_TRACE_PROVIDER_ID?.trim() + const name = process.env.CC_HAHA_TRACE_PROVIDER_NAME?.trim() + const format = process.env.CC_HAHA_TRACE_PROVIDER_FORMAT?.trim() + if (!id && !name && !format) return undefined + + return { + id: id || null, + name: name || id || 'Provider', + format: format || 'anthropic', + } +} + export function addApiRequestToCache(requestData: unknown): void { if (process.env.USER_TYPE !== 'ant') return cachedApiRequests.push({ @@ -143,10 +182,28 @@ function dumpRequest( } } +function createRequestPendingSnapshot(body: unknown): TraceBodySnapshot { + if (typeof body === 'string') { + return createTraceBodySnapshot(body.slice(0, 4096), { + alreadyTruncated: body.length > 4096, + }) + } + return createTraceBodySnapshot({ + pending: true, + note: 'request body captured on call completion', + }) +} + export function createDumpPromptsFetch( agentIdOrSessionId: string, + options?: { + traceSessionId?: string + querySource?: string + }, ): ClientOptions['fetch'] { const filePath = getDumpPromptsPath(agentIdOrSessionId) + const traceEnabled = shouldCaptureApiTrace() + const traceSessionId = options?.traceSessionId ?? agentIdOrSessionId return async (input: RequestInfo | URL, init?: RequestInit) => { const state = dumpState.get(agentIdOrSessionId) ?? { @@ -158,17 +215,212 @@ export function createDumpPromptsFetch( dumpState.set(agentIdOrSessionId, state) let timestamp: string | undefined + let traceStartedAtMs = 0 + let traceRequestBody: unknown if (init?.method === 'POST' && init.body) { timestamp = new Date().toISOString() + traceStartedAtMs = Date.now() + traceRequestBody = init.body // Parsing + stringifying the request (system prompt + tool schemas = MBs) // takes hundreds of ms. Defer so it doesn't block the actual API call — // this is debug tooling for /issue, not on the critical path. setImmediate(dumpRequest, init.body as string, timestamp, state, filePath) } + const requestUrl = getRequestUrl(input) + const isProviderProxyTrace = traceEnabled && isLocalProviderProxyUrl(requestUrl) + const traceProvider = getTraceProviderFromEnv() + const requestInit = isProviderProxyTrace + ? { + ...init, + headers: (() => { + const headers = new Headers(init?.headers) + if (!headers.has(TRACE_SESSION_HEADER)) { + headers.set(TRACE_SESSION_HEADER, traceSessionId) + } + return headers + })(), + } + : init + + const traceModel = extractModelFromRequestBody(traceRequestBody) + const traceCallId = timestamp && traceEnabled && !isProviderProxyTrace + ? createTraceCallId() + : undefined + + if (timestamp && traceCallId) { + void traceCaptureService.recordCall({ + id: traceCallId, + sessionId: traceSessionId, + source: 'anthropic', + querySource: options?.querySource, + provider: traceProvider, + model: traceModel, + status: 'pending', + startedAt: timestamp, + request: { + method: init?.method ?? 'POST', + url: requestUrl, + headers: requestInit?.headers as Headers | Record | undefined, + bodySnapshot: createRequestPendingSnapshot(traceRequestBody), + }, + metadata: { + phase: 'api_call_started', + }, + }) + void traceCaptureService.recordEvent({ + sessionId: traceSessionId, + callId: traceCallId, + source: 'anthropic', + provider: traceProvider, + model: traceModel, + timestamp, + phase: 'api_call_started', + severity: 'info', + title: 'API call started', + metadata: { + url: requestUrl, + }, + }) + } + // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins - const response = await globalThis.fetch(input, init) + let response: Response + try { + response = await globalThis.fetch(input, requestInit) + } catch (err) { + if (timestamp && traceCallId) { + const completedAt = new Date().toISOString() + void traceCaptureService.recordCall({ + id: traceCallId, + sessionId: traceSessionId, + source: 'anthropic', + querySource: options?.querySource, + provider: traceProvider, + model: traceModel, + status: 'error', + startedAt: timestamp, + completedAt, + durationMs: Date.now() - traceStartedAtMs, + request: { + method: init?.method ?? 'POST', + url: requestUrl, + headers: requestInit?.headers as Headers | Record | undefined, + bodySnapshot: createRequestPendingSnapshot(traceRequestBody), + }, + error: err, + metadata: { + phase: 'api_call_failed', + }, + }) + void traceCaptureService.recordEvent({ + sessionId: traceSessionId, + callId: traceCallId, + source: 'anthropic', + provider: traceProvider, + model: traceModel, + timestamp: completedAt, + phase: 'api_call_failed', + severity: 'error', + title: 'API call failed', + message: err instanceof Error ? err.message : String(err), + metadata: { + url: requestUrl, + }, + }) + } + throw err + } + + if (timestamp && traceCallId) { + const cloned = response.clone() + void (async () => { + try { + const bodySnapshot = await readResponseTraceSnapshot(cloned) + await traceCaptureService.recordCall({ + id: traceCallId, + sessionId: traceSessionId, + source: 'anthropic', + querySource: options?.querySource, + provider: traceProvider, + model: traceModel, + startedAt: timestamp, + completedAt: new Date().toISOString(), + durationMs: Date.now() - traceStartedAtMs, + request: { + method: init?.method ?? 'POST', + url: requestUrl, + headers: requestInit?.headers as Headers | Record | undefined, + body: traceRequestBody, + }, + response: { + status: response.status, + headers: response.headers, + bodySnapshot, + }, + metadata: { + phase: 'api_call_completed', + }, + }) + await traceCaptureService.recordEvent({ + sessionId: traceSessionId, + callId: traceCallId, + source: 'anthropic', + provider: traceProvider, + model: traceModel, + phase: 'api_call_completed', + severity: response.ok ? 'info' : 'warning', + title: 'API call completed', + metadata: { + status: response.status, + url: requestUrl, + }, + }) + } catch (err) { + await traceCaptureService.recordEvent({ + sessionId: traceSessionId, + callId: traceCallId, + source: 'anthropic', + provider: traceProvider, + model: traceModel, + phase: 'response_capture_failed', + severity: 'warning', + title: 'Response capture failed', + message: err instanceof Error ? err.message : String(err), + metadata: { + status: response.status, + url: requestUrl, + }, + }) + await traceCaptureService.recordCall({ + id: traceCallId, + sessionId: traceSessionId, + source: 'anthropic', + querySource: options?.querySource, + provider: traceProvider, + model: traceModel, + startedAt: timestamp, + completedAt: new Date().toISOString(), + durationMs: Date.now() - traceStartedAtMs, + request: { + method: init?.method ?? 'POST', + url: requestUrl, + headers: requestInit?.headers as Headers | Record | undefined, + body: traceRequestBody, + }, + response: { + status: response.status, + headers: response.headers, + }, + metadata: { + phase: 'api_call_completed', + responseCaptureFailed: true, + }, + }) + } + })() + } // Save response async if (timestamp && response.ok && process.env.USER_TYPE === 'ant') { @@ -224,3 +476,15 @@ export function createDumpPromptsFetch( return response } } + +function extractModelFromRequestBody(body: unknown): string | undefined { + try { + const parsed = typeof body === 'string' ? jsonParse(body) : body + if (parsed && typeof parsed === 'object' && 'model' in parsed && typeof parsed.model === 'string') { + return parsed.model + } + } catch { + // Best effort only. + } + return undefined +} diff --git a/src/services/api/traceCapture.ts b/src/services/api/traceCapture.ts new file mode 100644 index 00000000..59c603be --- /dev/null +++ b/src/services/api/traceCapture.ts @@ -0,0 +1,775 @@ +import { createHash, randomUUID } from 'crypto' +import { existsSync, readFileSync, statSync } from 'fs' +import { promises as fs } 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_SETTINGS_KEY = 'traceCapture' +const SENSITIVE_KEY_RE = /authorization|api[-_]?key|secret|token|cookie|password|bearer/i + +export type TraceCaptureSettings = { + enabled: boolean + storageDir: string +} + +export type TraceProviderInfo = { + id: string | null + name: string + format: string +} + +export type TraceBodySnapshot = { + contentType: 'json' | 'text' | 'empty' + bytes: number + sha256: string + preview: string + truncated: boolean +} + +export type TraceCallStatus = 'pending' | 'ok' | 'error' + +export type TraceEventSeverity = 'info' | 'warning' | 'error' + +export type TraceCallRecord = { + id: string + sessionId: string + source: 'anthropic' | 'proxy' + querySource?: string + provider?: TraceProviderInfo + model?: string + status?: TraceCallStatus + startedAt: string + completedAt?: string + durationMs?: number + metadata?: Record + request: { + method: string + url: string + headers: Record + body: TraceBodySnapshot + } + response?: { + status: number + headers: Record + body: TraceBodySnapshot + } + error?: { + name: string + message: string + code?: string + stack?: string + cause?: string + } +} + +export type TraceEventRecord = { + id: string + sessionId: string + timestamp: string + phase: string + severity: TraceEventSeverity + callId?: string + source?: TraceCallRecord['source'] + provider?: TraceProviderInfo + model?: string + title?: string + message?: string + metadata?: Record +} + +export type TraceSessionSummary = { + apiCalls: number + failedCalls: number + totalDurationMs: number + totalInputTokens: number + totalOutputTokens: number + models: Array<{ model: string; calls: number }> + updatedAt: string | null +} + +export type TraceSession = { + sessionId: string + summary: TraceSessionSummary + calls: TraceCallRecord[] + events: TraceEventRecord[] +} + +export type TraceSessionListItem = { + sessionId: string + summary: TraceSessionSummary + fileSize: number + fileUpdatedAt: string +} + +export type TraceSessionFileItem = { + sessionId: string + fileSize: number + fileUpdatedAt: string +} + +export type TraceSessionFileList = { + files: TraceSessionFileItem[] + total: number + storageDir: string + settings: TraceCaptureSettings +} + +export type TraceSessionList = { + traces: TraceSessionListItem[] + total: number + storageDir: string + settings: TraceCaptureSettings +} + +export type RecordTraceCallInput = { + id?: string + sessionId: string + source: TraceCallRecord['source'] + querySource?: string + provider?: TraceProviderInfo + model?: string + status?: TraceCallStatus + startedAt?: string + completedAt?: string + durationMs?: number + metadata?: Record + request: { + method?: string + url?: string + headers?: Headers | Record | null + body?: unknown + bodySnapshot?: TraceBodySnapshot + } + response?: { + status: number + headers?: Headers | Record | null + body?: unknown + bodySnapshot?: TraceBodySnapshot + } + error?: unknown +} + +export type RecordTraceEventInput = { + id?: string + sessionId: string + timestamp?: string + phase: string + severity?: TraceEventSeverity + callId?: string + source?: TraceCallRecord['source'] + provider?: TraceProviderInfo + model?: string + title?: string + message?: string + metadata?: Record +} + +type TraceFileEntry = + | TraceCallRecord + | { type: 'call'; record: TraceCallRecord } + | { type: 'event'; event: TraceEventRecord } + +const traceWriteQueues = new Map>() + +export function shouldCaptureApiTrace(): boolean { + if (isEnvDefinedFalsy(process.env.CC_HAHA_TRACE_API_CALLS)) return false + if (isEnvTruthy(process.env.CC_HAHA_TRACE_API_CALLS)) return true + return readTraceCaptureSettingsSync().enabled && + process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-desktop' +} + +export function isTraceCaptureEnabled(): boolean { + if (isEnvDefinedFalsy(process.env.CC_HAHA_TRACE_API_CALLS)) return false + if (isEnvTruthy(process.env.CC_HAHA_TRACE_API_CALLS)) return true + return readTraceCaptureSettingsSync().enabled +} + +export function getTraceStorageDir(): string { + return join(getClaudeConfigHomeDir(), 'cc-haha', 'traces') +} + +export function readTraceCaptureSettingsSync(): TraceCaptureSettings { + const settings = readManagedSettingsSync() + return normalizeTraceCaptureSettings(settings) +} + +export async function readTraceCaptureSettings(): Promise { + const settings = await readManagedSettings() + return normalizeTraceCaptureSettings(settings) +} + +export async function updateTraceCaptureSettings(input: Partial>): Promise { + const current = await readManagedSettings() + const traceCapture = current[TRACE_SETTINGS_KEY] + const previous = traceCapture && typeof traceCapture === 'object' && !Array.isArray(traceCapture) + ? traceCapture as Record + : {} + const nextTraceCapture = { + ...previous, + ...(typeof input.enabled === 'boolean' ? { enabled: input.enabled } : {}), + } + const nextSettings = { + ...current, + [TRACE_SETTINGS_KEY]: nextTraceCapture, + } + await writeManagedSettings(nextSettings) + return normalizeTraceCaptureSettings(nextSettings) +} + +export function createTraceBodySnapshot( + body: unknown, + options?: { maxPreviewChars?: number; alreadyTruncated?: boolean }, +): TraceBodySnapshot { + const maxPreviewChars = options?.maxPreviewChars ?? TRACE_PREVIEW_CHARS + const { serialized, contentType } = serializeTraceBody(body) + const bytes = Buffer.byteLength(serialized) + const preview = serialized.length > maxPreviewChars + ? serialized.slice(0, maxPreviewChars) + : serialized + + return { + contentType, + bytes, + sha256: createHash('sha256').update(serialized).digest('hex'), + preview, + truncated: Boolean(options?.alreadyTruncated) || serialized.length > maxPreviewChars, + } +} + +export function clearTraceCaptureStateForTests(): void { + traceWriteQueues.clear() +} + +export function createTraceCallId(): string { + return randomUUID() +} + +class TraceCaptureService { + async recordCall(input: RecordTraceCallInput): Promise { + if (!input.sessionId.trim()) return null + if (!isTraceCaptureEnabled()) return null + + const startedAt = input.startedAt ?? new Date().toISOString() + const completedAt = input.completedAt + const record: TraceCallRecord = { + id: input.id ?? createTraceCallId(), + sessionId: input.sessionId, + source: input.source, + ...(input.querySource ? { querySource: input.querySource } : {}), + ...(input.provider ? { provider: input.provider } : {}), + ...(input.model ? { model: input.model } : {}), + status: input.status ?? inferCallStatus(input), + startedAt, + ...(completedAt ? { completedAt } : {}), + ...(typeof input.durationMs === 'number' ? { durationMs: input.durationMs } : {}), + ...(input.metadata ? { metadata: sanitizeMetadata(input.metadata) } : {}), + request: { + method: input.request.method ?? 'POST', + url: sanitizeUrl(input.request.url ?? ''), + headers: sanitizeHeaders(input.request.headers), + body: input.request.bodySnapshot ?? createTraceBodySnapshot(input.request.body ?? null), + }, + ...(input.response + ? { + response: { + status: input.response.status, + headers: sanitizeHeaders(input.response.headers), + body: input.response.bodySnapshot ?? createTraceBodySnapshot(input.response.body ?? null), + }, + } + : {}), + ...(input.error ? { error: normalizeTraceError(input.error) } : {}), + } + + await appendTraceEntry(record.sessionId, { type: 'call', record }) + return record + } + + async recordEvent(input: RecordTraceEventInput): Promise { + if (!input.sessionId.trim()) return null + if (!isTraceCaptureEnabled()) return null + + const event: TraceEventRecord = { + id: input.id ?? randomUUID(), + sessionId: input.sessionId, + timestamp: input.timestamp ?? new Date().toISOString(), + phase: input.phase, + severity: input.severity ?? 'info', + ...(input.callId ? { callId: input.callId } : {}), + ...(input.source ? { source: input.source } : {}), + ...(input.provider ? { provider: input.provider } : {}), + ...(input.model ? { model: input.model } : {}), + ...(input.title ? { title: input.title } : {}), + ...(input.message ? { message: redactSecretsInText(input.message) } : {}), + ...(input.metadata ? { metadata: sanitizeMetadata(input.metadata) } : {}), + } + + await appendTraceEntry(event.sessionId, { type: 'event', event }) + return event + } + + async getSessionTrace(sessionId: string): Promise { + const { calls, events } = await readTraceEntries(sessionId) + return { + sessionId, + summary: summarizeCalls(calls), + calls, + events, + } + } + + async listSessionTraces(options?: { + limit?: number + offset?: number + query?: string + all?: boolean + sessionIds?: string[] + }): Promise { + const storageDir = getTraceStorageDir() + const settings = await readTraceCaptureSettings() + const all = options?.all === true + const limit = all ? Number.POSITIVE_INFINITY : clampListLimit(options?.limit ?? 50) + const offset = all ? 0 : Math.max(0, options?.offset ?? 0) + const query = options?.query?.trim().toLowerCase() ?? '' + const sessionIdFilter = options?.sessionIds?.length + ? new Set(options.sessionIds.map((sessionId) => sanitizeTraceFileName(sessionId))) + : null + const files = (await listTraceFiles(storageDir)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + const filteredFiles = sessionIdFilter + ? files.filter((file) => sessionIdFilter.has(file.name.replace(/\.jsonl$/, ''))) + : files + const matchingFiles = query + ? filteredFiles.filter((file) => file.name.replace(/\.jsonl$/, '').toLowerCase().includes(query)) + : filteredFiles + const pageFiles = all ? matchingFiles : matchingFiles.slice(offset, offset + limit) + const items: TraceSessionListItem[] = [] + + for (const file of pageFiles) { + const sessionId = file.name.replace(/\.jsonl$/, '') + const trace = await this.getSessionTrace(sessionId) + const updatedAt = trace.summary.updatedAt ?? file.updatedAt + items.push({ + sessionId: trace.sessionId || sessionId, + summary: trace.summary.updatedAt + ? trace.summary + : { ...trace.summary, updatedAt }, + fileSize: file.size, + fileUpdatedAt: file.updatedAt, + }) + } + + items.sort((a, b) => { + const aTime = a.summary.updatedAt ?? a.fileUpdatedAt + const bTime = b.summary.updatedAt ?? b.fileUpdatedAt + return bTime.localeCompare(aTime) + }) + + return { + traces: items, + total: matchingFiles.length, + storageDir, + settings, + } + } + + async listSessionTraceFiles(): Promise { + const storageDir = getTraceStorageDir() + const settings = await readTraceCaptureSettings() + const files = (await listTraceFiles(storageDir)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + + return { + files: files.map((file) => ({ + sessionId: file.name.replace(/\.jsonl$/, ''), + fileSize: file.size, + fileUpdatedAt: file.updatedAt, + })), + total: files.length, + storageDir, + settings, + } + } +} + +export const traceCaptureService = new TraceCaptureService() + +export async function readResponseTraceSnapshot(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? '' + if (!response.body) { + return createTraceBodySnapshot(null) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let text = '' + let bytes = 0 + let truncated = false + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + bytes += value.byteLength + if (text.length < TRACE_STREAM_CAPTURE_BYTES) { + text += decoder.decode(value, { stream: true }) + } else { + truncated = true + } + if (bytes > TRACE_STREAM_CAPTURE_BYTES) { + truncated = true + } + } + text += decoder.decode() + } finally { + reader.releaseLock() + } + + return createTraceBodySnapshot( + contentType.includes('application/json') ? parseJsonOrText(text) : text, + { alreadyTruncated: truncated }, + ) +} + +function serializeTraceBody(body: unknown): { serialized: string; contentType: TraceBodySnapshot['contentType'] } { + if (body === null || body === undefined) { + return { serialized: '', contentType: 'empty' } + } + + if (typeof body === 'string') { + const parsed = parseJsonOrText(body) + if (typeof parsed !== 'string') { + return { + serialized: JSON.stringify(redactSensitiveValue(parsed), null, 2), + contentType: 'json', + } + } + return { serialized: redactSecretsInText(body), contentType: 'text' } + } + + try { + return { + serialized: JSON.stringify(redactSensitiveValue(body), null, 2), + contentType: 'json', + } + } catch { + return { serialized: redactSecretsInText(String(body)), contentType: 'text' } + } +} + +function parseJsonOrText(text: string): unknown { + const trimmed = text.trim() + if (!trimmed) return text + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return text + try { + return JSON.parse(trimmed) + } catch { + return text + } +} + +function redactSensitiveValue(value: unknown, key = ''): unknown { + if (SENSITIVE_KEY_RE.test(key)) return '[redacted]' + if (Array.isArray(value)) return value.map((entry) => redactSensitiveValue(entry)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + redactSensitiveValue(entryValue, entryKey), + ]), + ) + } + if (typeof value === 'string') return redactSecretsInText(value) + return value +} + +function redactSecretsInText(value: string): string { + return value + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]') + .replace(/\bsk-[A-Za-z0-9._-]{8,}\b/g, 'sk-[redacted]') +} + +function sanitizeHeaders(headers: Headers | Record | null | undefined): Record { + if (!headers) return {} + const entries = headers instanceof Headers + ? Array.from(headers.entries()) + : Object.entries(headers) + + return Object.fromEntries( + entries.map(([key, value]) => [ + key, + SENSITIVE_KEY_RE.test(key) ? '[redacted]' : redactSecretsInText(String(value)), + ]), + ) +} + +function sanitizeUrl(url: string): string { + if (!url) return '' + try { + const parsed = new URL(url) + for (const key of Array.from(parsed.searchParams.keys())) { + if (SENSITIVE_KEY_RE.test(key)) { + parsed.searchParams.set(key, '[redacted]') + } + } + return parsed.toString() + } catch { + return url + } +} + +function sanitizeMetadata(metadata: Record): Record { + return redactSensitiveValue(metadata) as Record +} + +function inferCallStatus(input: RecordTraceCallInput): TraceCallStatus { + if (input.status) return input.status + if (input.error) return 'error' + if (!input.response && !input.completedAt) return 'pending' + if ((input.response?.status ?? 200) >= 400) return 'error' + return 'ok' +} + +function normalizeTraceError(error: unknown): TraceCallRecord['error'] { + if (error instanceof Error) { + const code = typeof (error as NodeJS.ErrnoException).code === 'string' + ? (error as NodeJS.ErrnoException).code + : undefined + const cause = 'cause' in error && error.cause !== undefined + ? redactSecretsInText(String(error.cause)) + : undefined + return { + name: error.name, + message: redactSecretsInText(error.message), + ...(code ? { code } : {}), + ...(error.stack ? { stack: redactSecretsInText(error.stack) } : {}), + ...(cause ? { cause } : {}), + } + } + return { name: typeof error, message: redactSecretsInText(String(error)) } +} + +async function appendTraceEntry(sessionId: string, entry: TraceFileEntry): Promise { + const previous = traceWriteQueues.get(sessionId) ?? Promise.resolve() + const next = previous + .catch(() => {}) + .then(async () => { + const filePath = getTraceFilePath(sessionId) + await fs.mkdir(dirname(filePath), { recursive: true }) + await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, 'utf-8') + }) + traceWriteQueues.set(sessionId, next) + try { + await next + } finally { + if (traceWriteQueues.get(sessionId) === next) { + traceWriteQueues.delete(sessionId) + } + } +} + +async function listTraceFiles(storageDir: string): Promise> { + let entries: string[] = [] + try { + entries = await fs.readdir(storageDir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } + + const files = await Promise.all(entries + .filter((name) => name.endsWith('.jsonl')) + .map(async (name) => { + const stat = await fs.stat(join(storageDir, name)).catch(() => null) + if (!stat?.isFile()) return null + return { + name, + size: stat.size, + updatedAt: stat.mtime.toISOString(), + } + })) + return files.filter((file): file is { name: string; size: number; updatedAt: string } => file !== null) +} + +async function readTraceEntries(sessionId: string): Promise<{ calls: TraceCallRecord[]; events: TraceEventRecord[] }> { + const filePath = getTraceFilePath(sessionId) + let raw = '' + try { + raw = await fs.readFile(filePath, 'utf-8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { calls: [], events: [] } + throw error + } + + const callsById = new Map() + const events: TraceEventRecord[] = [] + + for (const entry of raw + .split('\n') + .filter((line) => line.trim().length > 0) + .flatMap((line) => { + try { + return [JSON.parse(line) as TraceFileEntry] + } catch { + return [] + } + })) { + if (!entry || typeof entry !== 'object') continue + + if ('type' in entry && entry.type === 'event') { + if (isTraceEventRecordLike(entry.event)) { + events.push(entry.event) + } + continue + } + + const call = 'type' in entry && entry.type === 'call' ? entry.record : entry + if (isTraceCallRecordLike(call)) { + callsById.set(call.id, call) + } + } + + return { + calls: Array.from(callsById.values()).sort((a, b) => a.startedAt.localeCompare(b.startedAt)), + events: events.sort((a, b) => a.timestamp.localeCompare(b.timestamp)), + } +} + +function isTraceCallRecordLike(value: unknown): value is TraceCallRecord { + if (!value || typeof value !== 'object') return false + const record = value as Partial + return typeof record.id === 'string' + && typeof record.sessionId === 'string' + && typeof record.source === 'string' + && typeof record.startedAt === 'string' + && Boolean(record.request) +} + +function isTraceEventRecordLike(value: unknown): value is TraceEventRecord { + if (!value || typeof value !== 'object') return false + const record = value as Partial + return typeof record.id === 'string' + && typeof record.sessionId === 'string' + && typeof record.timestamp === 'string' + && typeof record.phase === 'string' + && typeof record.severity === 'string' +} + +function summarizeCalls(calls: TraceCallRecord[]): TraceSessionSummary { + const modelCounts = new Map() + let failedCalls = 0 + let totalDurationMs = 0 + let totalInputTokens = 0 + let totalOutputTokens = 0 + let updatedAt: string | null = null + + for (const call of calls) { + 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 + updatedAt = call.completedAt ?? call.startedAt + } + + return { + apiCalls: calls.length, + failedCalls, + totalDurationMs, + totalInputTokens, + totalOutputTokens, + models: Array.from(modelCounts.entries()).map(([model, count]) => ({ model, calls: count })), + updatedAt, + } +} + +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 + : parsed as Record + const input = numberFromUnknown(usage.input_tokens) + numberFromUnknown(usage.prompt_tokens) + const output = numberFromUnknown(usage.output_tokens) + numberFromUnknown(usage.completion_tokens) + return { input, output } +} + +function numberFromUnknown(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +function getTraceFilePath(sessionId: string): string { + return join(getTraceStorageDir(), `${sanitizeTraceFileName(sessionId)}.jsonl`) +} + +function sanitizeTraceFileName(sessionId: string): string { + return sessionId.replace(/[^a-zA-Z0-9._-]/g, '_') +} + +function getManagedSettingsPath(): string { + return join(getClaudeConfigHomeDir(), 'cc-haha', 'settings.json') +} + +function defaultTraceCaptureSettings(): TraceCaptureSettings { + return { + enabled: true, + storageDir: getTraceStorageDir(), + } +} + +function normalizeTraceCaptureSettings(settings: Record): TraceCaptureSettings { + const defaultSettings = defaultTraceCaptureSettings() + const traceCapture = settings[TRACE_SETTINGS_KEY] + if (!traceCapture || typeof traceCapture !== 'object' || Array.isArray(traceCapture)) { + return defaultSettings + } + + return { + ...defaultSettings, + enabled: (traceCapture as Record).enabled !== false, + } +} + +function readManagedSettingsSync(): Record { + const filePath = getManagedSettingsPath() + try { + if (!existsSync(filePath)) return {} + const stat = statSync(filePath) + if (!stat.isFile()) return {} + const parsed = JSON.parse(readFileSync(filePath, 'utf-8')) as unknown + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {} + } catch { + return {} + } +} + +async function readManagedSettings(): Promise> { + try { + const parsed = JSON.parse(await fs.readFile(getManagedSettingsPath(), 'utf-8')) as unknown + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {} + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} + return {} + } +} + +async function writeManagedSettings(settings: Record): Promise { + const filePath = getManagedSettingsPath() + const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomUUID()}` + await fs.mkdir(dirname(filePath), { recursive: true }) + await fs.writeFile(tmpFile, `${JSON.stringify(settings, null, 2)}\n`, 'utf-8') + await fs.rename(tmpFile, filePath) +} + +function clampListLimit(limit: number): number { + if (!Number.isFinite(limit) || limit <= 0) return 50 + return Math.min(Math.max(Math.round(limit), 1), 200) +}