mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
feat(trace): add session trace monitoring (#606)
Tested: - cd desktop && bun run test -- --run src/pages/TraceList.test.tsx - bun test src/server/__tests__/trace-capture.test.ts - bun run check:desktop - bun run check:server Scope-risk: broad
This commit is contained in:
parent
56661c7967
commit
0d45439fbe
@ -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)
|
||||
|
||||
@ -12,6 +12,12 @@ const booleanPayload: Validator = value => typeof value === 'boolean'
|
||||
const hasOnlyKeys = (value: Record<string, unknown>, 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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<string, BrowserWindow>()
|
||||
let isQuitting = false
|
||||
let trayController: TrayController | null = null
|
||||
|
||||
@ -84,6 +85,56 @@ function rendererEntry() {
|
||||
})
|
||||
}
|
||||
|
||||
async function loadRendererEntry(
|
||||
window: BrowserWindow,
|
||||
query?: Record<string, string>,
|
||||
) {
|
||||
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<typeof openDialog>[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)
|
||||
|
||||
@ -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<MessagesResponse>(`/api/sessions/${sessionId}/messages`)
|
||||
},
|
||||
|
||||
getTrace(sessionId: string) {
|
||||
return api.get<TraceSession>(`/api/sessions/${sessionId}/trace`)
|
||||
},
|
||||
|
||||
create(input?: string | CreateSessionRequest) {
|
||||
const body = typeof input === 'string'
|
||||
? (input ? { workDir: input } : {})
|
||||
|
||||
21
desktop/src/api/traces.ts
Normal file
21
desktop/src/api/traces.ts
Normal file
@ -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<TraceSessionList>(`/api/traces${suffix}`)
|
||||
},
|
||||
|
||||
getSettings() {
|
||||
return api.get<TraceCaptureSettings>('/api/traces/settings')
|
||||
},
|
||||
|
||||
updateSettings(settings: Partial<Pick<TraceCaptureSettings, 'enabled'>>) {
|
||||
return api.put<TraceCaptureSettings>('/api/traces/settings', settings)
|
||||
},
|
||||
}
|
||||
@ -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 }) => (
|
||||
<section data-standalone={standalone ? 'true' : 'false'} data-testid="trace-session">
|
||||
trace:{sessionId}
|
||||
</section>
|
||||
),
|
||||
}))
|
||||
|
||||
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(<AppShell />)
|
||||
|
||||
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(<AppShell />)
|
||||
|
||||
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()
|
||||
|
||||
@ -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 (
|
||||
<div className="app-shell-viewport flex overflow-hidden bg-[var(--color-surface)] text-[var(--color-text-primary)]">
|
||||
{traceLaunch.sessionId ? (
|
||||
<TraceSession sessionId={traceLaunch.sessionId} standalone />
|
||||
) : (
|
||||
<TraceList />
|
||||
)}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`app-shell app-shell-viewport flex overflow-hidden bg-[var(--color-surface)]${isMobileShell ? ' app-shell--mobile' : ''}`}>
|
||||
{isMobileShell && effectiveSidebarOpen ? (
|
||||
|
||||
@ -26,6 +26,14 @@ vi.mock('../../pages/TerminalSettings', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../pages/TraceSession', () => ({
|
||||
TraceSession: ({ sessionId }: { sessionId: string }) => <div data-testid="trace-session">trace:{sessionId}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../pages/TraceList', () => ({
|
||||
TraceList: () => <div data-testid="trace-list" />,
|
||||
}))
|
||||
|
||||
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(<ContentRouter />)
|
||||
|
||||
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(<ContentRouter />)
|
||||
|
||||
expect(screen.getByTestId('trace-list')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 = <Settings />
|
||||
} else if (activeTabType === 'scheduled') {
|
||||
page = <ScheduledTasks />
|
||||
} else if (activeTabType === 'trace') {
|
||||
const traceSessionId = tabs.find((t) => t.sessionId === activeTabId)?.traceSessionId
|
||||
page = traceSessionId ? <TraceSession sessionId={traceSessionId} /> : <EmptySession />
|
||||
} else if (activeTabType === 'traces') {
|
||||
page = <TraceList />
|
||||
} else if (activeTabType !== 'terminal') {
|
||||
page = <ActiveSession />
|
||||
}
|
||||
|
||||
@ -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')}
|
||||
</NavItem>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<NavItem
|
||||
active={activeTabId === TRACE_LIST_TAB_ID}
|
||||
collapsed={!expanded}
|
||||
label={t('sidebar.traces')}
|
||||
touchFriendly={isMobile}
|
||||
onClick={() => {
|
||||
useTabStore.getState().openTracesTab(t('sidebar.traces'))
|
||||
closeMobileDrawer()
|
||||
}}
|
||||
icon={<span className="material-symbols-outlined text-[18px]">account_tree</span>}
|
||||
>
|
||||
{t('sidebar.traces')}
|
||||
</NavItem>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -18,10 +18,12 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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': 'ローカルサーバーの起動に失敗しました',
|
||||
|
||||
@ -18,10 +18,12 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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': '로컬 서버를 시작하지 못했습니다',
|
||||
|
||||
@ -18,10 +18,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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': '本地服務啟動失敗',
|
||||
|
||||
@ -18,10 +18,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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': '本地服务启动失败',
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -168,6 +168,9 @@ export type DesktopHost = {
|
||||
open(target: string): Promise<void>
|
||||
openPath(path: string): Promise<void>
|
||||
}
|
||||
trace?: {
|
||||
openWindow(sessionId: string): Promise<void>
|
||||
}
|
||||
dialogs: {
|
||||
open(options?: DialogOpenOptions): Promise<string | string[] | null>
|
||||
save(options?: DialogSaveOptions): Promise<string | null>
|
||||
|
||||
27
desktop/src/lib/traceLaunch.test.ts
Normal file
27
desktop/src/lib/traceLaunch.test.ts
Normal file
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
30
desktop/src/lib/traceLaunch.ts
Normal file
30
desktop/src/lib/traceLaunch.ts
Normal file
@ -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()
|
||||
}
|
||||
185
desktop/src/lib/traceViewModel.test.ts
Normal file
185
desktop/src/lib/traceViewModel.test.ts
Normal file
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
634
desktop/src/lib/traceViewModel.ts
Normal file
634
desktop/src/lib/traceViewModel.ts
Normal file
@ -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<string, TraceSpan>
|
||||
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<TraceSpan, 'childIds'> & { childIds: string[] }
|
||||
|
||||
export function buildTraceViewModel(
|
||||
trace: TraceSessionData,
|
||||
messages: MessageEntry[],
|
||||
): TraceViewModel {
|
||||
const spans = new Map<string, MutableSpan>()
|
||||
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<string, string>()
|
||||
const resultBlocksByToolUseId = new Map<string, ToolResultBlock[]>()
|
||||
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<string, MutableSpan>,
|
||||
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<string, MutableSpan>,
|
||||
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<string, MutableSpan>, span: Omit<TraceSpan, 'childIds'>) {
|
||||
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<string, MutableSpan>, 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<string, unknown>
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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(<ActiveSession />)
|
||||
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'
|
||||
|
||||
|
||||
@ -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() {
|
||||
}
|
||||
>
|
||||
<div className={showRightPanel ? 'min-w-0 flex-1' : 'mx-auto w-full max-w-[860px] min-w-0'}>
|
||||
<h1
|
||||
className={
|
||||
showRightPanel
|
||||
? 'truncate text-[15px] font-bold font-headline leading-tight text-on-surface'
|
||||
: 'text-lg font-bold font-headline text-on-surface leading-tight'
|
||||
}
|
||||
>
|
||||
{session?.title || t('session.untitled')}
|
||||
</h1>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<h1
|
||||
className={
|
||||
showRightPanel
|
||||
? 'min-w-0 flex-1 truncate text-[15px] font-bold font-headline leading-tight text-on-surface'
|
||||
: 'min-w-0 flex-1 text-lg font-bold font-headline text-on-surface leading-tight'
|
||||
}
|
||||
>
|
||||
{session?.title || t('session.untitled')}
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('trace.open')}
|
||||
title={t('trace.open')}
|
||||
onClick={openTrace}
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[8px] border border-[var(--color-border)] px-2.5 text-xs font-semibold text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
|
||||
>
|
||||
<RadioTower size={14} strokeWidth={2} aria-hidden="true" />
|
||||
<span className={showRightPanel ? 'sr-only' : 'hidden sm:inline'}>{t('trace.open')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('trace.openWindow')}
|
||||
title={t('trace.openWindow')}
|
||||
onClick={openTraceWindow}
|
||||
className="inline-flex h-8 w-8 shrink-0 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]"
|
||||
>
|
||||
<ExternalLink size={14} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
showRightPanel
|
||||
|
||||
@ -1456,6 +1456,8 @@ export function GeneralSettings() {
|
||||
setWebSearch,
|
||||
network,
|
||||
setNetwork,
|
||||
traceCapture,
|
||||
setTraceCaptureEnabled,
|
||||
responseLanguage,
|
||||
setResponseLanguage,
|
||||
appMode,
|
||||
@ -2089,6 +2091,34 @@ export function GeneralSettings() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.traceTitle')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.traceDescription')}</p>
|
||||
<label className="flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('settings.general.traceEnabled')}
|
||||
checked={traceCapture.enabled}
|
||||
onChange={(e) => void setTraceCaptureEnabled(e.target.checked)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<SettingsCheckboxMark checked={traceCapture.enabled} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('settings.general.traceEnabled')}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
|
||||
{traceCapture.enabled ? t('settings.general.traceHintOn') : t('settings.general.traceHintOff')}
|
||||
</div>
|
||||
{traceCapture.storageDir && (
|
||||
<div className="mt-2 truncate rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[11px] text-[var(--color-text-secondary)]">
|
||||
{traceCapture.storageDir}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.notificationsTitle')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.notificationsDescription')}</p>
|
||||
|
||||
132
desktop/src/pages/TraceList.test.tsx
Normal file
132
desktop/src/pages/TraceList.test.tsx
Normal file
@ -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(<TraceList />)
|
||||
|
||||
expect(await screen.findByText('Debug stuck agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('/tmp/cc-haha/traces')).toBeInTheDocument()
|
||||
expect(screen.getByText('gpt-5.5 x2')).toBeInTheDocument()
|
||||
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(<TraceList />)
|
||||
|
||||
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(<TraceList />)
|
||||
|
||||
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(<TraceList />)
|
||||
|
||||
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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
335
desktop/src/pages/TraceList.tsx
Normal file
335
desktop/src/pages/TraceList.tsx
Normal file
@ -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<TraceListState>({ 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<string>()
|
||||
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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
|
||||
<header className="shrink-0 border-b border-[var(--color-border)] px-5 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-[var(--color-text-tertiary)]">
|
||||
<Workflow className="h-4 w-4" strokeWidth={1.8} aria-hidden="true" />
|
||||
<span>{t('trace.list.eyebrow')}</span>
|
||||
{state.status === 'ready' && (
|
||||
<span className={`rounded-md border px-1.5 py-0.5 ${
|
||||
state.data.settings.enabled
|
||||
? 'border-[var(--color-success)]/25 bg-[var(--color-success)]/10 text-[var(--color-success)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]'
|
||||
}`}>
|
||||
{state.data.settings.enabled ? t('trace.list.collecting') : t('trace.list.paused')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-[var(--color-text-primary)]">{t('trace.list.title')}</h1>
|
||||
{state.status === 'ready' && (
|
||||
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">
|
||||
{state.data.storageDir}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => openTraceSettings(t)}>
|
||||
{t('trace.list.settings')}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => void load()}>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t('trace.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-4 gap-3">
|
||||
<Metric label={t('trace.list.sessions')} value={state.status === 'ready' ? String(state.data.total) : '-'} />
|
||||
<Metric label={t('trace.apiCalls')} value={String(summary.apiCalls)} />
|
||||
<Metric label={t('trace.failedCalls')} value={String(summary.failedCalls)} tone={summary.failedCalls > 0 ? 'danger' : 'default'} />
|
||||
<Metric label={t('trace.models')} value={String(summary.models)} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="shrink-0 border-b border-[var(--color-border)] px-5 py-3">
|
||||
<div className="flex h-10 max-w-xl items-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 focus-within:border-[var(--color-border-focus)]">
|
||||
<Search className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.8} aria-hidden="true" />
|
||||
<input
|
||||
value={queryInput}
|
||||
onChange={(event) => setQueryInput(event.currentTarget.value)}
|
||||
placeholder={t('trace.list.searchPlaceholder')}
|
||||
className="min-w-0 flex-1 bg-transparent px-2 text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.status === 'loading' && (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
|
||||
)}
|
||||
{state.status === 'error' && (
|
||||
<div className="m-5 rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error)]/5 p-4 text-sm text-[var(--color-error)]">
|
||||
{state.message}
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'ready' && (
|
||||
<TraceRows
|
||||
traces={state.data.traces}
|
||||
total={state.data.total}
|
||||
loadingMore={isLoadingMore}
|
||||
onLoadMore={() => void load({
|
||||
append: true,
|
||||
offset: state.data.traces.length,
|
||||
silent: true,
|
||||
})}
|
||||
onOpenWindow={(sessionId) => {
|
||||
if (host.trace) void host.trace.openWindow(sessionId)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-1 items-center justify-center px-6 text-center">
|
||||
<div className="max-w-sm">
|
||||
<Workflow className="mx-auto h-8 w-8 text-[var(--color-text-tertiary)]" strokeWidth={1.5} aria-hidden="true" />
|
||||
<h2 className="mt-3 text-sm font-semibold text-[var(--color-text-primary)]">{t('trace.list.emptyTitle')}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{t('trace.list.emptyBody')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-[minmax(260px,1.5fr)_120px_90px_120px_minmax(160px,1fr)_96px] border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-2 text-[10px] font-semibold uppercase text-[var(--color-text-tertiary)]">
|
||||
<div>{t('trace.list.session')}</div>
|
||||
<div>{t('trace.apiCalls')}</div>
|
||||
<div>{t('trace.failedCalls')}</div>
|
||||
<div>{t('trace.duration')}</div>
|
||||
<div>{t('trace.models')}</div>
|
||||
<div className="text-right">{t('trace.list.actions')}</div>
|
||||
</div>
|
||||
{traces.map((trace) => (
|
||||
<TraceRow key={trace.sessionId} trace={trace} onOpenWindow={onOpenWindow} />
|
||||
))}
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-5 py-3 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span>{t('trace.list.loadedCount', { shown: traces.length, total })}</span>
|
||||
{traces.length < total && (
|
||||
<Button size="sm" variant="secondary" onClick={onLoadMore} disabled={loadingMore}>
|
||||
{loadingMore ? t('common.loading') : t('trace.list.loadMore')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid grid-cols-[minmax(260px,1.5fr)_120px_90px_120px_minmax(160px,1fr)_96px] items-center gap-0 border-b border-[var(--color-border)] px-5 py-3 text-sm hover:bg-[var(--color-surface-hover)]">
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTrace(trace.sessionId, title, t)}
|
||||
className="block min-w-0 text-left"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{hasError
|
||||
? <AlertTriangle className="h-4 w-4 shrink-0 text-[var(--color-error)]" strokeWidth={1.8} aria-hidden="true" />
|
||||
: <CheckCircle2 className="h-4 w-4 shrink-0 text-[var(--color-success)]" strokeWidth={1.8} aria-hidden="true" />}
|
||||
<span className="truncate font-medium text-[var(--color-text-primary)]">{title}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 gap-2 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span className="truncate font-mono">{trace.sessionId}</span>
|
||||
<span className="shrink-0">{formatUpdatedAt(updatedAt)}</span>
|
||||
</div>
|
||||
{trace.session?.projectPath && (
|
||||
<div className="mt-1 truncate text-[11px] text-[var(--color-text-tertiary)]">{trace.session.projectPath}</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="font-mono text-[var(--color-text-primary)]">{trace.summary.apiCalls}</div>
|
||||
<div className={hasError ? 'font-mono text-[var(--color-error)]' : 'font-mono text-[var(--color-text-primary)]'}>{trace.summary.failedCalls}</div>
|
||||
<div className="font-mono text-[var(--color-text-secondary)]">{formatDuration(trace.summary.totalDurationMs)}</div>
|
||||
<div className="min-w-0 truncate text-xs text-[var(--color-text-secondary)]" title={modelLabel}>{modelLabel}</div>
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTrace(trace.sessionId, title, t)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
|
||||
aria-label={t('trace.open')}
|
||||
title={t('trace.open')}
|
||||
>
|
||||
<Workflow className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenWindow(trace.sessionId)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
|
||||
aria-label={t('trace.openWindow')}
|
||||
title={t('trace.openWindow')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-span-6 mt-2 hidden text-[11px] text-[var(--color-text-tertiary)] md:block">
|
||||
{t('trace.list.fileSize')}: {formatBytes(trace.fileSize)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase text-[var(--color-text-tertiary)]">{label}</div>
|
||||
<div className={`mt-1 truncate font-mono text-lg ${tone === 'danger' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-primary)]'}`}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function openTrace(sessionId: string, title: string, t: ReturnType<typeof useTranslation>) {
|
||||
useTabStore.getState().openTraceTab(sessionId, `${t('trace.title')}: ${title}`)
|
||||
}
|
||||
|
||||
function openTraceSettings(t: ReturnType<typeof useTranslation>) {
|
||||
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()
|
||||
}
|
||||
174
desktop/src/pages/TraceSession.test.tsx
Normal file
174
desktop/src/pages/TraceSession.test.tsx
Normal file
@ -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(<TraceSession sessionId="session-live" pollIntervalMs={20} />)
|
||||
|
||||
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(<TraceSession sessionId="session-live" standalone pollIntervalMs={60_000} />)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Trace API title' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders lifecycle events as trace spans', async () => {
|
||||
vi.mocked(sessionsApi.getTrace).mockResolvedValue({
|
||||
...baseTrace,
|
||||
events: [{
|
||||
id: 'event-failed',
|
||||
sessionId: 'session-live',
|
||||
callId: 'call-1',
|
||||
source: 'anthropic',
|
||||
timestamp: '2026-06-09T10:10:00.100Z',
|
||||
phase: 'api_call_failed',
|
||||
severity: 'error',
|
||||
message: 'network down',
|
||||
}],
|
||||
})
|
||||
|
||||
render(<TraceSession sessionId="session-live" pollIntervalMs={60_000} />)
|
||||
|
||||
expect((await screen.findAllByText('API call failed')).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('network down').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Trace event failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders trace navigation and inspector labels in the active locale', async () => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
vi.mocked(sessionsApi.getTrace).mockResolvedValue(baseTrace)
|
||||
|
||||
render(<TraceSession sessionId="session-live" pollIntervalMs={60_000} />)
|
||||
|
||||
expect(await screen.findByText('运行树')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('搜索 Span')).toBeInTheDocument()
|
||||
expect(screen.getByText('对话线程')).toBeInTheDocument()
|
||||
expect(screen.getByText('诊断')).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: '输入' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Run tree')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('Search spans')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Thread')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Session activity')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /gpt-5\.5/ })[0]!)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '输入' }))
|
||||
expect(screen.getByText('请求正文')).toBeInTheDocument()
|
||||
expect(screen.getByText('请求头')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
1525
desktop/src/pages/TraceSession.tsx
Normal file
1525
desktop/src/pages/TraceSession.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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<void>
|
||||
setUpdateProxy: (settings: UpdateProxySettings) => Promise<void>
|
||||
setNetwork: (settings: NetworkSettings) => Promise<void>
|
||||
setTraceCaptureEnabled: (enabled: boolean) => Promise<void>
|
||||
enableH5Access: () => Promise<string>
|
||||
disableH5Access: () => Promise<void>
|
||||
regenerateH5AccessToken: () => Promise<string>
|
||||
@ -155,6 +159,11 @@ const DEFAULT_OUTPUT_STYLE_OPTIONS: OutputStyleOption[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const DEFAULT_TRACE_CAPTURE_SETTINGS: TraceCaptureSettings = {
|
||||
enabled: true,
|
||||
storageDir: '',
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
permissionMode: 'default',
|
||||
currentModel: null,
|
||||
@ -177,6 +186,7 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((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<SettingsStore>((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<SettingsStore>((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<DesktopTerminalSettings> | undefined,
|
||||
): DesktopTerminalSettings {
|
||||
@ -648,6 +681,14 @@ function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5Ac
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTraceCaptureSettings(): Promise<TraceCaptureSettings> {
|
||||
try {
|
||||
return normalizeTraceCaptureSettings(await tracesApi.getSettings())
|
||||
} catch {
|
||||
return DEFAULT_TRACE_CAPTURE_SETTINGS
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshH5DiagnosticsSilent(
|
||||
set: (partial: Partial<SettingsStore>) => void,
|
||||
): Promise<void> {
|
||||
|
||||
@ -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',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<TabStore>((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<TabStore>((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<TabStore>((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<TabStore>((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,
|
||||
|
||||
111
desktop/src/types/trace.ts
Normal file
111
desktop/src/types/trace.ts
Normal file
@ -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<string, unknown>
|
||||
request: {
|
||||
method: string
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
body: TraceBodySnapshot
|
||||
}
|
||||
response?: {
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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<string, string>
|
||||
|
||||
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<string, string>
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
542
src/server/__tests__/trace-capture.test.ts
Normal file
542
src/server/__tests__/trace-capture.test.ts
Normal file
@ -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<ReturnType<typeof traceCaptureService.getSessionTrace>>) => 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<ReturnType<typeof traceCaptureService.getSessionTrace>> & { 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
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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<Response> {
|
||||
return Response.json({ messages, taskNotifications })
|
||||
}
|
||||
|
||||
async function getSessionTrace(sessionId: string): Promise<Response> {
|
||||
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,
|
||||
|
||||
182
src/server/api/traces.ts
Normal file
182
src/server/api/traces.ts
Normal file
@ -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<Record<string, unknown>> {
|
||||
try {
|
||||
const body = await req.json()
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
return body as Record<string, unknown>
|
||||
} 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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<TraceSessionListApiItem> {
|
||||
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<Pick<TraceSessionListApiItem, 'sessionId' | 'session'>> {
|
||||
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<TraceSessionListApiItem, 'sessionId' | 'session'>, 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
|
||||
}
|
||||
@ -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<typeof getProxyFetchOptions>
|
||||
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<symbol, unknown>)[TRACE_RECORDED_ERROR_MARKER])
|
||||
}
|
||||
|
||||
function createTimeoutController(timeoutMs: number): {
|
||||
signal: AbortSignal
|
||||
@ -189,14 +220,26 @@ export async function handleProxyRequest(req: Request, url: URL): Promise<Respon
|
||||
const baseUrl = config.baseUrl.replace(/\/+$/, '')
|
||||
const networkSettings = await loadNetworkSettings()
|
||||
const proxyUrl = getManualNetworkProxyUrl(networkSettings)
|
||||
const traceContext = buildProxyTraceContext(req, config, body)
|
||||
|
||||
try {
|
||||
if (config.apiFormat === 'openai_chat') {
|
||||
return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl)
|
||||
return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl, traceContext)
|
||||
} else {
|
||||
return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl)
|
||||
return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl, traceContext)
|
||||
}
|
||||
} catch (err) {
|
||||
if (traceContext && !wasTraceErrorRecorded(err)) {
|
||||
void recordProxyTrace({
|
||||
context: traceContext,
|
||||
model: body.model,
|
||||
upstreamUrl: baseUrl,
|
||||
upstreamRequest: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
startedAtMs: Date.now(),
|
||||
error: err,
|
||||
}).catch(() => {})
|
||||
}
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<string, unknown> {
|
||||
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<void> {
|
||||
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<Uint8Array>,
|
||||
onComplete: (snapshot: TraceBodySnapshot, error?: unknown) => Promise<void>,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const decoder = new TextDecoder()
|
||||
let captured = ''
|
||||
let bytes = 0
|
||||
let truncated = false
|
||||
let finalized = false
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | 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<Uint8Array>({
|
||||
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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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<Response> {
|
||||
const path = url.pathname
|
||||
@ -119,6 +120,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'desktop-ui':
|
||||
return handleDesktopUiApi(req, url, segments)
|
||||
|
||||
case 'traces':
|
||||
return handleTracesApi(req, url, segments)
|
||||
|
||||
case 'filesystem':
|
||||
return handleFilesystemRoute(url.pathname, url)
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ import { sanitizePath } from '../../utils/path.js'
|
||||
import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js'
|
||||
import { attributionHeaderEnvForModel } from './attributionHeaderPolicy.js'
|
||||
import { buildNetworkEnvironment, loadNetworkSettings } from './networkSettings.js'
|
||||
import { readTraceCaptureSettings } from './traceCaptureService.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import {
|
||||
createImageMetadataText,
|
||||
@ -1046,6 +1047,9 @@ export class ConversationService {
|
||||
if (options?.resumeInterruptedTurn === false) {
|
||||
delete cleanEnv.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
|
||||
}
|
||||
delete cleanEnv.CC_HAHA_TRACE_PROVIDER_ID
|
||||
delete cleanEnv.CC_HAHA_TRACE_PROVIDER_NAME
|
||||
delete cleanEnv.CC_HAHA_TRACE_PROVIDER_FORMAT
|
||||
if (this.shouldStripInheritedProviderEnv(options?.providerId)) {
|
||||
for (const key of PROVIDER_ENV_KEYS) {
|
||||
delete cleanEnv[key]
|
||||
@ -1062,11 +1066,15 @@ export class ConversationService {
|
||||
}
|
||||
}
|
||||
|
||||
const explicitProviderEnv =
|
||||
const explicitProvider =
|
||||
typeof options?.providerId === 'string'
|
||||
? await this.providerService.getProviderRuntimeEnv(options.providerId)
|
||||
? await this.providerService.getProvider(options.providerId)
|
||||
: null
|
||||
const explicitProviderEnv = explicitProvider
|
||||
? await this.providerService.getProviderRuntimeEnv(explicitProvider.id)
|
||||
: null
|
||||
const networkEnv = buildNetworkEnvironment(await loadNetworkSettings())
|
||||
const traceCaptureEnabled = (await readTraceCaptureSettings()).enabled
|
||||
if (explicitProviderEnv && options?.model?.trim()) {
|
||||
explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim()
|
||||
}
|
||||
@ -1096,6 +1104,16 @@ export class ConversationService {
|
||||
...(sdkUrl
|
||||
? { CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID: 'com.claude-code-haha.desktop' }
|
||||
: {}),
|
||||
...(sdkUrl && traceCaptureEnabled
|
||||
? { CC_HAHA_TRACE_API_CALLS: '1' }
|
||||
: {}),
|
||||
...(sdkUrl && traceCaptureEnabled && explicitProvider
|
||||
? {
|
||||
CC_HAHA_TRACE_PROVIDER_ID: explicitProvider.id,
|
||||
CC_HAHA_TRACE_PROVIDER_NAME: explicitProvider.name,
|
||||
CC_HAHA_TRACE_PROVIDER_FORMAT: explicitProvider.apiFormat ?? 'anthropic',
|
||||
}
|
||||
: {}),
|
||||
...(desktopServerUrl
|
||||
? { CC_HAHA_DESKTOP_SERVER_URL: desktopServerUrl }
|
||||
: {}),
|
||||
|
||||
@ -375,6 +375,8 @@ export class ProviderService {
|
||||
// --- Proxy support ---
|
||||
|
||||
async getProviderForProxy(providerId?: string): Promise<{
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
apiFormat: ApiFormat
|
||||
@ -385,6 +387,8 @@ export class ProviderService {
|
||||
}
|
||||
const provider = await this.getProvider(providerId)
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
apiFormat: provider.apiFormat ?? 'anthropic',
|
||||
@ -399,6 +403,8 @@ export class ProviderService {
|
||||
const provider = await this.getProvider(index.activeId).catch(() => null)
|
||||
if (!provider) return null
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
apiFormat: provider.apiFormat ?? 'anthropic',
|
||||
|
||||
29
src/server/services/traceCaptureService.ts
Normal file
29
src/server/services/traceCaptureService.ts
Normal file
@ -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'
|
||||
@ -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<string, string> | 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<string, string> | 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<string, string> | 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<string, string> | 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
|
||||
}
|
||||
|
||||
775
src/services/api/traceCapture.ts
Normal file
775
src/services/api/traceCapture.ts
Normal file
@ -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<string, unknown>
|
||||
request: {
|
||||
method: string
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
body: TraceBodySnapshot
|
||||
}
|
||||
response?: {
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
request: {
|
||||
method?: string
|
||||
url?: string
|
||||
headers?: Headers | Record<string, string> | null
|
||||
body?: unknown
|
||||
bodySnapshot?: TraceBodySnapshot
|
||||
}
|
||||
response?: {
|
||||
status: number
|
||||
headers?: Headers | Record<string, string> | 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<string, unknown>
|
||||
}
|
||||
|
||||
type TraceFileEntry =
|
||||
| TraceCallRecord
|
||||
| { type: 'call'; record: TraceCallRecord }
|
||||
| { type: 'event'; event: TraceEventRecord }
|
||||
|
||||
const traceWriteQueues = new Map<string, Promise<void>>()
|
||||
|
||||
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<TraceCaptureSettings> {
|
||||
const settings = await readManagedSettings()
|
||||
return normalizeTraceCaptureSettings(settings)
|
||||
}
|
||||
|
||||
export async function updateTraceCaptureSettings(input: Partial<Pick<TraceCaptureSettings, 'enabled'>>): Promise<TraceCaptureSettings> {
|
||||
const current = await readManagedSettings()
|
||||
const traceCapture = current[TRACE_SETTINGS_KEY]
|
||||
const previous = traceCapture && typeof traceCapture === 'object' && !Array.isArray(traceCapture)
|
||||
? traceCapture as Record<string, unknown>
|
||||
: {}
|
||||
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<TraceCallRecord | null> {
|
||||
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<TraceEventRecord | null> {
|
||||
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<TraceSession> {
|
||||
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<TraceSessionList> {
|
||||
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<TraceSessionFileList> {
|
||||
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<TraceBodySnapshot> {
|
||||
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<string, string> | null | undefined): Record<string, string> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
return redactSensitiveValue(metadata) as Record<string, unknown>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<Array<{ name: string; size: number; updatedAt: string }>> {
|
||||
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<string, TraceCallRecord>()
|
||||
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<TraceCallRecord>
|
||||
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<TraceEventRecord>
|
||||
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<string, number>()
|
||||
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<string, unknown>
|
||||
: parsed as Record<string, unknown>
|
||||
const input = numberFromUnknown(usage.input_tokens) + numberFromUnknown(usage.prompt_tokens)
|
||||
const output = numberFromUnknown(usage.output_tokens) + numberFromUnknown(usage.completion_tokens)
|
||||
return { input, output }
|
||||
}
|
||||
|
||||
function 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<string, unknown>): 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<string, unknown>).enabled !== false,
|
||||
}
|
||||
}
|
||||
|
||||
function readManagedSettingsSync(): Record<string, unknown> {
|
||||
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<string, unknown>
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function readManagedSettings(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(getManagedSettingsPath(), 'utf-8')) as unknown
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: {}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManagedSettings(settings: Record<string, unknown>): Promise<void> {
|
||||
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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user