feat: allow deleting trace sessions

Adds an idempotent DELETE /api/traces/:sessionId path that removes only the local trace JSONL file and invalidates trace read cache. The Trace list now exposes a trash action behind a confirmation dialog that states chat history is not deleted.

Tested: bun test src/server/__tests__/trace-capture.test.ts

Tested: cd desktop && bun run test -- src/pages/TraceList.test.tsx --run

Tested: bun run check:server

Tested: bun run check:desktop

Tested: Browser smoke with temporary CLAUDE_CONFIG_DIR verified the Trace list delete button and confirmation dialog on ?traceWindow=1.

Constraint: GitHub issue #868 remains open until a release ships and post-release retest passes.

Confidence: high

Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-23 16:20:11 +08:00
parent 6bd19712e8
commit 46f612abd5
13 changed files with 300 additions and 78 deletions

View File

@ -1,5 +1,5 @@
import { api } from './client'
import type { TraceCaptureSettings, TraceSessionList } from '../types/trace'
import type { TraceCaptureSettings, TraceSessionDeleteResult, TraceSessionList } from '../types/trace'
export const tracesApi = {
list(options?: { limit?: number; offset?: number; query?: string }) {
@ -18,4 +18,8 @@ export const tracesApi = {
updateSettings(settings: Partial<Pick<TraceCaptureSettings, 'enabled'>>) {
return api.put<TraceCaptureSettings>('/api/traces/settings', settings)
},
deleteSession(sessionId: string) {
return api.delete<TraceSessionDeleteResult>(`/api/traces/${encodeURIComponent(sessionId)}`)
},
}

View File

@ -1749,6 +1749,7 @@ export const en = {
'trace.copySessionId': 'Copy session ID',
'trace.refresh': 'Refresh trace',
'trace.openWindow': 'Open in separate window',
'trace.delete': 'Delete trace',
'trace.noResponse': 'No response body captured',
'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.',
@ -1844,6 +1845,9 @@ export const en = {
'trace.list.loadFailed': 'Failed to load trace list',
'trace.list.loadedCount': 'Showing {shown} of {total}',
'trace.list.loadMore': 'Load more',
'trace.list.deleteConfirmTitle': 'Delete trace session',
'trace.list.deleteConfirmBody': 'Delete trace data for "{title}"? Chat history is not deleted.',
'trace.list.deleteFailed': 'Failed to delete trace session',
// ─── App Shell ──────────────────────────────────────
'app.serverFailed': 'Local server failed to start',

View File

@ -1751,6 +1751,7 @@ export const jp: Record<TranslationKey, string> = {
'trace.copySessionId': 'Session ID をコピー',
'trace.refresh': 'Trace を更新',
'trace.openWindow': '別ウィンドウで開く',
'trace.delete': 'Trace を削除',
'trace.noResponse': 'レスポンス本文はキャプチャされていません',
'trace.emptyTitle': 'Trace 呼び出しはまだありません',
'trace.emptyBody': 'このセッションにはキャプチャ済みのモデル呼び出しがありません。新しいデスクトップセッションでは、軽量なリクエストとレスポンスのプレビューを自動記録します。',
@ -1846,6 +1847,9 @@ export const jp: Record<TranslationKey, string> = {
'trace.list.loadFailed': 'Trace 一覧の読み込みに失敗しました',
'trace.list.loadedCount': '{shown} / {total} を表示中',
'trace.list.loadMore': 'さらに読み込む',
'trace.list.deleteConfirmTitle': 'Trace セッションを削除',
'trace.list.deleteConfirmBody': '「{title}」の Trace データを削除しますか?チャット履歴は削除されません。',
'trace.list.deleteFailed': 'Trace セッションの削除に失敗しました',
// ─── App Shell ──────────────────────────────────────
'app.serverFailed': 'ローカルサーバーの起動に失敗しました',

View File

@ -1751,6 +1751,7 @@ export const kr: Record<TranslationKey, string> = {
'trace.copySessionId': 'Session ID 복사',
'trace.refresh': 'Trace 새로고침',
'trace.openWindow': '별도 창에서 열기',
'trace.delete': 'Trace 삭제',
'trace.noResponse': '응답 본문이 캡처되지 않았습니다',
'trace.emptyTitle': '아직 Trace 호출이 없습니다',
'trace.emptyBody': '이 세션에는 캡처된 모델 호출이 없습니다. 새 데스크톱 세션은 간단한 요청 및 응답 미리보기를 자동으로 기록합니다.',
@ -1846,6 +1847,9 @@ export const kr: Record<TranslationKey, string> = {
'trace.list.loadFailed': 'Trace 목록을 불러오지 못했습니다',
'trace.list.loadedCount': '{shown} / {total} 표시 중',
'trace.list.loadMore': '더 불러오기',
'trace.list.deleteConfirmTitle': 'Trace 세션 삭제',
'trace.list.deleteConfirmBody': '"{title}"의 Trace 데이터를 삭제하시겠습니까? 채팅 기록은 삭제되지 않습니다.',
'trace.list.deleteFailed': 'Trace 세션을 삭제하지 못했습니다',
// ─── App Shell ──────────────────────────────────────
'app.serverFailed': '로컬 서버를 시작하지 못했습니다',

View File

@ -1751,6 +1751,7 @@ export const zh: Record<TranslationKey, string> = {
'trace.copySessionId': '複製 Session ID',
'trace.refresh': '重新整理 Trace',
'trace.openWindow': '在獨立視窗開啟',
'trace.delete': '刪除 Trace',
'trace.noResponse': '未捕獲回應正文',
'trace.emptyTitle': '暫無 Trace 呼叫',
'trace.emptyBody': '這個會話還沒有捕獲到模型呼叫。新的桌面會話會自動記錄精簡的請求和回應預覽。',
@ -1846,6 +1847,9 @@ export const zh: Record<TranslationKey, string> = {
'trace.list.loadFailed': 'Trace 列表載入失敗',
'trace.list.loadedCount': '已顯示 {shown} / {total}',
'trace.list.loadMore': '載入更多',
'trace.list.deleteConfirmTitle': '刪除 Trace 會話',
'trace.list.deleteConfirmBody': '刪除「{title}」的 Trace 資料?聊天記錄不會被刪除。',
'trace.list.deleteFailed': 'Trace 會話刪除失敗',
// ─── 應用外殼 ──────────────────────────────────────
'app.serverFailed': '本地服務啟動失敗',

View File

@ -1751,6 +1751,7 @@ export const zh: Record<TranslationKey, string> = {
'trace.copySessionId': '复制 Session ID',
'trace.refresh': '刷新 Trace',
'trace.openWindow': '在独立窗口打开',
'trace.delete': '删除 Trace',
'trace.noResponse': '未捕获响应正文',
'trace.emptyTitle': '暂无 Trace 调用',
'trace.emptyBody': '这个会话还没有捕获到模型调用。新的桌面会话会自动记录精简的请求和响应预览。',
@ -1846,6 +1847,9 @@ export const zh: Record<TranslationKey, string> = {
'trace.list.loadFailed': 'Trace 列表加载失败',
'trace.list.loadedCount': '已显示 {shown} / {total}',
'trace.list.loadMore': '加载更多',
'trace.list.deleteConfirmTitle': '删除 Trace 会话',
'trace.list.deleteConfirmBody': '删除“{title}”的 Trace 数据?聊天记录不会被删除。',
'trace.list.deleteFailed': 'Trace 会话删除失败',
// ─── 应用外壳 ──────────────────────────────────────
'app.serverFailed': '本地服务启动失败',

View File

@ -10,6 +10,7 @@ import type { TraceSessionList } from '../types/trace'
vi.mock('../api/traces', () => ({
tracesApi: {
list: vi.fn(),
deleteSession: vi.fn(),
},
}))
@ -90,6 +91,7 @@ describe('TraceList', () => {
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({ tabs: [], activeTabId: null })
vi.mocked(tracesApi.list).mockResolvedValue(traceList)
vi.mocked(tracesApi.deleteSession).mockResolvedValue({ sessionId: 'session-trace-list', deleted: true })
})
afterEach(() => {
@ -159,6 +161,31 @@ describe('TraceList', () => {
expect(useTabStore.getState().activeTabId).toBe('__trace__session-trace-list')
})
it('requires confirmation before deleting a trace session', async () => {
vi.mocked(tracesApi.list)
.mockResolvedValueOnce(traceList)
.mockResolvedValueOnce({ ...traceList, traces: [], total: 0 })
render(<TraceList />)
const row = await findTraceRow(/Debug stuck agent/)
fireEvent.click(within(row).getByRole('button', { name: 'Delete trace' }))
expect(tracesApi.deleteSession).not.toHaveBeenCalled()
expect(screen.getByText('Delete trace data for "Debug stuck agent"? Chat history is not deleted.')).toBeInTheDocument()
fireEvent.click(within(screen.getByRole('dialog', { name: 'Delete trace session' })).getByRole('button', { name: 'Delete' }))
await waitFor(() => {
expect(tracesApi.deleteSession).toHaveBeenCalledWith('session-trace-list')
})
await waitFor(() => {
expect(screen.queryByText('Debug stuck agent')).not.toBeInTheDocument()
})
expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 0, query: '' })
expect(useTabStore.getState().activeTabId).toBeNull()
})
it('opens General settings from the trace settings button', async () => {
render(<TraceList />)

View File

@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
import { ExternalLink, RefreshCw, Search, Workflow } from 'lucide-react'
import { ExternalLink, RefreshCw, Search, Trash2, 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 { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { getDesktopHost } from '../lib/desktopHost'
import type { TraceSessionList, TraceSessionListItem } from '../types/trace'
@ -26,6 +27,8 @@ export function TraceList() {
const [queryInput, setQueryInput] = useState('')
const [query, setQuery] = useState('')
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<TraceSessionListItem | null>(null)
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null)
const host = getDesktopHost()
useEffect(() => {
@ -101,88 +104,126 @@ export function TraceList() {
return { apiCalls, failedCalls, models: modelNames.size }
}, [state])
const confirmDelete = useCallback(async () => {
if (!deleteTarget) return
const currentLimit = state.status === 'ready'
? Math.max(PAGE_SIZE, state.data.traces.length)
: PAGE_SIZE
setDeletingSessionId(deleteTarget.sessionId)
try {
await tracesApi.deleteSession(deleteTarget.sessionId)
setDeleteTarget(null)
await load({ limit: currentLimit, silent: true })
} catch (error) {
setState({
status: 'error',
message: error instanceof Error ? error.message : t('trace.list.deleteFailed'),
})
} finally {
setDeletingSessionId(null)
}
}, [deleteTarget, load, state, t])
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-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-text-tertiary)]">
<Workflow className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
<span>{t('trace.list.eyebrow')}</span>
<>
<div 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-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-text-tertiary)]">
<Workflow className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
<span>{t('trace.list.eyebrow')}</span>
</div>
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1">
<h1 className="text-lg font-semibold tracking-tight text-[var(--color-text-primary)]">{t('trace.list.title')}</h1>
{state.status === 'ready' && (
<span className={`rounded-[var(--radius-sm)] border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
state.data.settings.enabled
? 'border-[var(--color-success)]/25 bg-[var(--color-success)]/10 text-[var(--color-success)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]'
}`}>
{state.data.settings.enabled ? t('trace.list.collecting') : t('trace.list.paused')}
</span>
)}
{state.status === 'ready' && (
<span className="min-w-0 max-w-full truncate font-mono text-[11px] text-[var(--color-text-tertiary)]" title={state.data.storageDir}>
{state.data.storageDir}
</span>
)}
</div>
</div>
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1">
<h1 className="text-lg font-semibold tracking-tight text-[var(--color-text-primary)]">{t('trace.list.title')}</h1>
{state.status === 'ready' && (
<span className={`rounded-[var(--radius-sm)] border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
state.data.settings.enabled
? 'border-[var(--color-success)]/25 bg-[var(--color-success)]/10 text-[var(--color-success)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]'
}`}>
{state.data.settings.enabled ? t('trace.list.collecting') : t('trace.list.paused')}
</span>
)}
{state.status === 'ready' && (
<span className="min-w-0 max-w-full truncate font-mono text-[11px] text-[var(--color-text-tertiary)]" title={state.data.storageDir}>
{state.data.storageDir}
</span>
)}
<div className="flex shrink-0 items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => openTraceSettings(t)}>
{t('trace.list.settings')}
</Button>
<Button size="sm" variant="secondary" onClick={() => void load()}>
<RefreshCw className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
{t('trace.refresh')}
</Button>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => openTraceSettings(t)}>
{t('trace.list.settings')}
</Button>
<Button size="sm" variant="secondary" onClick={() => void load()}>
<RefreshCw className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
{t('trace.refresh')}
</Button>
<div className="mt-3 flex flex-wrap items-baseline gap-x-5 gap-y-1.5">
<MetaChip label={t('trace.list.sessions')} value={state.status === 'ready' ? String(state.data.total) : '-'} />
<MetaChip label={t('trace.apiCalls')} value={String(summary.apiCalls)} />
<MetaChip label={t('trace.failedCalls')} value={String(summary.failedCalls)} tone={summary.failedCalls > 0 ? 'danger' : 'default'} />
<MetaChip label={t('trace.models')} value={String(summary.models)} />
</div>
</div>
</header>
<div className="mt-3 flex flex-wrap items-baseline gap-x-5 gap-y-1.5">
<MetaChip label={t('trace.list.sessions')} value={state.status === 'ready' ? String(state.data.total) : '-'} />
<MetaChip label={t('trace.apiCalls')} value={String(summary.apiCalls)} />
<MetaChip label={t('trace.failedCalls')} value={String(summary.failedCalls)} tone={summary.failedCalls > 0 ? 'danger' : 'default'} />
<MetaChip label={t('trace.models')} value={String(summary.models)} />
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-[var(--color-border)] px-5 py-3">
<div className="flex h-9 max-w-xl items-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 focus-within:border-[var(--color-border-focus)]">
<Search className="h-3.5 w-3.5 shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
<input
value={queryInput}
onChange={(event) => setQueryInput(event.currentTarget.value)}
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>
<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-9 max-w-xl items-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 focus-within:border-[var(--color-border-focus)]">
<Search className="h-3.5 w-3.5 shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
<input
value={queryInput}
onChange={(event) => setQueryInput(event.currentTarget.value)}
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)]"
{state.status === 'loading' && <TraceListSkeleton label={t('common.loading')} />}
{state.status === 'error' && (
<div className="m-5 rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error)]/5 p-4 text-sm text-[var(--color-error)]">
{state.message}
</div>
)}
{state.status === 'ready' && (
<TraceRows
traces={state.data.traces}
total={state.data.total}
loadingMore={isLoadingMore}
deletingSessionId={deletingSessionId}
onLoadMore={() => void load({
append: true,
offset: state.data.traces.length,
silent: true,
})}
onOpenWindow={(sessionId) => {
if (host.trace) void host.trace.openWindow(sessionId)
}}
onDelete={setDeleteTarget}
/>
</div>
)}
</div>
{state.status === 'loading' && <TraceListSkeleton label={t('common.loading')} />}
{state.status === 'error' && (
<div className="m-5 rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error)]/5 p-4 text-sm text-[var(--color-error)]">
{state.message}
</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>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => {
if (!deletingSessionId) setDeleteTarget(null)
}}
onConfirm={() => void confirmDelete()}
title={t('trace.list.deleteConfirmTitle')}
body={deleteTarget
? t('trace.list.deleteConfirmBody', { title: getTraceTitle(deleteTarget, t) })
: ''}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
loading={deletingSessionId !== null}
/>
</>
)
}
@ -192,12 +233,16 @@ function TraceRows({
traces,
total,
onOpenWindow,
onDelete,
deletingSessionId,
}: {
loadingMore: boolean
onLoadMore: () => void
traces: TraceSessionListItem[]
total: number
onOpenWindow: (sessionId: string) => void
onDelete: (trace: TraceSessionListItem) => void
deletingSessionId: string | null
}) {
const t = useTranslation()
@ -217,7 +262,13 @@ function TraceRows({
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="divide-y divide-[var(--color-border)]" role="list">
{traces.map((trace) => (
<TraceRow key={trace.sessionId} trace={trace} onOpenWindow={onOpenWindow} />
<TraceRow
key={trace.sessionId}
trace={trace}
onOpenWindow={onOpenWindow}
onDelete={onDelete}
isDeleting={deletingSessionId === trace.sessionId}
/>
))}
</div>
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-5 py-3 text-xs text-[var(--color-text-tertiary)]">
@ -235,12 +286,16 @@ function TraceRows({
function TraceRow({
trace,
onOpenWindow,
onDelete,
isDeleting,
}: {
trace: TraceSessionListItem
onOpenWindow: (sessionId: string) => void
onDelete: (trace: TraceSessionListItem) => void
isDeleting: boolean
}) {
const t = useTranslation()
const title = trace.session?.title || t('session.untitled')
const title = getTraceTitle(trace, t)
const updatedAt = trace.summary.updatedAt ?? trace.fileUpdatedAt
const failedCalls = trace.summary.failedCalls
const visibleModels = trace.summary.models.slice(0, MAX_MODEL_CHIPS)
@ -308,7 +363,7 @@ function TraceRow({
<MetricCell label={t('trace.tokens')} value={formatCompact(totalTokens)} />
</div>
</button>
<div className="flex w-[60px] shrink-0 items-center justify-end gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<div className="flex w-[92px] shrink-0 items-center justify-end gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<RowAction
label={t('trace.open')}
onClick={(event) => {
@ -327,6 +382,17 @@ function TraceRow({
>
<ExternalLink className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</RowAction>
<RowAction
label={t('trace.delete')}
tone="danger"
disabled={isDeleting}
onClick={(event) => {
event.stopPropagation()
onDelete(trace)
}}
>
<Trash2 className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</RowAction>
</div>
</div>
)
@ -336,18 +402,27 @@ function RowAction({
label,
onClick,
children,
tone = 'default',
disabled = false,
}: {
label: string
onClick: (event: MouseEvent<HTMLButtonElement>) => void
children: ReactNode
tone?: 'default' | 'danger'
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
className={`flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-high)] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 ${
tone === 'danger'
? 'hover:text-[var(--color-error)]'
: 'hover:text-[var(--color-text-primary)]'
}`}
>
{children}
</button>
@ -398,6 +473,10 @@ function openTrace(sessionId: string, title: string, t: ReturnType<typeof useTra
useTabStore.getState().openTraceTab(sessionId, `${t('trace.title')}: ${title}`)
}
function getTraceTitle(trace: TraceSessionListItem, t: ReturnType<typeof useTranslation>): string {
return trace.session?.title || t('session.untitled')
}
function openTraceSettings(t: ReturnType<typeof useTranslation>) {
useUIStore.getState().setPendingSettingsTab('general')
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')

View File

@ -117,3 +117,8 @@ export type TraceSessionList = {
storageDir: string
settings: TraceCaptureSettings
}
export type TraceSessionDeleteResult = {
sessionId: string
deleted: boolean
}

View File

@ -1148,6 +1148,48 @@ describe('session trace API', () => {
})
})
test('deletes a trace session file and invalidates cached reads', async () => {
await traceCaptureService.recordCall({
sessionId: 'session-delete-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 cached = await traceCaptureService.getSessionTrace('session-delete-trace')
expect(cached.calls).toHaveLength(1)
const req = new Request('http://localhost:3456/api/traces/session-delete-trace', { method: 'DELETE' })
const res = await handleApiRequest(req, new URL(req.url))
const body = await res.json() as { sessionId: string; deleted: boolean }
expect(res.status).toBe(200)
expect(body).toEqual({ sessionId: 'session-delete-trace', deleted: true })
await expect(fs.stat(path.join(tmpDir, 'cc-haha', 'traces', 'session-delete-trace.jsonl'))).rejects.toThrow()
const afterDelete = await traceCaptureService.getSessionTrace('session-delete-trace')
expect(afterDelete.calls).toEqual([])
expect(afterDelete.events).toEqual([])
const secondReq = new Request('http://localhost:3456/api/traces/session-delete-trace', { method: 'DELETE' })
const secondRes = await handleApiRequest(secondReq, new URL(secondReq.url))
const secondBody = await secondRes.json() as { sessionId: string; deleted: boolean }
expect(secondRes.status).toBe(200)
expect(secondBody).toEqual({ sessionId: 'session-delete-trace', deleted: false })
})
test('searches trace sessions by session title and project path before paginating', async () => {
await traceCaptureService.recordCall({
sessionId: 'session-title-alpha',

View File

@ -60,6 +60,9 @@ export async function handleTracesApi(
throw methodNotAllowed(req.method, '/api/traces/settings')
default:
if (req.method === 'DELETE' && segments.length === 3) {
return await deleteTraceSession(sub)
}
throw ApiError.notFound(`Unknown traces endpoint: ${sub}`)
}
} catch (error) {
@ -67,6 +70,13 @@ export async function handleTracesApi(
}
}
async function deleteTraceSession(segment: string): Promise<Response> {
const sessionId = decodePathSegment(segment).trim()
if (!sessionId) throw ApiError.badRequest('Trace session id is required')
const result = await traceCaptureService.deleteSessionTrace(sessionId)
return Response.json(result)
}
async function listTraces(url: URL): Promise<Response> {
const limit = parseTraceListLimit(url.searchParams.get('limit'))
const offset = parseTraceListOffset(url.searchParams.get('offset'))
@ -169,6 +179,14 @@ function traceListItemMatchesQuery(item: Pick<TraceSessionListApiItem, 'sessionI
return terms.every((term) => haystack.includes(term))
}
function decodePathSegment(value: string): string {
try {
return decodeURIComponent(value)
} catch {
throw ApiError.badRequest('Invalid trace session id')
}
}
function parseTraceListLimit(value: string | null): number {
const parsed = Number.parseInt(value || '50', 10)
if (!Number.isFinite(parsed) || parsed <= 0) return 50

View File

@ -28,6 +28,7 @@ export type {
TraceProviderInfo,
TraceResponseCapture,
TraceSession,
TraceSessionDeleteResult,
TraceSessionFileItem,
TraceSessionFileList,
TraceSessionList,

View File

@ -135,6 +135,11 @@ export type TraceSessionList = {
settings: TraceCaptureSettings
}
export type TraceSessionDeleteResult = {
sessionId: string
deleted: boolean
}
export type RecordTraceCallInput = {
id?: string
sessionId: string
@ -373,6 +378,27 @@ class TraceCaptureService {
return calls.find((call) => call.id === callId) ?? null
}
async deleteSessionTrace(sessionId: string): Promise<TraceSessionDeleteResult> {
const normalizedSessionId = sanitizeTraceFileName(sessionId)
if (!normalizedSessionId) return { sessionId: normalizedSessionId, deleted: false }
const pendingWrite = traceWriteQueues.get(sessionId) ?? traceWriteQueues.get(normalizedSessionId)
if (pendingWrite) await pendingWrite.catch(() => {})
const filePath = getTraceFilePath(normalizedSessionId)
try {
await fs.unlink(filePath)
traceReadCache.delete(filePath)
return { sessionId: normalizedSessionId, deleted: true }
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
traceReadCache.delete(filePath)
return { sessionId: normalizedSessionId, deleted: false }
}
throw error
}
}
async listSessionTraces(options?: {
limit?: number
offset?: number