fix(perf): bound large session history and tracing resource usage

Page transcript and trace reads, bound UI caches and retained task records,
and replace full-file background polling with incremental projections.
Preserve recovery and ownership semantics across pages and cancel stale work.
This commit is contained in:
程序员阿江(Relakkes)
2026-09-19 20:29:06 +08:00
parent 6bfed2b774
commit 27f660fe5c
70 changed files with 5083 additions and 926 deletions
+36 -4
View File
@@ -30,6 +30,23 @@ type MessagesResponse = {
messages: MessageEntry[]
taskNotifications?: AgentTaskNotification[]
}
export type SessionHistoryPage = MessagesResponse & {
page?: {
nextCursor: string | null
hasMore: boolean
historyComplete: boolean
sourceVersion: string
scannedBytes: number
omittedOversizedEntries: number
}
}
export type SessionHistoryRecovery = MessagesResponse & {
sourceVersion: string
status: 'ready' | 'incomplete'
completeness?: { goal: boolean; todos: boolean; activity: boolean; usage: boolean }
tokenUsage: { input_tokens: number; output_tokens: number; cache_read_tokens?: number; cache_creation_tokens?: number } | null
omittedRecords: number
}
type CreateSessionResponse = { sessionId: string; workDir?: string }
export type BatchDeleteSessionsResponse = {
ok: boolean
@@ -410,8 +427,18 @@ export const sessionsApi = {
return api.get<ProjectSessionHistoryResponse>(`/api/sessions/project-history?${query.toString()}`, options)
},
getMessages(sessionId: string) {
return api.get<MessagesResponse>(`/api/sessions/${sessionId}/messages`)
getMessages(sessionId: string, options?: ApiRequestOptions) {
return api.get<SessionHistoryPage>(`/api/sessions/${sessionId}/messages`, options)
},
getHistoryPage(sessionId: string, page?: { cursor?: string }, options?: ApiRequestOptions) {
const query = new URLSearchParams()
if (page?.cursor) query.set('cursor', page.cursor)
return api.get<SessionHistoryPage>(`/api/sessions/${sessionId}/messages${query.size ? `?${query}` : ''}`, options)
},
getHistoryRecovery(sessionId: string, options?: ApiRequestOptions) {
return api.get<SessionHistoryRecovery>(`/api/sessions/${sessionId}/history-recovery`, options)
},
getSummary(sessionId: string, options?: ApiRequestOptions) {
@@ -422,8 +449,13 @@ export const sessionsApi = {
return api.get<SessionChatStatusResponse>(`/api/sessions/${sessionId}/chat/status`, { signal })
},
getTrace(sessionId: string) {
return api.get<TraceSession>(`/api/sessions/${sessionId}/trace`)
getTrace(sessionId: string, options?: ApiRequestOptions, page?: { offset?: number; revisionToken?: string; scanCursor?: string }) {
const query = new URLSearchParams()
if (page?.offset) query.set('offset', String(page.offset))
if (page?.revisionToken) query.set('revisionToken', page.revisionToken)
if (page?.scanCursor) query.set('scanCursor', page.scanCursor)
const suffix = query.size ? `?${query}` : ''
return api.get<TraceSession>(`/api/sessions/${sessionId}/trace${suffix}`, options)
},
getTraceCall(sessionId: string, callId: string) {
+2
View File
@@ -35,6 +35,8 @@ export type SubagentRunResponse = {
/** Terminal events with the same fragment-scoped ids as activityMessages. */
activityTaskNotifications?: AgentTaskNotification[]
truncated: boolean
historyComplete?: boolean
activityComplete?: boolean
updatedAt?: string
source: SubagentRunSource
/**
+5 -4
View File
@@ -1,4 +1,4 @@
import { api } from './client'
import { api, type ApiRequestOptions } from './client'
import type { TraceCaptureSettings, TraceSessionDeleteResult, TraceSessionList } from '../types/trace'
export type TraceSessionRevision = {
@@ -10,22 +10,23 @@ export type TraceSessionRevision = {
}
export const tracesApi = {
list(options?: { limit?: number; offset?: number; query?: string }) {
list(options?: { limit?: number; offset?: number; query?: string }, requestOptions?: ApiRequestOptions) {
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}`)
return api.get<TraceSessionList>(`/api/traces${suffix}`, requestOptions)
},
getRevision(sessionId: string, sinceRevision?: number, sinceRevisionToken?: string) {
getRevision(sessionId: string, sinceRevision?: number, sinceRevisionToken?: string, options?: ApiRequestOptions) {
const params = new URLSearchParams()
if (sinceRevision !== undefined) params.set('sinceRevision', String(sinceRevision))
if (sinceRevisionToken !== undefined) params.set('sinceRevisionToken', sinceRevisionToken)
const suffix = params.toString() ? `?${params}` : ''
return api.get<TraceSessionRevision>(
`/api/traces/${encodeURIComponent(sessionId)}/revision${suffix}`,
options,
)
},
@@ -53,6 +53,8 @@ function makeSessionState(overrides: Partial<PerSessionState> = {}): PerSessionS
messages: [],
chatState: 'idle',
connectionState: 'connected',
historyStatus: 'ready',
historyHydrated: true,
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
@@ -338,6 +340,71 @@ describe('MessageList nested tool calls', () => {
vi.useRealTimers()
})
it('does not load checkpoints from stale cached rows before history hydration completes', async () => {
const messages: UIMessage[] = [
{ id: 'cached-user', type: 'user_text', content: 'Cached prompt', timestamp: 1 },
{ id: 'cached-reply', type: 'assistant_text', content: 'Cached reply', timestamp: 2 },
]
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({
messages, historyStatus: 'idle', historyHydrated: false, historyWindowed: false,
}) } })
render(<MessageList />)
expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled()
act(() => useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({
messages, historyStatus: 'loading', historyHydrated: false, historyWindowed: false,
}) } }))
expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled()
act(() => useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({
messages, historyStatus: 'ready', historyHydrated: false, historyWindowed: false,
}) } }))
expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled()
const partialPage = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 1024, omittedOversizedEntries: 0 }
act(() => useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({
messages, historyWindowed: false, historyPage: partialPage,
}) } }))
expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled()
act(() => useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({
messages, historyWindowed: true, historyPage: partialPage,
}) } }))
expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Load undo checkpoints' }))
await waitFor(() => expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalledTimes(1))
})
it('defers checkpoint transcript work until explicitly requested for a bounded history window', async () => {
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ historyWindowed: true, messages: [
{ id: 'user-1', transcriptMessageId: 'user-1', type: 'user_text', content: 'User prompt', timestamp: 1 },
{ id: 'reply', transcriptMessageId: 'reply', type: 'assistant_text', content: 'Assistant reply', timestamp: 2 },
] }) } })
vi.mocked(sessionsApi.getTurnCheckpoints).mockResolvedValue({ checkpoints: [{
target: { targetUserMessageId: 'a-different-old-user', userMessageIndex: 0, userMessageCount: 200 },
code: { available: true, filesChanged: ['wrong.ts'], insertions: 1, deletions: 0 },
}] })
render(<MessageList />)
expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Load undo checkpoints' }))
await waitFor(() => expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalledTimes(1))
await waitFor(() => expect(screen.getByRole('button', { name: 'Load undo checkpoints' }).hasAttribute('disabled')).toBe(false))
expect(screen.queryByRole('region', { name: 'Turn changed files' })).toBeNull()
})
it('pages through bounded history without replacing live state and returns to the latest window', async () => {
const page = { nextCursor: 'older-cursor', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 1024, omittedOversizedEntries: 0 }
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ historyWindowed: true, historyPage: page, messages: [{ id: 'live', type: 'assistant_text', content: 'current live message', timestamp: 1 }] }) } })
const getPage = vi.spyOn(sessionsApi, 'getHistoryPage')
.mockResolvedValueOnce({ messages: [{ id: 'old', type: 'assistant', content: 'older page message', timestamp: '2020-01-01T00:00:00Z' }], page: { ...page, nextCursor: null, hasMore: false } })
.mockResolvedValueOnce({ messages: [{ id: 'latest', type: 'assistant', content: 'latest page message', timestamp: '2026-01-01T00:00:00Z' }], page: { ...page, historyComplete: true } })
render(<MessageList />)
expect(screen.getByTestId('history-window-notice')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Older messages' }))
expect(await screen.findByText('older page message')).toBeTruthy()
expect(useChatStore.getState().sessions[ACTIVE_TAB]?.messages[0]?.id).toBe('live')
expect(getPage).toHaveBeenCalledWith(ACTIVE_TAB, { cursor: 'older-cursor' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
fireEvent.click(screen.getByRole('button', { name: 'Latest messages' }))
expect(await screen.findByText('latest page message')).toBeTruthy()
expect(useChatStore.getState().sessions[ACTIVE_TAB]?.historyBrowseMessages).toBeUndefined()
})
it('windows long transcripts instead of mounting every historical message at once', () => {
useChatStore.setState({
sessions: {
+35 -7
View File
@@ -2255,6 +2255,7 @@ export function MessageList({
const branchSession = useSessionStore((s) => s.branchSession)
const stopGeneration = useChatStore((s) => s.stopGeneration)
const reloadHistory = useChatStore((s) => s.reloadHistory)
const loadOlderHistory = useChatStore((s) => s.loadOlderHistory)
const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill)
const memberSessionTeam = useTeamStore((s) => (
resolvedSessionId ? s.getTeamByMemberSessionId(resolvedSessionId) : null
@@ -2294,12 +2295,17 @@ export function MessageList({
))
}, [teamSnapshot])
const addToast = useUIStore((s) => s.addToast)
const messages = sessionState?.messages ?? EMPTY_MESSAGES
const messages = sessionState?.historyBrowseMessages ?? sessionState?.messages ?? EMPTY_MESSAGES
const historyWindowKey = `${resolvedSessionId}:${messages[0]?.id ?? ''}:${messages.at(-1)?.id ?? ''}`
const checkpointHistoryReady = sessionState?.historyStatus === 'ready' && sessionState?.historyHydrated === true
const checkpointRequiresRequest = Boolean(sessionState?.historyWindowed || sessionState?.historyPage?.historyComplete === false)
const checkpointWindowKey = checkpointRequiresRequest ? historyWindowKey : ''
const [requestedWindowCheckpoints, setRequestedWindowCheckpoints] = useState<{ key: string; revision: number } | null>(null)
const chatState = sessionState?.chatState ?? 'idle'
const isPreparingTurn = Boolean(sessionState?.isPreparingTurn)
const historyMutationEpoch = sessionState?.historyMutationEpoch ?? 0
const streamingText = sessionState?.streamingText ?? ''
const streamingToolInput = sessionState?.streamingToolInput ?? ''
const streamingText = sessionState?.historyViewingOlder ? '' : sessionState?.streamingText ?? ''
const streamingToolInput = sessionState?.historyViewingOlder ? '' : sessionState?.streamingToolInput ?? ''
const activeThinkingId = sessionState?.activeThinkingId ?? null
const hasApiRetry = Boolean(sessionState?.apiRetry)
const hasStreamingFallback = Boolean(sessionState?.streamingFallback)
@@ -3015,7 +3021,8 @@ export function MessageList({
}, [renderItemKeys])
useEffect(() => {
if (!resolvedSessionId || completedTurnTargets.length === 0 || isDirectAgentSession) {
if (!resolvedSessionId || !checkpointHistoryReady || completedTurnTargets.length === 0 || isDirectAgentSession ||
(checkpointRequiresRequest && requestedWindowCheckpoints?.key !== historyWindowKey)) {
setTurnChangeCards([])
setTurnChangeLoadError(null)
setIsLoadingTurnChangeCards(false)
@@ -3056,7 +3063,7 @@ export function MessageList({
normalizeTurnCheckpoints(checkpointResponse).flatMap((checkpoint) => {
const target =
targetByMessageId.get(checkpoint.target.targetUserMessageId) ??
targetByUserMessageIndex.get(checkpoint.target.userMessageIndex)
(sessionState?.historyWindowed ? undefined : targetByUserMessageIndex.get(checkpoint.target.userMessageIndex))
if (!target) {
return []
}
@@ -3084,7 +3091,7 @@ export function MessageList({
cancelled = true
controller.abort()
}
}, [chatState, completedTurnTargets, hasRunningBackgroundTasks, historyMutationEpoch, isDirectAgentSession, latestCompletedTurnId, resolvedSessionId])
}, [chatState, completedTurnTargets, hasRunningBackgroundTasks, historyMutationEpoch, isDirectAgentSession, latestCompletedTurnId, resolvedSessionId, sessionState?.historyWindowed, checkpointHistoryReady, checkpointRequiresRequest, checkpointWindowKey, requestedWindowCheckpoints])
const handleUndoCurrentTurn = useCallback(async (mode: SessionRewindMode = 'both') => {
if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId || hasRunningBackgroundTasks) return
@@ -3107,7 +3114,7 @@ export function MessageList({
const result = await sessionsApi.rewind(resolvedSessionId, {
targetUserMessageId: checkpointTarget.targetUserMessageId,
userMessageIndex: checkpointTarget.userMessageIndex,
expectedContent: target.expectedContent,
...(sessionState?.historyWindowed ? {} : { expectedContent: target.expectedContent }),
mode,
})
@@ -3616,6 +3623,27 @@ export function MessageList({
// the agent-teams workbench appeared.
className="mx-auto max-w-[900px]"
>
{sessionState?.historyWindowed || sessionState?.historyPage?.hasMore ? (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-secondary)]" data-testid="history-window-notice">
<span>{t('chat.history.windowNotice')}</span>
{sessionState.historyPage?.nextCursor ? (
<Button variant="secondary" size="xs" disabled={sessionState.historyPageLoading} onClick={() => resolvedSessionId && void loadOlderHistory(resolvedSessionId)}>
{t('chat.history.older')}
</Button>
) : null}
<Button variant="secondary" size="xs" disabled={sessionState.historyPageLoading} onClick={() => resolvedSessionId && void loadOlderHistory(resolvedSessionId, true)}>
{t('chat.history.latest')}
</Button>
{completedTurnTargets.length > 0 ? (
<Button variant="secondary" size="xs" disabled={isLoadingTurnChangeCards || chatState !== 'idle'} onClick={() => setRequestedWindowCheckpoints((current) => ({ key: historyWindowKey, revision: (current?.revision ?? 0) + 1 }))}>
{t('chat.history.loadCheckpoints')}
</Button>
) : null}
{sessionState.historyRecoveryStatus === 'loading' ? <span role="status">{t('chat.history.recovering')}</span> : null}
{sessionState.historyRecoveryStatus === 'incomplete' || sessionState.historyRecoveryStatus === 'error' ? <span role="status">{t('chat.history.recoveryIncomplete')}</span> : null}
{sessionState.historyError ? <span role="alert">{sessionState.historyError}</span> : null}
</div>
) : null}
{virtualTranscriptWindow.enabled ? (
<VirtualSpacer height={virtualTranscriptWindow.beforeHeight} position="top" />
) : null}
+13
View File
@@ -1,4 +1,17 @@
export const en = {
"chat.history.loadCheckpoints": "Load undo checkpoints",
"chat.history.windowNotice": "Only a bounded history window is shown. Long content is shortened; full history remains on disk.",
"chat.history.older": "Older messages",
"chat.history.latest": "Latest messages",
"chat.history.recovering": "Restoring session state…",
"chat.history.recoveryIncomplete": "Some session state could not be restored.",
"trace.windowNotice": "Transcript and trace use bounded pages.",
"trace.windowLimited": "Some records exceed the viewing limit; the original file is unchanged.",
"trace.windowFirst": "First page / Live",
"trace.windowPrevious": "Previous page",
"trace.windowNext": "Next page",
"trace.windowScanNext": "Scan next segment",
"extensions.myPlugins": "My plugins",
"extensions.mySkills": "My skills",
"extensions.browse": "Back to browse",
+14 -2
View File
@@ -1,7 +1,19 @@
import type {
TranslationKey } from './en'
import type { TranslationKey } from './en'
export const jp: Record<TranslationKey, string> = {
"chat.history.loadCheckpoints": "取り消しチェックポイントを読み込む",
"chat.history.windowNotice": "履歴の一部を表示しています。長い内容は短縮され、完全な履歴はディスクに保存されています。",
"chat.history.older": "以前のメッセージ",
"chat.history.latest": "最新のメッセージ",
"chat.history.recovering": "セッション状態を復元中…",
"chat.history.recoveryIncomplete": "一部のセッション状態を復元できませんでした。",
"trace.windowNotice": "会話とトレースは制限付きページで読み込まれます。",
"trace.windowLimited": "表示上限を超えた記録があります。元のファイルは変更されません。",
"trace.windowFirst": "先頭 / ライブ",
"trace.windowPrevious": "前のページ",
"trace.windowNext": "次のページ",
"trace.windowScanNext": "次の区間を読み込む",
"extensions.myPlugins": "マイプラグイン",
"extensions.mySkills": "マイスキル",
"extensions.browse": "一覧に戻る",
+14 -2
View File
@@ -1,7 +1,19 @@
import type {
TranslationKey } from './en'
import type { TranslationKey } from './en'
export const kr: Record<TranslationKey, string> = {
"chat.history.loadCheckpoints": "실행 취소 체크포인트 불러오기",
"chat.history.windowNotice": "일부 기록만 표시합니다. 긴 내용은 축약되며 전체 기록은 디스크에 보관됩니다.",
"chat.history.older": "이전 메시지",
"chat.history.latest": "최신 메시지",
"chat.history.recovering": "세션 상태 복원 중…",
"chat.history.recoveryIncomplete": "일부 세션 상태를 복원하지 못했습니다.",
"trace.windowNotice": "대화와 추적은 제한된 페이지로 불러옵니다.",
"trace.windowLimited": "일부 기록이 표시 한도를 초과했습니다. 원본 파일은 유지됩니다.",
"trace.windowFirst": "처음 / 실시간",
"trace.windowPrevious": "이전 페이지",
"trace.windowNext": "다음 페이지",
"trace.windowScanNext": "다음 구간 읽기",
"extensions.myPlugins": "내 플러그인",
"extensions.mySkills": "내 스킬",
"extensions.browse": "둘러보기로 돌아가기",
+14 -2
View File
@@ -1,7 +1,19 @@
import type {
TranslationKey } from './en'
import type { TranslationKey } from './en'
export const zh: Record<TranslationKey, string> = {
"chat.history.loadCheckpoints": "載入復原檢查點",
"chat.history.windowNotice": "目前僅顯示部分歷史,較長內容已縮短;完整記錄仍儲存在磁碟。",
"chat.history.older": "更早訊息",
"chat.history.latest": "最新訊息",
"chat.history.recovering": "正在還原工作階段狀態…",
"chat.history.recoveryIncomplete": "部分工作階段狀態尚未還原。",
"trace.windowNotice": "聊天記錄與追蹤以有限視窗載入。",
"trace.windowLimited": "部分記錄超過檢視上限,原始檔案保持完整。",
"trace.windowFirst": "首頁 / 即時",
"trace.windowPrevious": "上一頁",
"trace.windowNext": "下一頁",
"trace.windowScanNext": "掃描下一段",
"extensions.myPlugins": "我的外掛",
"extensions.mySkills": "我的技能",
"extensions.browse": "返回瀏覽",
+14 -2
View File
@@ -1,7 +1,19 @@
import type {
TranslationKey } from './en'
import type { TranslationKey } from './en'
export const zh: Record<TranslationKey, string> = {
"chat.history.loadCheckpoints": "加载撤回检查点",
"chat.history.windowNotice": "当前仅显示部分历史,较长内容已缩短;完整记录仍保存在磁盘。",
"chat.history.older": "更早消息",
"chat.history.latest": "最新消息",
"chat.history.recovering": "正在恢复会话状态…",
"chat.history.recoveryIncomplete": "部分会话状态尚未恢复。",
"trace.windowNotice": "聊天记录与追踪按有限窗口加载。",
"trace.windowLimited": "部分记录超出查看上限,原始文件保持完整。",
"trace.windowFirst": "首页 / 实时",
"trace.windowPrevious": "上一页",
"trace.windowNext": "下一页",
"trace.windowScanNext": "扫描下一段",
"extensions.myPlugins": "我的插件",
"extensions.mySkills": "我的技能",
"extensions.browse": "返回浏览",
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { boundActivityText, boundChatHistory, CHAT_HISTORY_MAX_ROWS } from './chatHistoryBudget'
import type { UIMessage } from '../types/chat'
function text(id: number, content = 'message'): UIMessage {
return { id: String(id), type: 'assistant_text', timestamp: id, content }
}
describe('chat history retention', () => {
it('bounds many small rows and keeps the recent window', () => {
const result = boundChatHistory(Array.from({ length: 2000 }, (_, index) => text(index)))
expect(result.messages).toHaveLength(CHAT_HISTORY_MAX_ROWS)
expect(result.messages[0]?.id).toBe('1500')
expect(result.dropped).toBe(1500)
})
it('caps a huge structured tool payload without losing row identity or mutating input', () => {
const message: UIMessage = { id: 'tool', type: 'tool_use', toolName: 'Bash', toolUseId: 'id', timestamp: 123, input: { command: 'x'.repeat(2_000_000) } }
const result = boundChatHistory([message])
expect(result.clipped).toBe(true)
expect(result.messages[0]).toMatchObject({ id: 'tool', toolUseId: 'id', timestamp: 123 })
expect(JSON.stringify(result.messages).length).toBeLessThan(34_000)
expect((message.input as { command: string }).command).toHaveLength(2_000_000)
expect(boundChatHistory(result.messages).messages).toBe(result.messages)
})
it('enforces the byte budget for large rows and preserves unchanged references', () => {
const small = [text(0)]
expect(boundChatHistory(small).messages).toBe(small)
const result = boundChatHistory(Array.from({ length: 100 }, (_, index) => text(index, 'x'.repeat(30_000))), 128 * 1024)
expect(result.bytes).toBeLessThanOrEqual(128 * 1024)
expect(result.messages.at(-1)?.id).toBe('99')
})
it('retains recent terminal records and every active lifecycle, including active-to-terminal transitions', () => {
const records = Object.fromEntries(Array.from({ length: 700 }, (_, index) => [String(index), {
taskId: String(index), status: 'completed', updatedAt: index,
}]))
records.active = { taskId: 'active', status: 'running', updatedAt: 0 }
const bounded = boundActivityText(records, 1024 * 1024)!
expect(Object.keys(bounded)).toHaveLength(501)
expect(bounded['199']).toBeUndefined()
expect(bounded['200']).toBe(records['200'])
expect(bounded.active).toBe(records.active)
const completed = boundActivityText({ ...bounded, active: { ...bounded.active!, status: 'completed', updatedAt: 999 } }, 1024 * 1024)!
expect(Object.keys(completed)).toHaveLength(500)
expect(completed.active).toMatchObject({ taskId: 'active', status: 'completed', updatedAt: 999 })
expect(completed['200']).toBeUndefined()
const allRunning = Object.fromEntries(Array.from({ length: 1000 }, (_, index) => [String(index), { taskId: String(index), status: 'running' }]))
expect(boundActivityText(allRunning, 1024, 2)).toBe(allRunning)
})
it('memoizes unchanged activity records by identity and budgets instead of scanning on each delta', () => {
let enumerations = 0
const records = new Proxy({ task: { status: 'completed', result: 'result' } }, {
ownKeys(target) { enumerations++; return Reflect.ownKeys(target) },
})
const first = boundActivityText(records, 1024, 500)
for (let index = 0; index < 100; index++) expect(boundActivityText(records, 1024, 500)).toBe(first)
expect(enumerations).toBe(1)
boundActivityText(records, 512, 500)
expect(enumerations).toBe(2)
boundActivityText(records, 512, 100)
expect(enumerations).toBe(3)
})
})
+132
View File
@@ -0,0 +1,132 @@
import type { UIMessage } from '../types/chat'
export const CHAT_HISTORY_MAX_ROWS = 500
export const CHAT_HISTORY_MAX_BYTES = 2 * 1024 * 1024
export const CHAT_STREAM_MAX_CHARS = 64 * 1024
const MESSAGE_MAX_CHARS = 32 * 1024
const cache = new WeakMap<UIMessage, { message: UIMessage; bytes: number; clipped: boolean }>()
export function copyChatPreview(value: string, maxChars: number, tail = false): string {
if (value.length <= maxChars) return value
// V8 substrings may retain the entire source string's backing storage.
// Round-trip only the bounded slice so the preview owns its small buffer.
const slice = tail ? value.slice(-maxChars) : value.slice(0, maxChars)
return new TextDecoder().decode(new TextEncoder().encode(slice))
}
function previewMessage(message: UIMessage) {
const cached = cache.get(message)
if (cached) return cached
let remaining = MESSAGE_MAX_CHARS
let nodes = 0
let clipped = false
function visit(value: unknown, depth: number): unknown {
if (++nodes > 2048 || depth > 16) {
clipped = true
return null
}
if (typeof value === 'string') {
const allowed = Math.max(0, remaining)
remaining -= Math.min(value.length, allowed)
if (value.length <= allowed) return value
clipped = true
// A partial base64 URI cannot render. Keep its attachment metadata only.
if (value.startsWith('data:')) return undefined
return copyChatPreview(value, allowed)
}
if (!value || typeof value !== 'object') return value
const output: Record<string, unknown> | unknown[] = Array.isArray(value) ? [] : {}
let changed = false
for (const key in value) {
if (!Object.prototype.hasOwnProperty.call(value, key)) continue
if (nodes > 2048 || remaining <= 0) { clipped = true; changed = true; break }
const before = (value as Record<string, unknown>)[key]
const after = visit(before, depth + 1)
const target = output as Record<string, unknown>
target[key] = after
changed ||= before !== after
}
return changed ? output : value
}
// Identity and timestamps stay intact. Only display payloads are shortened;
// permission requests and runtime recovery state live outside these rows.
const result = { ...message } as UIMessage & Record<string, unknown>
for (const key of ['content', 'input', 'modelContent', 'partialInput', 'summary', 'objective', 'message', 'tasks', 'attachments', 'task', 'files']) {
if (key in result) result[key] = visit(result[key], 0)
}
const entry = { message: clipped ? result : message, bytes: 256 + (MESSAGE_MAX_CHARS - remaining) * 2 + nodes * 32, clipped }
cache.set(message, entry)
cache.set(entry.message, entry)
return entry
}
export function boundChatHistory(messages: UIMessage[], budget = CHAT_HISTORY_MAX_BYTES) {
const retained: UIMessage[] = []
let bytes = 0
let clipped = false
let start = messages.length
while (start > 0 && retained.length < CHAT_HISTORY_MAX_ROWS) {
const entry = previewMessage(messages[start - 1]!)
if (bytes + entry.bytes > budget) break
retained.push(entry.message)
bytes += entry.bytes
clipped ||= entry.clipped
start--
}
retained.reverse()
return { messages: start === 0 && retained.every((message, index) => message === messages[index]) ? messages : retained, bytes, dropped: start, clipped: clipped || start > 0 }
}
export const CHAT_TERMINAL_ACTIVITY_MAX_PER_SESSION = 500
export const CHAT_TERMINAL_ACTIVITY_MAX_TOTAL = 4000
const activityCache = new WeakMap<object, { budget: number; terminalLimit: number; result: object }>()
// UI projections retain every active lifecycle, plus recent terminal evidence.
// Runtime/provider input and pending permission requests live elsewhere.
export function boundActivityText<T extends object>(
records: Record<string, T> | undefined,
budget: number,
terminalLimit = CHAT_TERMINAL_ACTIVITY_MAX_PER_SESSION,
): Record<string, T> | undefined {
if (!records) return records
const cached = activityCache.get(records)
if (cached?.budget === budget && cached.terminalLimit === terminalLimit) return cached.result as Record<string, T>
const fields = ['prompt', 'result', 'summary', 'description'] as const
const entries = Object.entries(records)
const terminal = entries.filter(([, entry]) => {
const status = (entry as Record<string, unknown>).status
return status === 'completed' || status === 'failed' || status === 'stopped'
})
let result = records
if (terminal.length > terminalLimit) {
function time(entry: T): number {
const value = entry as Record<string, unknown>
const timestamp = value.updatedAt ?? value.timestamp ?? value.startedAt
const parsed = typeof timestamp === 'number' ? timestamp : typeof timestamp === 'string' ? Date.parse(timestamp) : 0
return Number.isFinite(parsed) ? parsed : 0
}
// Stable sort keeps insertion order for bookends without timestamps.
terminal.sort((a, b) => time(a[1]) - time(b[1]))
const dropped = new Set(terminal.slice(0, terminal.length - terminalLimit).map(([id]) => id))
result = Object.fromEntries(entries.filter(([id]) => !dropped.has(id)))
}
const retained = result === records ? entries : Object.entries(result)
const maxChars = Math.min(16 * 1024, Math.floor(budget / (2 * Math.max(1, retained.length) * fields.length)))
for (const [id, entry] of retained) {
let next = entry
for (const field of fields) {
const value = (entry as Record<string, unknown>)[field]
if (typeof value !== 'string' || value.length <= maxChars) continue
if (next === entry) next = { ...entry }
const target = next as Record<string, unknown>
target[field] = copyChatPreview(value, maxChars)
}
if (next === entry) continue
if (result === records) result = { ...records }
result[id] = next
}
const memo = { budget, terminalLimit, result }
activityCache.set(records, memo)
activityCache.set(result, memo)
return result
}
+19
View File
@@ -191,4 +191,23 @@ describe('fetchTraceCallDetail', () => {
expect(traceCallRequests(fetchMock)).toHaveLength(34)
})
it('does not retain a single oversized semantic request', async () => {
const call = makeCall()
call.request.semantic = { version: 1, request: { prompt: 'x'.repeat(9 * 1024 * 1024) } }
const fetchMock = mockFetch(() => jsonResponse({ call }))
await fetchTraceCallDetail('session-1', 'call-1')
await fetchTraceCallDetail('session-1', 'call-1')
expect(traceCallRequests(fetchMock)).toHaveLength(2)
})
it('evicts by retained bytes before the entry-count limit', async () => {
const fetchMock = mockFetch((url) => {
const call = makeCall({ id: url.split('/').at(-1)! })
call.request.body.preview = 'x'.repeat(3 * 1024 * 1024)
return jsonResponse({ call })
})
for (const id of ['a', 'b', 'c', 'b', 'a']) await fetchTraceCallDetail('session-1', id)
expect(traceCallRequests(fetchMock)).toHaveLength(4)
})
})
+49 -7
View File
@@ -2,7 +2,9 @@ import { sessionsApi } from '../../api/sessions'
import { getBaseUrl } from '../../api/client'
import type { TraceCallRecord } from '../../types/trace'
const callCache = new Map<string, TraceCallRecord>()
const callCache = new Map<string, { call: TraceCallRecord; bytes: number }>()
let cachedBytes = 0
const TRACE_CALL_CACHE_MAX_BYTES = 16 * 1024 * 1024
const TRACE_CALL_CACHE_MAX_ENTRIES = 32
export async function fetchTraceCallDetail(
@@ -16,21 +18,24 @@ export async function fetchTraceCallDetail(
if (cached) {
callCache.delete(key)
callCache.set(key, cached)
return cached
return cached.call
}
for (const existingKey of callCache.keys()) {
if (existingKey.startsWith(prefix)) callCache.delete(existingKey)
if (existingKey.startsWith(prefix)) removeCachedCall(existingKey)
}
try {
const result = await sessionsApi.getTraceCall(sessionId, callId)
const call = result?.call
if (!call) return null
if (isTerminalCall(call)) {
callCache.set(key, call)
while (callCache.size > TRACE_CALL_CACHE_MAX_ENTRIES) {
const bytes = estimateRetainedBytes(call)
if (isTerminalCall(call) && bytes <= TRACE_CALL_CACHE_MAX_BYTES) {
removeCachedCall(key)
callCache.set(key, { call, bytes })
cachedBytes += bytes
while (callCache.size > TRACE_CALL_CACHE_MAX_ENTRIES || cachedBytes > TRACE_CALL_CACHE_MAX_BYTES) {
const oldestKey = callCache.keys().next().value
if (oldestKey === undefined) break
callCache.delete(oldestKey)
removeCachedCall(oldestKey)
}
}
return call
@@ -41,6 +46,7 @@ export async function fetchTraceCallDetail(
export function clearTraceCallCache(): void {
callCache.clear()
cachedBytes = 0
}
function isTerminalCall(call: TraceCallRecord): boolean {
@@ -48,3 +54,39 @@ function isTerminalCall(call: TraceCallRecord): boolean {
if (call.status === 'pending') return false
return Boolean(call.response || call.error)
}
function removeCachedCall(key: string): void {
const entry = callCache.get(key)
if (!entry) return
cachedBytes -= entry.bytes
callCache.delete(key)
}
// Count strings and parsed semantic payloads without serializing a second copy.
// This is a conservative retention budget, not an exact JS heap measurement.
function estimateRetainedBytes(value: unknown): number {
let bytes = 0
const seen = new WeakSet<object>()
function visit(item: unknown, depth: number): void {
if (bytes > TRACE_CALL_CACHE_MAX_BYTES) return
if (typeof item === 'string') bytes += 32 + item.length * 2
else if (item && typeof item === 'object') {
if (seen.has(item)) return
seen.add(item)
if (depth > 128) {
bytes = TRACE_CALL_CACHE_MAX_BYTES + 1
return
}
bytes += 64
for (const key in item) {
if (!Object.prototype.hasOwnProperty.call(item, key)) continue
bytes += 16 + key.length * 2
visit((item as Record<string, unknown>)[key], depth + 1)
if (bytes > TRACE_CALL_CACHE_MAX_BYTES) break
}
} else bytes += 8
}
visit(value, 0)
return bytes
}
+30 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { buildTraceViewModel } from './traceViewModel'
import type { MessageEntry } from '../types/session'
import type { TraceSession } from '../types/trace'
@@ -302,3 +302,32 @@ describe('traceViewModel', () => {
})
})
})
describe('large trace turn lookup', () => {
it('does not rescan all turn timestamps for each history message', () => {
const messages: MessageEntry[] = Array.from({ length: 1000 }, (_, index) => ({
id: `user-${index}`, type: 'user', content: `Turn ${index}`,
timestamp: new Date(1_700_000_000_000 + index * 1000).toISOString(),
}))
const getTime = vi.spyOn(Date.prototype, 'getTime')
try {
const result = buildTraceViewModel({ ...trace, calls: [], events: [] }, messages)
expect(result.spansById.get('message:user-999')?.turnIndex).toBe(999)
// The previous quadratic lookup exceeded a million conversions.
expect(getTime.mock.calls.length).toBeLessThan(100_000)
} finally {
getTime.mockRestore()
}
})
it('preserves the last matching turn for out-of-order and duplicate timestamps', () => {
const messages: MessageEntry[] = [3, 1, 2, 2].map((second, index) => ({
id: `user-${index}`, type: 'user', content: `Turn ${index}`,
timestamp: `2026-06-09T10:00:0${second}.000Z`,
}))
messages.push({ id: 'reply', type: 'assistant', content: 'Reply', timestamp: '2026-06-09T10:00:02.500Z' })
const result = buildTraceViewModel({ ...trace, calls: [], events: [] }, messages)
expect(result.spansById.get('message:reply')?.turnIndex).toBe(3)
})
})
+28 -16
View File
@@ -153,12 +153,13 @@ export function buildTraceViewModel(
})
}
const findTurn = createTurnLookup(turns)
const toolSpanIds = new Map<string, string>()
const resultBlocksByToolUseId = new Map<string, ToolResultOccurrence[]>()
const deferredToolResultSpans: Array<{ parentId: string; message: MessageEntry; block: ToolResultBlock }> = []
for (const message of messages) {
const turn = findTurnForTimestamp(turns, message.timestamp)
const turn = findTurn(message.timestamp)
const turnId = turn.id
if (message.type === 'tool_result') {
@@ -238,14 +239,14 @@ export function buildTraceViewModel(
}
for (const item of deferredToolResultSpans) {
const turn = findTurnForTimestamp(turns, item.message.timestamp)
const turn = findTurn(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 turn = findTurn(call.startedAt)
const spanId = `llm:${call.id}`
const status = getCallStatus(call)
const derivedCallTiming = call.durationMs !== undefined
@@ -274,7 +275,7 @@ export function buildTraceViewModel(
}
for (const event of traceEvents) {
const turn = findTurnForTimestamp(turns, event.timestamp)
const turn = findTurn(event.timestamp)
const callParentId = event.callId ? `llm:${event.callId}` : undefined
const parentId = callParentId && spans.has(callParentId) ? callParentId : turn.id
const spanId = `event:${event.id}`
@@ -353,9 +354,8 @@ function buildDiagnosis(
.sort((a, b) => compareSpanActivityTime(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 lastTurnIds = new Set(lastTurn?.spanIds ?? [])
const lastTurnSpans = spans.filter((span) => lastTurnIds.has(span.id))
const lastTurnHasUser = lastTurnSpans.some((span) => span.message?.type === 'user')
const lastTurnHasAgentWork = lastTurnSpans.some((span) =>
span.kind === 'llm' ||
@@ -655,17 +655,29 @@ function createTurnTitle(index: number, content: unknown): string {
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
function createTurnLookup(turns: TraceTurn[]): (timestamp: string) => TraceTurn {
// Preserve last-matching-turn semantics for out-of-order child activity
// without scanning every turn for every message, call, and event.
const entries = turns.map((turn, index) => ({ time: new Date(turn.timestamp).getTime(), index }))
.filter((entry) => Number.isFinite(entry.time))
.sort((a, b) => a.time - b.time)
let latestIndex = 0
const latestTurns = entries.map((entry) => {
latestIndex = Math.max(latestIndex, entry.index)
return turns[latestIndex]!
})
return (timestamp) => {
const time = new Date(timestamp).getTime()
if (!Number.isFinite(time)) return turns[0]!
let lo = 0
let hi = entries.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
if (entries[mid]!.time <= time) lo = mid + 1
else hi = mid
}
return lo === 0 ? turns[0]! : latestTurns[lo - 1]!
}
return current
}
function earliestTimestamp(
@@ -463,6 +463,16 @@ describe('SubagentRunPage', () => {
expect(screen.queryByTestId('conversation-navigator')).not.toBeInTheDocument()
})
it('does not replace authoritative activity with a partial transcript tail', async () => {
const tabId = `${SUBAGENT_TAB_PREFIX}session-1__tool-1`
const tasks = { running: { taskId: 'running', toolUseId: 'owned-tool', status: 'running' as const, description: 'Live owned task', startedAt: 1, updatedAt: 2 } }
useChatStore.setState({ sessions: { [tabId]: { ...createDefaultSessionState(), backgroundAgentTasks: tasks } } })
vi.mocked(subagentsApi.getRunByTool).mockResolvedValue(subagentRun({ activityComplete: false, historyComplete: false, truncated: true }))
render(<SubagentRunPage sourceSessionId="session-1" toolUseId="tool-1" title="SubAgent" />)
await screen.findByTestId('subagent-conversation')
expect(useChatStore.getState().sessions[tabId]?.backgroundAgentTasks).toBe(tasks)
})
it('auto-opens the owning SubAgent Task and Bash activity without another click', async () => {
vi.mocked(subagentsApi.getRunByTool).mockResolvedValue(subagentRun({
status: 'running',
@@ -2135,6 +2145,7 @@ describe('SubagentRunPage', () => {
useChatStore.setState({
sessions: {
'session-1': {
...createDefaultSessionState(),
backgroundAgentTasks: {
'agent-1': {
taskId: 'agent-1',
+7 -4
View File
@@ -153,11 +153,11 @@ export function SubagentRunPage({
// The server only sends `activityMessages` when it differs from `messages`
// (truncation); below that threshold `messages` is the same projection.
const projection = data?.activityMessages ?? data?.messages
if (!projection) return undefined
if (!projection || data?.activityComplete === false) return undefined
return mapHistoryMessagesToUiMessages(projection, {
includeTeammateMessages: true,
})
}, [data?.activityMessages, data?.messages])
}, [data?.activityMessages, data?.messages, data?.activityComplete])
const handleReturn = () => {
const store = useTabStore.getState()
@@ -216,7 +216,7 @@ export function SubagentRunPage({
data.activityMessages ?? data.messages,
data.activityTaskNotifications ?? data.taskNotifications ?? [],
)
useChatStore.setState((state) => {
useChatStore.getState().applyBoundedUpdate((state) => {
const existing = state.sessions[tabId] ?? createDefaultSessionState()
const localMessages = existing.messages.filter((message) => {
if (message.type === 'error' && message.code === 'SUBAGENT_MESSAGE_FAILED') {
@@ -229,7 +229,10 @@ export function SubagentRunPage({
const hasPendingMessage = localMessages.some((message) => (
message.type === 'user_text' && message.pending === true
))
const mergedActivity = mergeReconstructedRunActivity({
const mergedActivity = data.activityComplete === false ? {
agentTaskNotifications: existing.agentTaskNotifications ?? {},
backgroundAgentTasks: existing.backgroundAgentTasks ?? {},
} : mergeReconstructedRunActivity({
agentTaskNotifications: existing.agentTaskNotifications ?? {},
backgroundAgentTasks: existing.backgroundAgentTasks ?? {},
}, runActivity, {
+41 -6
View File
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TraceList } from './TraceList'
@@ -104,11 +104,46 @@ describe('TraceList', () => {
useSettingsStore.setState({ locale: 'en' })
})
it('keeps one poll in flight and aborts it when the settings page closes', async () => {
vi.useFakeTimers()
try {
let signal: AbortSignal | undefined
vi.mocked(tracesApi.list).mockResolvedValueOnce(traceList).mockImplementationOnce((_query, options) => {
signal = options?.signal
return new Promise(() => {})
})
const view = render(<TraceList />)
await act(async () => { await Promise.resolve() })
await act(async () => { await vi.advanceTimersByTimeAsync(20_000) })
expect(tracesApi.list).toHaveBeenCalledTimes(2)
expect(signal?.aborted).toBe(false)
view.unmount()
expect(signal?.aborted).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('aborts an obsolete search and ignores its late response', async () => {
let firstSignal: AbortSignal | undefined
let resolveFirst!: (data: TraceSessionList) => void
vi.mocked(tracesApi.list).mockImplementationOnce((_query, options) => {
firstSignal = options?.signal
return new Promise((resolve) => { resolveFirst = resolve })
}).mockResolvedValueOnce(secondTraceList)
render(<TraceList />)
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'second' } })
await findTraceRow(/Second trace session/)
expect(firstSignal?.aborted).toBe(true)
await act(async () => { resolveFirst(traceList) })
expect(screen.queryByRole('listitem', { name: /Debug stuck agent/ })).not.toBeInTheDocument()
})
it('renders rows with title, model chips, failure count and metrics', async () => {
render(<TraceList />)
const row = await findTraceRow(/Debug stuck agent/)
expect(tracesApi.list).toHaveBeenCalledWith({ limit: 50, offset: 0, query: '' })
expect(tracesApi.list).toHaveBeenCalledWith({ limit: 50, offset: 0, query: '' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
// header: storage dir + collection badge + aggregate chips
expect(screen.getByText('/tmp/cc-haha/traces')).toBeInTheDocument()
@@ -230,7 +265,7 @@ describe('TraceList', () => {
await waitFor(() => {
expect(screen.queryByText('Debug stuck agent')).not.toBeInTheDocument()
})
expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 0, query: '' })
expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 0, query: '' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
expect(useTabStore.getState().activeTabId).toBeNull()
})
@@ -273,8 +308,8 @@ describe('TraceList', () => {
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: '' })
expect(tracesApi.list).toHaveBeenNthCalledWith(1, { limit: 50, offset: 0, query: '' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 1, query: '' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
})
it('sends title search text to the trace list API', async () => {
@@ -286,7 +321,7 @@ describe('TraceList', () => {
})
await waitFor(() => {
expect(tracesApi.list).toHaveBeenLastCalledWith({ limit: 50, offset: 0, query: 'stuck agent' })
expect(tracesApi.list).toHaveBeenLastCalledWith({ limit: 50, offset: 0, query: 'stuck agent' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
})
})
+19 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { KeyboardEvent } from 'react'
import { ExternalLink, RefreshCw, Trash2, Workflow } from 'lucide-react'
import { tracesApi } from '../api/traces'
@@ -35,6 +35,7 @@ export function TraceList() {
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<TraceSessionListItem | null>(null)
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null)
const activeRequest = useRef<AbortController | null>(null)
const host = getDesktopHost()
useEffect(() => {
@@ -50,16 +51,20 @@ export function TraceList() {
offset?: number
silent?: boolean
}) => {
if (options?.silent && activeRequest.current) return
activeRequest.current?.abort()
const controller = new AbortController()
activeRequest.current = controller
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) {
setIsLoadingMore(append)
if (!append && !options?.silent) {
setState({ status: 'loading' })
}
const data = await tracesApi.list({ limit, offset, query })
const data = await tracesApi.list({ limit, offset, query }, { signal: controller.signal })
if (controller.signal.aborted || activeRequest.current !== controller) return
setState((previous) => {
if (!append || previous.status !== 'ready') {
return { status: 'ready', data }
@@ -73,17 +78,25 @@ export function TraceList() {
}
})
} catch (error) {
if (controller.signal.aborted || activeRequest.current !== controller) return
setState({
status: 'error',
message: error instanceof Error ? error.message : t('trace.list.loadFailed'),
})
} finally {
if (append) setIsLoadingMore(false)
if (activeRequest.current === controller) {
activeRequest.current = null
setIsLoadingMore(false)
}
}
}, [query, t])
useEffect(() => {
void load()
return () => {
activeRequest.current?.abort()
activeRequest.current = null
}
}, [load])
useEffect(() => {
+67 -13
View File
@@ -15,7 +15,7 @@ import type { TraceCallRecord, TraceSession as TraceSessionData } from '../types
vi.mock('../api/sessions', () => ({
sessionsApi: {
getTrace: vi.fn(),
getMessages: vi.fn(),
getHistoryPage: vi.fn(),
getTraceCall: vi.fn(),
},
}))
@@ -173,7 +173,7 @@ describe('TraceSession', () => {
window.localStorage.clear()
useSettingsStore.setState({ locale: 'en' })
vi.mocked(sessionsApi.getTrace).mockResolvedValue(baseTrace)
vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: baseMessages })
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValue({ messages: baseMessages })
vi.mocked(sessionsApi.getTraceCall).mockResolvedValue({ call: fullCall })
vi.mocked(tracesApi.getRevision).mockResolvedValue({
sessionId: SESSION_ID,
@@ -205,6 +205,25 @@ describe('TraceSession', () => {
useSettingsStore.setState({ locale: 'en' })
})
it('does not attribute old trace calls to an unrelated partial transcript tail', async () => {
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValue({ messages: [{ id: 'tail', type: 'user', timestamp: '2026-06-10T00:00:00Z', content: 'Unrelated later question' }], page: { historyComplete: false, hasMore: true, nextCursor: 'older', sourceVersion: 'v1', scannedBytes: 1024, omittedOversizedEntries: 0 } })
await renderReady()
expect(screen.queryByText('Unrelated later question')).not.toBeInTheDocument()
expect(screen.getByTestId('trace-split-layout')).toBeInTheDocument()
})
it('replaces bounded trace pages and forwards snapshot and scan cursors', async () => {
const window = { offset: 0, limit: 100, totalCalls: 201, totalEvents: 0, hasMore: true, revisionToken: 'snapshot-v1', state: 'limited' as const, oversizedRecords: 1, startByte: 0, scannedBytes: 64 * 1024 * 1024, fileBytes: 500 * 1024 * 1024, recordLimit: 10000, recordBytesLimit: 2 * 1024 * 1024, nextScanCursor: 'next-segment' }
vi.mocked(sessionsApi.getTrace).mockResolvedValue({ ...baseTrace, window })
await renderReady()
fireEvent.click(screen.getByRole('button', { name: /^Next page$/ }))
await waitFor(() => expect(sessionsApi.getTrace).toHaveBeenLastCalledWith(SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }), { offset: 100, revisionToken: 'snapshot-v1' }))
await screen.findByRole('button', { name: 'Scan next segment' })
fireEvent.click(screen.getByRole('button', { name: 'Scan next segment' }))
await waitFor(() => expect(sessionsApi.getTrace).toHaveBeenLastCalledWith(SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }), { scanCursor: 'next-segment' }))
expect(screen.queryByText('Some records exceed the viewing limit; the original file is unchanged.')).toBeInTheDocument()
})
it('renders the two-pane layout with tree and detail', async () => {
await renderReady()
@@ -232,7 +251,7 @@ describe('TraceSession', () => {
})
it('groups timeline rows by turn with user message previews', async () => {
vi.mocked(sessionsApi.getMessages).mockResolvedValue({
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValue({
messages: [
...baseMessages,
{ id: 'msg-5', type: 'user', content: 'Second question', timestamp: '2026-06-09T10:05:00.000Z' },
@@ -512,18 +531,18 @@ describe('TraceSession', () => {
fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5'))
await waitFor(() => expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1))
expect(sessionsApi.getTrace).toHaveBeenCalledTimes(1)
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1)
expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(1)
for (const expectedTraceCalls of [2, 3]) {
await act(async () => { await vi.advanceTimersByTimeAsync(20) })
expect(sessionsApi.getTrace).toHaveBeenCalledTimes(expectedTraceCalls)
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1)
expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(1)
expect(screen.queryByText('claude-sonnet-4-5 x2')).not.toBeInTheDocument()
}
await act(async () => { await vi.advanceTimersByTimeAsync(20) })
expect(sessionsApi.getTrace).toHaveBeenCalledTimes(4)
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2)
expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(2)
expect(screen.getByText('claude-sonnet-4-5 x2')).toBeInTheDocument()
expect(vi.mocked(sessionsApi.getTraceCall).mock.calls.length).toBeGreaterThan(1)
const detail = within(screen.getByTestId('trace-detail'))
@@ -541,7 +560,42 @@ describe('TraceSession', () => {
await waitFor(() => expect(vi.mocked(tracesApi.getRevision).mock.calls.length).toBeGreaterThanOrEqual(3))
expect(sessionsApi.getTrace).toHaveBeenCalledTimes(1)
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1)
expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(1)
})
it('reuses message history when only trace calls change', async () => {
vi.mocked(sessionsApi.getTrace)
.mockResolvedValueOnce({ ...baseTrace, messageSignature: 'unchanged-transcript' })
.mockResolvedValue({
...baseTrace,
messageSignature: 'unchanged-transcript',
summary: { ...baseTrace.summary, models: [{ model: 'updated-model', calls: 1 }] },
})
vi.mocked(tracesApi.getRevision)
.mockResolvedValueOnce({ sessionId: SESSION_ID, revision: 1, changed: true, reset: false })
.mockResolvedValue({ sessionId: SESSION_ID, revision: 2, changed: true, reset: false })
await renderReady(20)
expect(await screen.findByText('updated-model x1')).toBeInTheDocument()
expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(1)
})
it('aborts in-flight trace data requests when the page is closed', async () => {
vi.mocked(sessionsApi.getTrace).mockImplementation(() => new Promise(() => {}))
const { unmount } = render(<TraceSession sessionId={SESSION_ID} />)
const signal = vi.mocked(sessionsApi.getTrace).mock.calls[0]?.[1]?.signal
expect(signal?.aborted).toBe(false)
unmount()
expect(signal?.aborted).toBe(true)
})
it('aborts an outstanding revision probe when the page closes', async () => {
vi.mocked(tracesApi.getRevision).mockImplementation(() => new Promise(() => {}))
const { unmount } = render(<TraceSession sessionId={SESSION_ID} />)
await waitFor(() => expect(tracesApi.getRevision).toHaveBeenCalled())
const signal = vi.mocked(tracesApi.getRevision).mock.calls[0]?.[3]?.signal
expect(signal?.aborted).toBe(false)
unmount()
expect(signal?.aborted).toBe(true)
})
it('uses the revision cursor for unchanged polls without refetching the full trace', async () => {
@@ -549,8 +603,8 @@ describe('TraceSession', () => {
await waitFor(() => expect(vi.mocked(tracesApi.getRevision).mock.calls.length).toBeGreaterThanOrEqual(2))
expect(sessionsApi.getTrace).toHaveBeenCalledTimes(1)
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1)
expect(tracesApi.getRevision).toHaveBeenLastCalledWith(SESSION_ID, 1, undefined)
expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(1)
expect(tracesApi.getRevision).toHaveBeenLastCalledWith(SESSION_ID, 1, undefined, expect.objectContaining({ signal: expect.any(AbortSignal) }))
})
it('serializes slow revision probes so stale polls cannot overwrite newer state', async () => {
@@ -589,7 +643,7 @@ describe('TraceSession', () => {
vi.mocked(tracesApi.getRevision)
.mockResolvedValueOnce({ sessionId: SESSION_ID, revision: 1, changed: true, reset: false })
.mockResolvedValue({ sessionId: SESSION_ID, revision: 2, changed: true, reset: false })
vi.mocked(sessionsApi.getMessages)
vi.mocked(sessionsApi.getHistoryPage)
.mockResolvedValueOnce({ messages: pendingMessages })
.mockReturnValue(refreshedMessages)
@@ -599,7 +653,7 @@ describe('TraceSession', () => {
fireEvent.click(tree.getByText('Bash'))
expect(within(screen.getByTestId('trace-detail')).queryByText('file.txt')).not.toBeInTheDocument()
await waitFor(() => expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2))
await waitFor(() => expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(2))
resolveRefreshedMessages({ messages: baseMessages })
expect(await within(screen.getByTestId('trace-detail')).findByText('file.txt')).toBeInTheDocument()
})
@@ -774,7 +828,7 @@ describe('TraceSession', () => {
},
calls: [],
})
vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: [] })
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValue({ messages: [] })
render(<TraceSession sessionId={SESSION_ID} pollIntervalMs={60_000} />)
@@ -881,7 +935,7 @@ describe('TraceSession', () => {
})
it('says what an assistant turn did when the provider withheld its reasoning', async () => {
vi.mocked(sessionsApi.getMessages).mockResolvedValue({
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValue({
messages: [
baseMessages[0]!,
{
+41 -8
View File
@@ -62,6 +62,7 @@ export function TraceSession({
const [state, setState] = useState<LoadState>({ status: 'loading' })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [refreshNonce, setRefreshNonce] = useState(0)
const [tracePage, setTracePage] = useState<{ offset?: number; revisionToken?: string; scanCursor?: string }>({})
const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [clockNowMs, setClockNowMs] = useState(() => Date.now())
@@ -71,6 +72,9 @@ export function TraceSession({
useEffect(() => {
let cancelled = false
const controller = new AbortController()
let loadedMessages: MessageEntry[] | undefined
let loadedMessageSignature: string | null | undefined
let loadInFlight = false
let revisionPollingAvailable = true
let currentRevision: number | undefined
@@ -92,6 +96,7 @@ export function TraceSession({
sessionId,
currentRevision,
currentRevisionToken,
{ signal: controller.signal },
)
if (cancelled || requestGeneration !== revisionRequestGeneration) return
if (!revision.changed) {
@@ -108,14 +113,14 @@ export function TraceSession({
}
if (silent) setRefreshing(true)
const trace = await sessionsApi.getTrace(sessionId)
const trace = await sessionsApi.getTrace(sessionId, { signal: controller.signal }, tracePage)
if (!isTraceSessionData(trace)) {
throw new Error(t('trace.snapshotEmpty'))
}
if (cancelled) return
if (!silent && revisionPollingAvailable) {
const requestGeneration = ++revisionRequestGeneration
void tracesApi.getRevision(sessionId).then((revision) => {
void tracesApi.getRevision(sessionId, undefined, undefined, { signal: controller.signal }).then((revision) => {
if (cancelled || requestGeneration !== revisionRequestGeneration) return
currentRevision = revision.revision
currentRevisionToken = revision.revisionToken
@@ -133,10 +138,22 @@ export function TraceSession({
}
const signature = traceSnapshotSignature(trace)
if (silent && snapshotSignatureRef.current === signature) return
const messageResponse = await sessionsApi.getMessages(sessionId).catch(() => ({ messages: [] }))
// Trace calls/events often advance without any transcript mutation.
// Reuse the transcript until its independent revision changes.
if (!loadedMessages || trace.messageSignature == null || trace.messageSignature !== loadedMessageSignature) {
const messageResponse = await sessionsApi.getHistoryPage(sessionId, {}, { signal: controller.signal })
.catch(() => null)
if (cancelled) return
if (messageResponse) {
// A tail page cannot assign historical calls to their original turns.
// Keep those calls under session activity instead of inventing an association.
loadedMessages = messageResponse.page?.historyComplete === false ? [] : messageResponse.messages
loadedMessageSignature = trace.messageSignature
}
}
if (cancelled) return
snapshotSignatureRef.current = signature
setState({ status: 'ready', trace, messages: messageResponse.messages })
snapshotSignatureRef.current = loadedMessages ? signature : null
setState({ status: 'ready', trace, messages: loadedMessages ?? [] })
setClockNowMs(Date.now())
setLastLoadedAt(new Date().toISOString())
} catch (error) {
@@ -154,16 +171,21 @@ export function TraceSession({
lastSpanIdRef.current = null
void load(false)
const interval = window.setInterval(() => {
void load(true)
// Keep a historical page stable. Returning to the first page resumes live polling.
if (!tracePage.offset && !tracePage.scanCursor) void load(true)
}, pollIntervalMs)
return () => {
cancelled = true
controller.abort()
window.clearInterval(interval)
}
}, [sessionId, refreshNonce, pollIntervalMs, t])
}, [sessionId, refreshNonce, pollIntervalMs, t, tracePage])
const refresh = () => setRefreshNonce((value) => value + 1)
const refresh = () => {
setTracePage({})
setRefreshNonce((value) => value + 1)
}
const openWindow = () => {
const host = getDesktopHost()
@@ -281,6 +303,17 @@ export function TraceSession({
refreshing={refreshing}
updatedAt={lastLoadedAt}
/>
{trace.window && (
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--color-border)] px-4 py-2 text-xs" role="status">
<span>{t('trace.windowNotice')}</span>
<span>{trace.window.offset + 1}–{trace.window.offset + Math.max(trace.calls.length, trace.events?.length ?? 0)} / {Math.max(trace.window.totalCalls, trace.window.totalEvents)}</span>
{(trace.window.state === 'limited' || trace.window.oversizedRecords > 0) && <span>{t('trace.windowLimited')}</span>}
<Button size="sm" variant="ghost" disabled={!tracePage.offset && !tracePage.scanCursor} onClick={() => setTracePage({})}>{t('trace.windowFirst')}</Button>
<Button size="sm" variant="ghost" disabled={!tracePage.offset} onClick={() => setTracePage({ ...tracePage, offset: Math.max(0, (tracePage.offset ?? 0) - trace.window!.limit) })}>{t('trace.windowPrevious')}</Button>
<Button size="sm" variant="ghost" disabled={!trace.window.hasMore} onClick={() => setTracePage({ ...tracePage, offset: trace.window!.offset + trace.window!.limit, revisionToken: trace.window!.revisionToken })}>{t('trace.windowNext')}</Button>
{trace.window.nextScanCursor && <Button size="sm" variant="ghost" onClick={() => setTracePage({ scanCursor: trace.window!.nextScanCursor })}>{t('trace.windowScanNext')}</Button>}
</div>
)}
<DiagnosisBanner viewModel={viewModel} onSelect={setSelectedId} />
{hasTraceContent && activeSpan ? (
<div className="flex min-h-0 flex-1 flex-col">
+196 -12
View File
@@ -105,6 +105,8 @@ vi.mock('../api/websocket', () => ({
vi.mock('../api/sessions', () => ({
sessionsApi: {
getMessages: vi.fn(async () => ({ messages: [] })),
getHistoryPage: vi.fn(async () => ({ messages: [] })),
getHistoryRecovery: vi.fn(async () => ({ status: 'incomplete', messages: [] })),
getSlashCommands: vi.fn(async () => ({ commands: [] })),
},
}))
@@ -1905,7 +1907,7 @@ describe('chatStore history mapping', () => {
const historyLoad = useChatStore.getState().loadHistory(TEST_SESSION_ID)
await vi.waitFor(() => {
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }))
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
@@ -2831,7 +2833,7 @@ describe('chatStore history mapping', () => {
const historyLoad = useChatStore.getState().loadHistory(TEST_SESSION_ID)
await vi.waitFor(() => {
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }))
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
@@ -8967,7 +8969,7 @@ describe('chatStore history mapping', () => {
useChatStore.getState().connectToSession(TEST_SESSION_ID)
await Promise.resolve()
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }))
expect(sendMock).not.toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'prewarm_session' })
})
@@ -11772,7 +11774,7 @@ describe('chatStore history mapping', () => {
)
})
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }))
expect(session?.streamingText).toBe('')
expect(session?.messages).toContainEqual(expect.objectContaining({
type: 'assistant_text',
@@ -11826,7 +11828,7 @@ describe('chatStore history mapping', () => {
?.backgroundAgentTasks?.['agent-task-1']?.status,
).toBe('stopped')
})
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }))
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'idle')
})
@@ -11862,7 +11864,7 @@ describe('chatStore history mapping', () => {
})
await vi.waitFor(() => {
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }))
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
})
expect(
@@ -11903,7 +11905,7 @@ describe('chatStore history mapping', () => {
})
await vi.waitFor(() => {
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }))
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'idle')
})
expect(
@@ -11944,7 +11946,7 @@ describe('chatStore history mapping', () => {
})
await vi.waitFor(() => {
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }))
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'idle')
})
expect(
@@ -11993,7 +11995,7 @@ describe('chatStore history mapping', () => {
})
await vi.waitFor(() => {
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }))
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
})
expect(
@@ -12179,7 +12181,7 @@ describe('chatStore history mapping', () => {
turnState: 'idle',
})
await vi.waitFor(() => {
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }))
})
useChatStore.getState().sendMessage(TEST_SESSION_ID, 'new turn')
@@ -12594,7 +12596,7 @@ describe('chatStore history mapping', () => {
turnState: 'idle',
})
await vi.waitFor(() => {
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }))
})
useChatStore.getState().sendMessage(TEST_SESSION_ID, 'new turn')
@@ -12640,7 +12642,7 @@ describe('chatStore history mapping', () => {
turnState: 'running',
})
await vi.waitFor(() => {
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ signal: expect.any(AbortSignal) }))
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]).toMatchObject({
@@ -15129,6 +15131,188 @@ describe('chatStore activity state survival across reload paths', () => {
expect(session?.agentTaskNotifications).toEqual(notifications)
})
it('shows a bounded page immediately and restores state independently without treating the tail as authoritative', async () => {
const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 1024, omittedOversizedEntries: 0 }
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages: [{ id: 'tail', type: 'assistant', content: 'recent', timestamp: '2026-01-01T00:00:00Z' }], page })
let resolveRecovery!: (value: Awaited<ReturnType<typeof sessionsApi.getHistoryRecovery>>) => void
vi.mocked(sessionsApi.getHistoryRecovery).mockReturnValueOnce(new Promise((resolve) => { resolveRecovery = resolve }))
const usage = { input_tokens: 321, output_tokens: 123 }
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ tokenUsage: usage }) } })
await useChatStore.getState().loadHistory(TEST_SESSION_ID)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toEqual(expect.arrayContaining([expect.objectContaining({ content: 'recent' })]))
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.tokenUsage).toBe(usage)
expect(setTasksFromTodosMock).not.toHaveBeenCalled()
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyRecoveryStatus).toBe('loading')
resolveRecovery({ status: 'ready', sourceVersion: 'v1', omittedRecords: 0, messages: [], tokenUsage: { input_tokens: 999, output_tokens: 888 } })
await vi.waitFor(() => expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyRecoveryStatus).toBe('ready'))
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.tokenUsage).toEqual({ input_tokens: 999, output_tokens: 888 })
})
it('cancels stale background recovery before an authoritative history reload', async () => {
const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 10, omittedOversizedEntries: 0 }
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages: [], page })
let resolveRecovery!: (value: Awaited<ReturnType<typeof sessionsApi.getHistoryRecovery>>) => void
let signal: AbortSignal | undefined
vi.mocked(sessionsApi.getHistoryRecovery).mockImplementationOnce((_id, options) => {
signal = options?.signal
return new Promise((resolve) => { resolveRecovery = resolve })
})
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession() } })
await useChatStore.getState().loadHistory(TEST_SESSION_ID)
await useChatStore.getState().reloadHistory(TEST_SESSION_ID)
expect(signal?.aborted).toBe(true)
resolveRecovery({ status: 'ready', sourceVersion: 'v1', messages: [], tokenUsage: { input_tokens: 999, output_tokens: 999 }, omittedRecords: 0 })
await Promise.resolve()
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.tokenUsage).toEqual({ input_tokens: 0, output_tokens: 0 })
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyRecoveryStatus).toBe('ready')
})
it('restores complete recovery fields from a newer append without clearing incomplete fields', async () => {
const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: '1:2:10:1', scannedBytes: 10, omittedOversizedEntries: 0 }
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages: [], page })
vi.mocked(sessionsApi.getHistoryRecovery).mockResolvedValueOnce({
status: 'incomplete', sourceVersion: '1:2:20:2', omittedRecords: 1, messages: [],
completeness: { usage: true, goal: false, todos: false, activity: false },
tokenUsage: { input_tokens: 999, output_tokens: 888 },
})
const goal = { action: 'created' as const, objective: 'Preserve live goal', updatedAt: 1 }
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ activeGoal: goal }) } })
await useChatStore.getState().loadHistory(TEST_SESSION_ID)
await vi.waitFor(() => expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyRecoveryStatus).toBe('incomplete'))
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.tokenUsage).toEqual({ input_tokens: 999, output_tokens: 888 })
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toBe(goal)
expect(setTasksFromTodosMock).not.toHaveBeenCalled()
})
it('reads older pages into a separate display window without rewriting live task state', async () => {
const live = [{ id: 'live', type: 'assistant_text' as const, content: 'Live', timestamp: 1 }]
const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 1024, omittedOversizedEntries: 0 }
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ messages: live, chatState: 'thinking', historyPage: page }) } })
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValueOnce({ messages: [{ id: 'old', type: 'user', content: 'old page', timestamp: '2020-01-01T00:00:00Z' }], page: { ...page, nextCursor: null, hasMore: false } })
await useChatStore.getState().loadOlderHistory(TEST_SESSION_ID)
const current = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(current?.messages).toBe(live)
expect(current?.chatState).toBe('thinking')
expect(current?.historyBrowseMessages).toEqual(expect.arrayContaining([expect.objectContaining({ content: 'old page' })]))
expect(setTasksFromTodosMock).not.toHaveBeenCalled()
})
it.each(['loadHistory', 'reloadHistory'] as const)('cancels an older page before %s replaces the current history', async (action) => {
const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 10, omittedOversizedEntries: 0 }
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ historyPage: page }) } })
let resolvePage!: (value: Awaited<ReturnType<typeof sessionsApi.getHistoryPage>>) => void
let signal: AbortSignal | undefined
vi.mocked(sessionsApi.getHistoryPage).mockImplementationOnce((_id, _page, options) => {
signal = options?.signal
return new Promise((resolve) => { resolvePage = resolve })
})
const pending = useChatStore.getState().loadOlderHistory(TEST_SESSION_ID)
await useChatStore.getState()[action](TEST_SESSION_ID)
expect(signal?.aborted).toBe(true)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyPageLoading).toBe(false)
resolvePage({ messages: [{ id: 'stale', type: 'assistant', content: 'stale older page', timestamp: '2026-01-01T00:00:00Z' }], page })
await pending
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyBrowseMessages).toBeUndefined()
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyPage).toBeUndefined()
})
it('bounds external session projections and activity text without changing task control state or permission input', () => {
const raw = 'x'.repeat(2_000_000)
const task = { taskId: 'task', toolUseId: 'tool', status: 'running' as const, startedAt: 1, updatedAt: 2, prompt: raw, result: raw, summary: raw }
const permission = { requestId: 'permission', toolName: 'Bash', input: { command: raw } }
useChatStore.getState().applyBoundedUpdate(() => ({ sessions: Object.fromEntries(Array.from({ length: 20 }, (_, index) => [String(index), makeSession({
messages: Array.from({ length: 600 }, (_, row) => ({ id: String(row), type: 'assistant_text', content: 'x'.repeat(30_000), timestamp: row })),
backgroundAgentTasks: { task },
agentTaskNotifications: { tool: { taskId: 'task', toolUseId: 'tool', status: 'completed', result: raw, summary: raw } },
pendingPermission: permission,
})])) }))
let retained = 0
for (const session of Object.values(useChatStore.getState().sessions)) {
expect(session.messages.length).toBeLessThanOrEqual(500)
expect(session.backgroundAgentTasks?.task).toMatchObject({ taskId: 'task', toolUseId: 'tool', status: 'running', startedAt: 1, updatedAt: 2 })
expect(session.pendingPermission).toBe(permission)
retained += JSON.stringify(session.messages).length * 2
retained += JSON.stringify(session.backgroundAgentTasks).length * 2
retained += JSON.stringify(session.agentTaskNotifications).length * 2
}
expect(retained).toBeLessThan(16 * 1024 * 1024)
expect(task.result).toHaveLength(2_000_000)
expect(permission.input.command).toHaveLength(2_000_000)
})
it('shares a terminal activity count budget across twenty sessions without dropping active lifecycles', () => {
const sessions = Object.fromEntries(Array.from({ length: 20 }, (_, sessionIndex) => {
const tasks = Object.fromEntries(Array.from({ length: 700 }, (_, index) => [String(index), { taskId: String(index), toolUseId: String(index), status: 'completed' as const, startedAt: index, updatedAt: index }]))
const notifications = Object.fromEntries(Array.from({ length: 700 }, (_, index) => [String(index), { taskId: String(index), toolUseId: String(index), status: 'completed' as const, timestamp: new Date(index).toISOString() }]))
return [String(sessionIndex), makeSession({ backgroundAgentTasks: { ...tasks, active: { taskId: 'active', status: 'running', startedAt: 0, updatedAt: 0 } }, agentTaskNotifications: notifications })]
}))
useChatStore.getState().applyBoundedUpdate(() => ({ sessions }))
let terminalCount = 0
for (const session of Object.values(useChatStore.getState().sessions)) {
expect(session.backgroundAgentTasks?.active?.status).toBe('running')
expect(session.backgroundAgentTasks?.['699']?.status).toBe('completed')
expect(session.agentTaskNotifications['699']?.status).toBe('completed')
terminalCount += Object.values(session.backgroundAgentTasks ?? {}).filter((task) => task.status !== 'running').length
terminalCount += Object.keys(session.agentTaskNotifications).length
}
expect(terminalCount).toBe(4000)
const before = useChatStore.getState().sessions['0']!
useChatStore.getState().handleServerMessage('0', { type: 'content_delta', text: 'live text' })
expect(useChatStore.getState().sessions['0']?.backgroundAgentTasks).toBe(before.backgroundAgentTasks)
expect(useChatStore.getState().sessions['0']?.agentTaskNotifications).toBe(before.agentTaskNotifications)
})
it('shares a bounded display budget across many running sessions', () => {
const sessions = Object.fromEntries(Array.from({ length: 20 }, (_, sessionIndex) => [
`budget-${sessionIndex}`,
makeSession({ chatState: 'thinking', messages: Array.from({ length: 60 }, (_, index) => ({ id: `${sessionIndex}-${index}`, type: 'assistant_text', content: 'x'.repeat(30_000), timestamp: index })) }),
]))
useChatStore.setState({ sessions })
useChatStore.getState().handleServerMessage('budget-0', { type: 'status', state: 'thinking', verb: 'Working' })
const retained = Object.values(useChatStore.getState().sessions)
expect(retained.every((session) => session.chatState === 'thinking')).toBe(true)
const retainedCharacters = retained.reduce((total, session) => total + session.messages.reduce((sum, message) => sum + ('content' in message && typeof message.content === 'string' ? message.content.length : 0), 0), 0)
expect(retainedCharacters * 2).toBeLessThanOrEqual(16 * 1024 * 1024)
})
it('bounds live message retention while preserving operational state', () => {
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ messages: Array.from({ length: 2000 }, (_, index) => ({ id: String(index), type: 'assistant_text', content: 'old', timestamp: index })), chatState: 'thinking' }) } })
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'status', state: 'thinking', verb: 'Working' })
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.messages.length).toBeLessThanOrEqual(500)
expect(session?.historyWindowed).toBe(true)
expect(session?.chatState).toBe('thinking')
})
it('aborts a pending history download on disconnect', async () => {
let requestSignal: AbortSignal | undefined
vi.mocked(sessionsApi.getMessages).mockImplementationOnce((_id, options) => new Promise((_resolve, reject) => {
requestSignal = options?.signal
requestSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))
}))
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession() } })
const pending = useChatStore.getState().loadHistory(TEST_SESSION_ID)
expect(requestSignal?.aborted).toBe(false)
useChatStore.getState().disconnectSession(TEST_SESSION_ID)
expect(requestSignal?.aborted).toBe(true)
await pending
expect(useChatStore.getState().sessions[TEST_SESSION_ID]).toBeUndefined()
})
it('aborts a superseded authoritative history reload', async () => {
let firstSignal: AbortSignal | undefined
vi.mocked(sessionsApi.getMessages).mockImplementationOnce((_id, options) => new Promise((_resolve, reject) => {
firstSignal = options?.signal
firstSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))
}))
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession() } })
const first = useChatStore.getState().reloadHistory(TEST_SESSION_ID)
const second = useChatStore.getState().reloadHistory(TEST_SESSION_ID)
expect(firstSignal?.aborted).toBe(true)
await Promise.all([first, second])
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyStatus).toBe('ready')
})
it('applies a cold history response against the latest optimistic user turn', async () => {
let resolveHistory!: (value: { messages: MessageEntry[] }) => void
vi.mocked(sessionsApi.getMessages).mockReturnValueOnce(new Promise((resolve) => {
+214 -16
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import { boundActivityText, boundChatHistory, copyChatPreview, CHAT_STREAM_MAX_CHARS, CHAT_TERMINAL_ACTIVITY_MAX_PER_SESSION, CHAT_TERMINAL_ACTIVITY_MAX_TOTAL } from '../lib/chatHistoryBudget'
import { wsManager } from '../api/websocket'
import { sessionsApi } from '../api/sessions'
import { sessionsApi, type SessionHistoryPage } from '../api/sessions'
import { ApiResponseParseError } from '../api/client'
import { subagentsApi } from '../api/subagents'
import { useTeamStore } from './teamStore'
@@ -132,6 +133,12 @@ export type PerSessionState = {
/** True once durable transcript history has been applied for this lifecycle. */
historyHydrated?: boolean
historyError?: string | null
historyPage?: SessionHistoryPage['page']
historyWindowed?: boolean
historyPageLoading?: boolean
historyViewingOlder?: boolean
historyBrowseMessages?: UIMessage[]
historyRecoveryStatus?: 'loading' | 'ready' | 'incomplete' | 'error'
streamingText: string
streamingToolInput: string
activeToolUseId: string | null
@@ -359,6 +366,7 @@ export type AskUserQuestionDraft = {
}
type ChatStore = {
applyBoundedUpdate: (update: (state: ChatStore) => Partial<ChatStore>) => void
sessions: Record<string, PerSessionState>
/** sessionId → toolUseId → draft. In-memory, like `composerDraft`. */
askUserQuestionDrafts: Record<string, Record<string, AskUserQuestionDraft>>
@@ -411,6 +419,7 @@ type ChatStore = {
sessionId: string,
options?: { mode?: 'terminal-reconnect' },
) => Promise<void>
loadOlderHistory: (sessionId: string, latest?: boolean) => Promise<void>
reloadHistory: (
sessionId: string,
guard?: {
@@ -665,7 +674,7 @@ function retireAgentStream(sessionId: string, streamId: string): void {
}
function advanceAgentStreamRevision(sessionId: string): void {
useChatStore.setState((state) => ({
useChatStore.getState().applyBoundedUpdate((state) => ({
sessions: {
...state.sessions,
[sessionId]: {
@@ -2192,9 +2201,14 @@ function describeHistoryLoadError(error: unknown): string {
async function fetchAndMapSessionHistory(
sessionId: string,
existingOwnedToolUseIds = new Set<string>(),
signal?: AbortSignal,
) {
const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId)
const uiMessages = mapHistoryMessagesToUiMessages(messages)
const response = await sessionsApi.getMessages(sessionId, { signal })
const { page } = response
const historyComplete = !page || page.historyComplete
const messages = historyComplete ? response.messages : []
const taskNotifications = historyComplete ? response.taskNotifications : []
const uiMessages = mapHistoryMessagesToUiMessages(response.messages)
// Session history intentionally joins child tool activity back into the
// parent transcript for the conversation timeline. Activity ownership is
// narrower: restoring from that joined stream would put a child's shell
@@ -2218,9 +2232,10 @@ async function fetchAndMapSessionHistory(
rootRunMessages,
rootRunNotifications,
)
const restoredGoalState = deriveActiveGoalStateFromMessages(uiMessages)
const restoredGoalState = deriveActiveGoalStateFromMessages(historyComplete ? uiMessages : [])
return {
rawMessages: messages,
page,
historyComplete,
uiMessages,
activeGoal: restoredGoalState.activeGoal,
hasRestoredGoalState: restoredGoalState.hasStateEvidence,
@@ -2232,7 +2247,63 @@ async function fetchAndMapSessionHistory(
}
}
function recoveryMatchesHistorySource(pageVersion: string, recoveryVersion: string): boolean {
if (pageVersion === recoveryVersion) return true
const page = pageVersion.split(':')
const recovery = recoveryVersion.split(':')
// A newer append-only snapshot can restore an older page. Equal-size
// rewrites and inode replacements must never restore stale session state.
return page.length === 4 && recovery.length === 4 && page[0] === recovery[0] && page[1] === recovery[1]
&& Number.isFinite(Number(page[2])) && Number(recovery[2]) > Number(page[2])
}
async function recoverSessionHistory(sessionId: string, sourceVersion: string): Promise<void> {
historyRecoveryControllers.get(sessionId)?.abort()
const controller = new AbortController()
historyRecoveryControllers.set(sessionId, controller)
const lifecycle = currentHistoryLifecycle(sessionId)
const baseline = useChatStore.getState().sessions[sessionId]
if (!baseline) return
const taskBaseline = useCLITaskStore.getState()
useChatStore.getState().applyBoundedUpdate((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyRecoveryStatus: 'loading' })) }))
try {
const recovery = await sessionsApi.getHistoryRecovery(sessionId, { signal: controller.signal, timeout: 120_000 })
if (controller.signal.aborted || !isCurrentHistoryLifecycle(sessionId, lifecycle)) return
if (!recoveryMatchesHistorySource(sourceVersion, recovery.sourceVersion)) {
useChatStore.getState().applyBoundedUpdate((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyRecoveryStatus: 'incomplete' })) }))
return
}
const complete = recovery.completeness ?? { goal: recovery.status === 'ready', todos: recovery.status === 'ready', activity: recovery.status === 'ready', usage: recovery.status === 'ready' }
const rootMessages = recovery.messages.filter((message) => !message.parentToolUseId)
const goal = deriveActiveGoalStateFromMessages(mapHistoryMessagesToUiMessages(rootMessages))
const activity = reconstructRunActivityFromTranscript(rootMessages, (recovery.taskNotifications ?? []).filter((item) => !item.ownerAgentId))
useChatStore.getState().applyBoundedUpdate((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, (current) => ({
historyRecoveryStatus: recovery.status,
activeGoal: complete.goal && current.activeGoalRevision === baseline.activeGoalRevision ? goal.activeGoal : current.activeGoal,
tokenUsage: complete.usage && current.tokenUsage === baseline.tokenUsage ? recovery.tokenUsage ?? current.tokenUsage : current.tokenUsage,
backgroundAgentTasks: complete.activity && current.backgroundAgentTasks === baseline.backgroundAgentTasks
? mergeBackgroundAgentTaskRecords(current.backgroundAgentTasks ?? {}, activity.backgroundAgentTasks)
: current.backgroundAgentTasks,
agentTaskNotifications: complete.activity && current.agentTaskNotifications === baseline.agentTaskNotifications
? mergeAgentTaskNotificationRecords(current.agentTaskNotifications, activity.agentTaskNotifications, current.backgroundAgentTasks ?? {})
: current.agentTaskNotifications,
})) }))
const taskCurrent = useCLITaskStore.getState()
if (complete.todos && taskCurrent.sessionId === taskBaseline.sessionId && taskCurrent.tasks === taskBaseline.tasks) {
taskCurrent.setTasksFromTodos(extractLastTodoWriteFromHistory(rootMessages) ?? [], sessionId)
if (hasUserMessagesAfterTaskCompletion(rootMessages)) taskCurrent.markCompletedAndDismissed(sessionId)
}
} catch {
if (!controller.signal.aborted && isCurrentHistoryLifecycle(sessionId, lifecycle)) {
useChatStore.getState().applyBoundedUpdate((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyRecoveryStatus: 'error' })) }))
}
} finally {
if (historyRecoveryControllers.get(sessionId) === controller) historyRecoveryControllers.delete(sessionId)
}
}
type HistoryLoadInFlight = {
controller: AbortController
lifecycleGeneration: number
promise: Promise<void>
}
@@ -2263,6 +2334,9 @@ type TerminalReconnectHistoryBoundary = {
}
const historyLoadsInFlight = new Map<string, HistoryLoadInFlight>()
const historyRecoveryControllers = new Map<string, AbortController>()
const historyPageControllers = new Map<string, AbortController>()
const historyReloadControllers = new Map<string, AbortController>()
const historyReloadGenerations = new Map<string, number>()
const historyReloadCompletionGenerations = new Map<string, number>()
const historyLifecycleGenerations = new Map<string, number>()
@@ -2278,7 +2352,14 @@ function currentHistoryLifecycle(sessionId: string): number {
function advanceHistoryLifecycle(sessionId: string): number {
const nextGeneration = currentHistoryLifecycle(sessionId) + 1
historyLifecycleGenerations.set(sessionId, nextGeneration)
historyLoadsInFlight.get(sessionId)?.controller.abort()
historyLoadsInFlight.delete(sessionId)
historyReloadControllers.get(sessionId)?.abort()
historyReloadControllers.delete(sessionId)
historyRecoveryControllers.get(sessionId)?.abort()
historyRecoveryControllers.delete(sessionId)
historyPageControllers.get(sessionId)?.abort()
historyPageControllers.delete(sessionId)
terminalReconnectHistoryBoundaries.delete(sessionId)
return nextGeneration
}
@@ -2625,7 +2706,41 @@ function shouldPrewarmSession(sessionId: string): boolean {
return knownSession?.messageCount === 0
}
export const useChatStore = create<ChatStore>((set, get) => ({
export const useChatStore = create<ChatStore>((setState, get) => {
const set = (update: Partial<ChatStore> | ((state: ChatStore) => Partial<ChatStore>)) => {
setState((previous) => {
const patch = typeof update === 'function' ? update(previous) : update
if (patch === previous || !patch.sessions) return patch
let sessions = patch.sessions
const sessionCount = Math.max(1, Object.keys(sessions).length)
const budget = Math.min(2 * 1024 * 1024, Math.floor(16 * 1024 * 1024 / sessionCount))
const terminalLimit = Math.min(CHAT_TERMINAL_ACTIVITY_MAX_PER_SESSION, Math.floor(CHAT_TERMINAL_ACTIVITY_MAX_TOTAL / (2 * sessionCount)))
for (const [id, session] of Object.entries(sessions)) {
// Bound the aggregate as well as individual tabs. Operational state is
// retained even when an older tab's display history needs reloading.
const displayBudget = Math.floor(budget * 3 / 4)
const activityBudget = Math.floor(budget / 8)
const tasks = boundActivityText(session.backgroundAgentTasks, activityBudget, terminalLimit)
const notifications = boundActivityText(session.agentTaskNotifications, activityBudget, terminalLimit)!
const bounded = boundChatHistory(session.messages, session.historyBrowseMessages ? Math.floor(displayBudget / 2) : displayBudget)
const browse = session.historyBrowseMessages ? boundChatHistory(session.historyBrowseMessages, Math.floor(displayBudget / 2)).messages : undefined
const text = copyChatPreview(session.streamingText, CHAT_STREAM_MAX_CHARS, true)
const input = copyChatPreview(session.streamingToolInput, CHAT_STREAM_MAX_CHARS)
if (bounded.messages === session.messages && browse === session.historyBrowseMessages && text === session.streamingText && input === session.streamingToolInput && tasks === session.backgroundAgentTasks && notifications === session.agentTaskNotifications) continue
if (sessions === patch.sessions) sessions = { ...sessions }
sessions[id] = {
...session, messages: bounded.messages, historyBrowseMessages: browse, streamingText: text, streamingToolInput: input,
backgroundAgentTasks: tasks, agentTaskNotifications: notifications,
historyWindowed: true,
...(bounded.dropped && session.streamAttemptStartIndex !== undefined
? { streamAttemptStartIndex: Math.max(0, session.streamAttemptStartIndex - bounded.dropped) } : {}),
}
}
return sessions === patch.sessions ? patch : { ...patch, sessions }
})
}
return ({
applyBoundedUpdate: set,
sessions: {},
askUserQuestionDrafts: {},
@@ -3249,12 +3364,23 @@ export const useChatStore = create<ChatStore>((set, get) => ({
},
loadHistory: async (sessionId, options) => {
if (historyPageControllers.has(sessionId)) {
historyPageControllers.get(sessionId)?.abort()
historyPageControllers.delete(sessionId)
set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyPageLoading: false })) }))
}
const lifecycleGeneration = currentHistoryLifecycle(sessionId)
const existingLoad = historyLoadsInFlight.get(sessionId)
if (existingLoad?.lifecycleGeneration === lifecycleGeneration) {
return existingLoad.promise
}
if (existingLoad) historyLoadsInFlight.delete(sessionId)
if (existingLoad) {
existingLoad.controller.abort()
historyLoadsInFlight.delete(sessionId)
}
const controller = new AbortController()
historyRecoveryControllers.get(sessionId)?.abort()
historyRecoveryControllers.delete(sessionId)
// Workflow runs are rebuilt from disk alongside the transcript. Without
// this, reopening a session that ran a workflow showed no trace of it —
@@ -3340,6 +3466,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
})
const {
uiMessages,
page,
historyComplete,
activeGoal,
hasRestoredGoalState,
restoredNotifications,
@@ -3350,6 +3478,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
} = await fetchAndMapSessionHistory(
sessionId,
sessionOwnedActivityToolUseIds(get().sessions[sessionId]),
controller.signal,
)
let historyApplied = false
set((state) => {
@@ -3491,6 +3620,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
)
return {
historyStatus: 'ready',
historyPage: page,
historyViewingOlder: false,
historyBrowseMessages: undefined,
historyWindowed: !historyComplete,
historyRecoveryStatus: historyComplete ? 'ready' : 'loading',
historyHydrated: true,
historyError: null,
...(shouldBackfillColdHistory ? {
@@ -3505,7 +3639,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
requestedActiveGoalRevision,
activeGoal,
hasRestoredGoalState,
discardBaselineMessages,
discardBaselineMessages && historyComplete,
),
agentTaskNotifications,
backgroundAgentTasks,
@@ -3576,6 +3710,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
)
return {
historyStatus: 'ready',
historyPage: page,
historyViewingOlder: false,
historyBrowseMessages: undefined,
historyWindowed: !historyComplete,
historyRecoveryStatus: historyComplete ? 'ready' : 'loading',
historyHydrated: true,
historyError: null,
...(shouldBackfillColdHistory ? {
@@ -3590,7 +3729,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
requestedActiveGoalRevision,
activeGoal,
hasRestoredGoalState,
discardBaselineMessages,
discardBaselineMessages && historyComplete,
),
agentTaskNotifications,
backgroundAgentTasks,
@@ -3604,6 +3743,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}) }
})
if (!historyApplied) return
if (!historyComplete && page) void recoverSessionHistory(sessionId, page.sourceVersion)
if (
terminalReconnectBoundary &&
!terminalReconnectBoundary.preHydrationGap &&
@@ -3615,7 +3755,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const taskStoreChangedWhileLoading =
currentTaskStore.sessionId !== requestedTaskStoreSessionId ||
currentTaskStore.tasks !== requestedTasks
if (!taskStoreChangedWhileLoading) {
if (historyComplete && !taskStoreChangedWhileLoading) {
if (lastTodos && lastTodos.length > 0) {
if (
currentTaskStore.sessionId === sessionId &&
@@ -3690,14 +3830,54 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}
})()
historyLoadsInFlight.set(sessionId, { lifecycleGeneration, promise: load })
historyLoadsInFlight.set(sessionId, { lifecycleGeneration, promise: load, controller })
return load
},
loadOlderHistory: async (sessionId, latest = false) => {
const session = get().sessions[sessionId]
const cursor = session?.historyPage?.nextCursor
if (!session || (!latest && !cursor) || session.historyPageLoading) return
const lifecycle = currentHistoryLifecycle(sessionId)
const controller = new AbortController()
historyPageControllers.get(sessionId)?.abort()
historyPageControllers.set(sessionId, controller)
set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyPageLoading: true, historyError: null })) }))
try {
const result = await sessionsApi.getHistoryPage(sessionId, latest ? undefined : { cursor: cursor! }, { signal: controller.signal })
if (controller.signal.aborted || !isCurrentHistoryLifecycle(sessionId, lifecycle)) return
const messages = mapHistoryMessagesToUiMessages(result.messages)
set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, (current) => ({
...(latest ? {
messages: mergeColdRestoredHistoryIntoLiveMessages(messages, current.messages),
historyBrowseMessages: undefined,
} : { historyBrowseMessages: messages }),
historyPage: result.page, historyViewingOlder: !latest, historyWindowed: true,
})) }))
if (latest && result.page && !result.page.historyComplete) void recoverSessionHistory(sessionId, result.page.sourceVersion)
} catch (error) {
if (controller.signal.aborted || !isCurrentHistoryLifecycle(sessionId, lifecycle)) return
set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyError: describeHistoryLoadError(error) })) }))
} finally {
if (historyPageControllers.get(sessionId) === controller) {
historyPageControllers.delete(sessionId)
set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyPageLoading: false })) }))
}
}
},
reloadHistory: async (sessionId, guard) => {
if (historyPageControllers.has(sessionId)) {
historyPageControllers.get(sessionId)?.abort()
historyPageControllers.delete(sessionId)
set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ historyPageLoading: false })) }))
}
const lifecycleGeneration = currentHistoryLifecycle(sessionId)
const reloadGeneration = (historyReloadGenerations.get(sessionId) ?? 0) + 1
historyReloadGenerations.set(sessionId, reloadGeneration)
historyReloadControllers.get(sessionId)?.abort()
const controller = new AbortController()
historyReloadControllers.set(sessionId, controller)
try {
const sessionAtReloadStart = get().sessions[sessionId]
const requestedMutationEpoch = sessionAtReloadStart?.historyMutationEpoch ?? 0
@@ -3705,7 +3885,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
if (pendingLoad?.lifecycleGeneration === lifecycleGeneration) {
await pendingLoad.promise
}
if (!isCurrentHistoryLifecycle(sessionId, lifecycleGeneration)) return
if (!isCurrentHistoryLifecycle(sessionId, lifecycleGeneration) || controller.signal.aborted) return
historyRecoveryControllers.get(sessionId)?.abort()
historyRecoveryControllers.delete(sessionId)
// A reload can queue behind a cold load. Capture snapshot baselines only
// after that load settles so its REST state is not mistaken for a live
// mutation that should override the newer reload response.
@@ -3723,6 +3905,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
)
const {
uiMessages,
page,
historyComplete,
activeGoal,
restoredNotifications,
restoredBackgroundTasks,
@@ -3732,6 +3916,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
} = await fetchAndMapSessionHistory(
sessionId,
sessionOwnedActivityToolUseIds(get().sessions[sessionId]),
controller.signal,
)
if (
@@ -3803,9 +3988,14 @@ export const useChatStore = create<ChatStore>((set, get) => ({
return {
sessions: updateSessionIn(state.sessions, sessionId, () => ({
historyStatus: 'ready',
historyPage: page,
historyViewingOlder: false,
historyBrowseMessages: undefined,
historyWindowed: !historyComplete,
historyRecoveryStatus: historyComplete ? 'ready' : 'loading',
historyHydrated: true,
historyError: null,
activeGoal: activeGoalChangedWhileLoading
activeGoal: activeGoalChangedWhileLoading || !historyComplete
? session.activeGoal ?? null
: activeGoal,
agentTaskNotifications,
@@ -3835,6 +4025,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
})
if (!historyApplied) return
if (!historyComplete && page) void recoverSessionHistory(sessionId, page.sourceVersion)
const terminalReconnectBoundary = terminalReconnectHistoryBoundaries.get(sessionId)
if (
terminalReconnectBoundary?.lifecycleGeneration === lifecycleGeneration &&
@@ -3882,7 +4073,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const taskStoreChangedWhileReloading =
currentTaskStore.sessionId !== requestedTaskStoreSessionId ||
currentTaskStore.tasks !== requestedTasks
if (!taskStoreChangedWhileReloading) {
if (historyComplete && !taskStoreChangedWhileReloading) {
if (lastTodos && lastTodos.length > 0) {
currentTaskStore.setTasksFromTodos(lastTodos, sessionId)
} else {
@@ -3919,6 +4110,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
)),
}
})
} finally {
if (historyReloadControllers.get(sessionId) === controller) historyReloadControllers.delete(sessionId)
}
},
@@ -5330,6 +5523,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
queuedUserMessages: [],
historyMutationEpoch: (session?.historyMutationEpoch ?? 0) + 1,
historyStatus: 'ready',
historyPage: undefined,
historyViewingOlder: false,
historyBrowseMessages: undefined,
historyWindowed: false,
historyHydrated: true,
historyError: null,
}))
@@ -5582,7 +5779,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
break
}
},
}))
})
})
function updateOptimisticSessionTitle(sessionId: string, content: string): void {
const title = deriveSessionTitle(content)
+2 -2
View File
@@ -803,7 +803,7 @@ function syncMemberSessionMessages(
memberIsWorking(member, snapshot) ||
unsettledReplies.length > 0
)
useChatStore.setState((state) => {
useChatStore.getState().applyBoundedUpdate((state) => {
const existing = state.sessions[sessionId]
const nextState = existing ?? createMemberSessionState()
const currentStreamRevision = nextState.agentStreamRevision ?? 0
@@ -919,7 +919,7 @@ function clearMemberSessionState(sessionIds: Iterable<string>): void {
initialMemberSessionLoads.delete(sessionId)
awaitingMemberReplies.delete(sessionId)
}
useChatStore.setState((state) => {
useChatStore.getState().applyBoundedUpdate((state) => {
const sessions = { ...state.sessions }
for (const sessionId of ids) delete sessions[sessionId]
return { sessions }
+16
View File
@@ -87,6 +87,22 @@ export type TraceSessionSummary = {
}
export type TraceSession = {
window?: {
offset: number
limit: number
totalCalls: number
totalEvents: number
hasMore: boolean
revisionToken: string
state: 'ready' | 'indexing' | 'limited'
oversizedRecords: number
startByte: number
scannedBytes: number
fileBytes: number
recordLimit: number
recordBytesLimit: number
nextScanCursor?: string
}
sessionId: string
messageSignature?: string | null
session?: {
@@ -28,6 +28,10 @@ const checks: Check[] = [
'--test-name-pattern', 'frozen v[45]',
],
},
{
title: 'Trace projection resource-window schema migrations',
command: ['bun', 'test', './src/server/services/localIndex/traceIndex.test.ts'],
},
{
title: 'Server persistent JSON migrations',
command: ['bun', 'test', './src/server/__tests__/persistence-upgrade.test.ts'],
+77 -5
View File
@@ -1511,6 +1511,14 @@ describe('ConversationService', () => {
expect(contextEstimate?.model).toBe('MiniMax-M3')
expect(contextEstimate?.rawMaxTokens).toBe(1_000_000)
// No transcript append: provider edits alone must invalidate inspection data.
await providerService.updateProvider(provider.id, {
modelContextWindows: { 'MiniMax-M3': 64_000 },
})
const changed = await svc.getInspectionTranscriptSnapshot(sessionId)
expect(changed?.contextEstimate?.rawMaxTokens).toBe(64_000)
expect(changed?.usage?.models[0]?.contextWindow).toBe(64_000)
} finally {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
@@ -2181,7 +2189,7 @@ describe('WebSocket Chat Integration', () => {
return messages
}
async function runTurnUntilComplete(sessionId: string, content: string): Promise<any[]> {
async function runTurnUntilComplete(sessionId: string, content: string, onMessage?: (message: any) => void): Promise<any[]> {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
@@ -2194,6 +2202,7 @@ describe('WebSocket Chat Integration', () => {
ws.onmessage = (e) => {
const msg = JSON.parse(e.data as string)
messages.push(msg)
onMessage?.(msg)
if (msg.type === 'connected') {
ws.send(JSON.stringify({ type: 'user_message', content }))
}
@@ -2970,9 +2979,19 @@ describe('WebSocket Chat Integration', () => {
}, 15_000)
it('should not add a CLI exit error after a reported SDK API error', async () => {
const sessionId = `chat-api-error-exit-${crypto.randomUUID()}`
const messages = await runTurnUntilComplete(
`chat-api-error-exit-${crypto.randomUUID()}`,
sessionId,
'trigger api error then exit',
(message) => {
if (message.type === 'error' && message.code === 'invalid_request') {
// Prove the error was reported before asking the fixture to exit.
// Transport flushing alone does not settle the async SDK handler.
void conversationService.requestControl(sessionId, {
subtype: 'mock_exit_after_api_error_ack',
}).catch(() => undefined)
}
},
)
const errors = messages.filter((m) => m.type === 'error')
@@ -3926,6 +3945,20 @@ describe('WebSocket Chat Integration', () => {
const originalStartSession = conversationService.startSession.bind(conversationService)
const originalSendMessage = conversationService.sendMessage.bind(conversationService)
const originalStopSession = conversationService.stopSession.bind(conversationService)
const originalAppendMetadata = sessionService.appendSessionMetadata.bind(sessionService)
let firstStartPending = true
let stoppedDuringFirstStart = false
let markRuntimePersisted!: () => void
const runtimePersisted = new Promise<void>((resolve) => { markRuntimePersisted = resolve })
conversationService.stopSession = ((sid: string) => {
if (sid === sessionId && firstStartPending) stoppedDuringFirstStart = true
return originalStopSession(sid)
}) as typeof conversationService.stopSession
sessionService.appendSessionMetadata = (async (sid, metadata) => {
await originalAppendMetadata(sid, metadata)
if (sid === sessionId && metadata.runtimeModelId === 'first-turn-sonnet') markRuntimePersisted()
}) as typeof sessionService.appendSessionMetadata
const startCalls: Array<{
sessionId: string
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
@@ -3948,8 +3981,13 @@ describe('WebSocket Chat Integration', () => {
) {
startCalls.push({ sessionId: sid, options })
if (startCalls.length === 1) {
// Expose a registered process while the public startup promise is
// deliberately unresolved. hasSession alone must not admit a restart.
await originalStartSession(sid, workDir, sdkUrl, options)
markFirstStart()
await firstStartGate
firstStartPending = false
return
}
return originalStartSession(sid, workDir, sdkUrl, options)
}) as typeof conversationService.startSession
@@ -3977,13 +4015,17 @@ describe('WebSocket Chat Integration', () => {
if (msg.type === 'connected') {
ws.send(JSON.stringify({ type: 'prewarm_session' }))
void firstStartEntered.then(() => {
void firstStartEntered.then(async () => {
ws.send(JSON.stringify({ type: 'user_message', content: 'first turn while runtime changes' }))
ws.send(JSON.stringify({
type: 'set_runtime_config',
providerId: provider.id,
modelId: 'first-turn-sonnet',
}))
await runtimePersisted
// Drain the transition continuation after its controlled metadata
// write, without depending on a wall-clock delay or SDK timing.
await new Promise<void>((resolve) => setImmediate(resolve))
releaseFirstStart()
})
return
@@ -4007,6 +4049,7 @@ describe('WebSocket Chat Integration', () => {
}
})
expect(stoppedDuringFirstStart).toBe(false)
expect(startCalls).toHaveLength(2)
expect(startCalls[0]).toMatchObject({ sessionId })
expect(startCalls[0]?.options?.providerId).toBeNull()
@@ -4026,6 +4069,9 @@ describe('WebSocket Chat Integration', () => {
ws.close()
conversationService.startSession = originalStartSession
conversationService.sendMessage = originalSendMessage
conversationService.stopSession = originalStopSession
sessionService.appendSessionMetadata = originalAppendMetadata
releaseFirstStart()
conversationService.stopSession(sessionId)
}
}, 20_000)
@@ -4493,6 +4539,15 @@ describe('WebSocket Chat Integration', () => {
const { sessionId } = await createRes.json() as { sessionId: string }
const originalStartSession = conversationService.startSession.bind(conversationService)
let terminalSdkResultReceived = false
let restartedBeforeTerminalResult = false
const originalHandleSdkPayload = conversationService.handleSdkPayload.bind(conversationService)
const sdkPayloadSpy = spyOn(conversationService, 'handleSdkPayload').mockImplementation((sid, payload, options) => {
if (sid === sessionId && payload.split('\n').some((line) => {
try { return JSON.parse(line).type === 'result' } catch { return false }
})) terminalSdkResultReceived = true
originalHandleSdkPayload(sid, payload, options)
})
const startCalls: Array<{
sessionId: string
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
@@ -4504,6 +4559,7 @@ describe('WebSocket Chat Integration', () => {
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
) {
if (startCalls.length > 0 && !terminalSdkResultReceived) restartedBeforeTerminalResult = true
startCalls.push({ sessionId: sid, options })
return originalStartSession(sid, workDir, sdkUrl, options)
}) as typeof conversationService.startSession
@@ -4568,7 +4624,8 @@ describe('WebSocket Chat Integration', () => {
if (msg.type === 'message_complete' && switchTriggered && !turnComplete) {
turnComplete = true
expect(startCalls).toHaveLength(1)
// The server may already have restarted by the time this queued
// client event arrives; ordering is checked at SDK receipt above.
return
}
@@ -4585,6 +4642,7 @@ describe('WebSocket Chat Integration', () => {
}
})
expect(restartedBeforeTerminalResult).toBe(false)
expect(startCalls).toHaveLength(2)
expect(startCalls[0]).toMatchObject({
sessionId,
@@ -4602,6 +4660,7 @@ describe('WebSocket Chat Integration', () => {
})
} finally {
ws.close()
sdkPayloadSpy.mockRestore()
conversationService.startSession = originalStartSession
conversationService.stopSession(sessionId)
}
@@ -4647,6 +4706,15 @@ describe('WebSocket Chat Integration', () => {
const { sessionId } = await createRes.json() as { sessionId: string }
const originalStartSession = conversationService.startSession.bind(conversationService)
let terminalSdkResultReceived = false
let restartedBeforeTerminalResult = false
const originalHandleSdkPayload = conversationService.handleSdkPayload.bind(conversationService)
const sdkPayloadSpy = spyOn(conversationService, 'handleSdkPayload').mockImplementation((sid, payload, options) => {
if (sid === sessionId && payload.split('\n').some((line) => {
try { return JSON.parse(line).type === 'result' } catch { return false }
})) terminalSdkResultReceived = true
originalHandleSdkPayload(sid, payload, options)
})
const startCalls: Array<{
sessionId: string
options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
@@ -4658,6 +4726,7 @@ describe('WebSocket Chat Integration', () => {
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
) {
if (startCalls.length > 0 && !terminalSdkResultReceived) restartedBeforeTerminalResult = true
startCalls.push({ sessionId: sid, options })
if (startCalls.length > 1) {
throw new Error('deferred restart failed')
@@ -4706,7 +4775,8 @@ describe('WebSocket Chat Integration', () => {
if (msg.type === 'message_complete' && switchTriggered && !turnComplete) {
turnComplete = true
expect(startCalls).toHaveLength(1)
// The server may already have restarted by the time this queued
// client event arrives; ordering is checked at SDK receipt above.
return
}
@@ -4739,6 +4809,7 @@ describe('WebSocket Chat Integration', () => {
expect(switchTriggered).toBe(true)
expect(turnComplete).toBe(true)
expect(restartError).toBe(true)
expect(restartedBeforeTerminalResult).toBe(false)
expect(startCalls).toHaveLength(2)
expect(startCalls[1]).toMatchObject({
sessionId,
@@ -4749,6 +4820,7 @@ describe('WebSocket Chat Integration', () => {
})
} finally {
ws.close()
sdkPayloadSpy.mockRestore()
conversationService.startSession = originalStartSession
conversationService.stopSession(sessionId)
}
@@ -275,6 +275,9 @@ ws.addEventListener('message', (event) => {
void (async () => {
for (const line of lines) {
const parsed = JSON.parse(line)
if (parsed.type === 'control_request' && parsed.request?.subtype === 'mock_exit_after_api_error_ack') {
process.exit(1)
}
if (parsed.type === 'user') {
sendInit()
@@ -340,7 +343,10 @@ ws.addEventListener('message', (event) => {
session_id: sessionId,
})
if (text.includes('then exit')) {
setTimeout(() => process.exit(1), 10)
// Wait for the test client's acknowledgment that the API error
// was reported. A 10ms exit could outrun the async SDK handler.
// Bound fixture lifetime if that acknowledgment never arrives.
setTimeout(() => process.exit(1), 5_000)
continue
}
emit(ws, {
@@ -11,15 +11,17 @@
* These tests fail if that flag is dropped.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { handleApiRequest } from '../router.js'
import { sessionService } from '../services/sessionService.js'
const SUBAGENT_SENTINEL = 'SUBPAGENT_ONLY_SENTINEL_read_alpha'
let tmpDir: string
let previousConfig: string | undefined
async function api(method: string, pathname: string): Promise<Response> {
const url = new URL(pathname, 'http://localhost:3456')
@@ -123,16 +125,76 @@ async function seedSessionWithSubagent(): Promise<string> {
}
beforeEach(async () => {
previousConfig = process.env.CLAUDE_CONFIG_DIR
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'session-messages-http-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
})
afterEach(async () => {
delete process.env.CLAUDE_CONFIG_DIR
if (previousConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfig
await fs.rm(tmpDir, { recursive: true, force: true })
})
describe('session messages HTTP surface', () => {
it('bounds both public history endpoints and returns a continuation without canonical hydration', async () => {
const sessionId = await seedSessionWithSubagent()
const filePath = path.join(tmpDir, 'projects', '-tmp-http-invariant', `${sessionId}.jsonl`)
const content = 'x'.repeat(256 * 1024)
for (let n = 0; n < 20; n++) await fs.appendFile(filePath, JSON.stringify({ type: 'assistant', uuid: `large-${n}`, timestamp: '2026-01-02T00:00:00Z', message: { role: 'assistant', content } }) + '\n')
const full = spyOn(sessionService, 'getSession').mockImplementation(() => { throw new Error('canonical hydration is forbidden on UI routes') })
try {
for (const suffix of ['/messages', '']) {
const response = await api('GET', `/api/sessions/${sessionId}${suffix}`)
expect(response.status).toBe(200)
const raw = await response.text()
expect(Buffer.byteLength(raw)).toBeLessThan(2 * 1024 * 1024)
const body = JSON.parse(raw)
expect(body.page.hasMore).toBe(true)
expect(body.page.nextCursor).toBeString()
expect(body.messages.at(-1).id).toBe('large-19')
}
expect(full).not.toHaveBeenCalled()
} finally { full.mockRestore() }
})
it('rejects oversized automatic checkpoint previews before canonical history loading', async () => {
const sessionId = await seedSessionWithSubagent()
const filePath = path.join(tmpDir, 'projects', '-tmp-http-invariant', `${sessionId}.jsonl`)
await fs.truncate(filePath, 17 * 1024 * 1024)
const canonical = spyOn(sessionService, 'getSessionMessagesWithEvidence').mockImplementation(() => { throw new Error('full history must not be hydrated') })
try {
const response = await api('GET', `/api/sessions/${sessionId}/turn-checkpoints`)
expect(response.status).toBe(413)
expect((await response.json() as { error: string }).error).toBe('HISTORY_CHECKPOINT_PREVIEW_LIMIT')
expect(canonical).not.toHaveBeenCalled()
} finally { canonical.mockRestore() }
})
it('returns messages and task notifications without a second transcript scan', async () => {
const sessionId = await seedSessionWithSubagent()
await fs.appendFile(path.join(tmpDir, 'projects', '-tmp-http-invariant', `${sessionId}.jsonl`),
JSON.stringify({
type: 'cc-haha-task-notification',
taskNotification: { taskId: 'task-1', toolUseId: 'Agent:0', status: 'completed', summary: 'Done' },
timestamp: '2026-01-01T00:02:00.000Z',
}) + '\n')
const separateNotifications = spyOn(sessionService, 'getSessionTaskNotifications')
try {
const response = await api('GET', `/api/sessions/${sessionId}/messages`)
expect(response.status).toBe(200)
const body = await response.json() as { messages: unknown[]; taskNotifications: unknown[] }
expect(body.messages.length).toBeGreaterThan(0)
expect(body.taskNotifications).toEqual([{
taskId: 'task-1', toolUseId: 'Agent:0', status: 'completed', summary: 'Done',
timestamp: '2026-01-01T00:02:00.000Z',
}])
expect(separateNotifications).not.toHaveBeenCalled()
} finally {
separateNotifications.mockRestore()
}
})
it('never inlines linked subagent tool messages in /messages', async () => {
const sessionId = await seedSessionWithSubagent()
@@ -46,6 +46,30 @@ function requestSummary(sessionId: string, method = 'GET') {
}
describe('session summary', () => {
it('shares unchanged title metadata scans across trace polling and session summaries', async () => {
const fixture = await seedSession()
const service = new SessionService()
const internals = service as unknown as {
scanSessionListSummary: (...args: unknown[]) => Promise<unknown>
}
const scan = spyOn(internals, 'scanSessionListSummary')
try {
const [first, second] = await Promise.all([
service.getSessionTitleAndMeta(fixture.filePath),
service.getSessionTitleAndMeta(fixture.filePath),
])
expect(first).toEqual(second)
expect(first.title).toBe('Old conversation')
expect((await service.getSessionSummary(SESSION_ID))?.title).toBe(first.title)
expect(scan).toHaveBeenCalledTimes(1)
await fs.appendFile(fixture.filePath, `${JSON.stringify({ type: 'custom-title', customTitle: 'Updated title' })}\n`)
expect((await service.getSessionTitleAndMeta(fixture.filePath)).title).toBe('Updated title')
expect(scan).toHaveBeenCalledTimes(2)
} finally {
scan.mockRestore()
}
})
it('reads only the selected old transcript with list-equivalent runtime and workspace metadata', async () => {
const fixture = await seedSession()
await seedSession('12870001-bbbb-cccc-dddd-eeeeeeeeeeee')
+90
View File
@@ -1736,6 +1736,96 @@ describe('SessionService', () => {
.toHaveLength(0)
})
it('shares bounded inspection across metadata/context/usage readers and invalidates after append', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const projectDir = '-tmp-inspection-budget'
await writeSessionFile(projectDir, sessionId, [makeSessionMetaEntry('/tmp/inspection'), makeUserEntry('x'.repeat(256 * 1024), crypto.randomUUID())])
const fullRead = spyOn(service as any, 'readJsonlFile').mockImplementation(() => { throw new Error('unbounded history read') })
const stream = spyOn(service as any, 'streamJsonlFile')
try {
await Promise.all([service.getTranscriptMetadata(sessionId), service.getTranscriptContextEstimate(sessionId), service.getTranscriptUsage(sessionId)])
expect(fullRead).not.toHaveBeenCalled()
expect(stream).toHaveBeenCalledTimes(1)
await fs.appendFile(path.join(tmpDir, 'projects', projectDir, `${sessionId}.jsonl`), JSON.stringify({ ...makeUserEntry('next', crypto.randomUUID()), cwd: '/tmp/new-inspection' }) + '\n')
expect((await service.getTranscriptMetadata(sessionId))?.cwd).toBe('/tmp/new-inspection')
expect(stream).toHaveBeenCalledTimes(2)
} finally { fullRead.mockRestore(); stream.mockRestore() }
})
it('reports oversized inspection records rather than returning partial authoritative state', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-inspection-limit', sessionId, [makeUserEntry('x'.repeat(2 * 1024 * 1024), crypto.randomUUID())])
await expect(service.getTranscriptMetadata(sessionId)).rejects.toMatchObject({ statusCode: 413, code: 'HISTORY_INSPECTION_LIMIT' })
})
it('coalesces overlapping history reads without retaining stale history', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const projectDir = '-tmp-project'
await writeSessionFile(projectDir, sessionId, [makeUserEntry('hello', crypto.randomUUID())])
const readSpy = spyOn(service as any, 'readJsonlFileWithDiagnostics')
try {
const histories = await Promise.all([
service.getSessionHistory(sessionId),
service.getSessionHistory(sessionId),
])
expect(readSpy).toHaveBeenCalledTimes(1)
expect(histories[0]).toEqual(histories[1])
expect(histories[0]!.messages).toHaveLength(1)
await fs.appendFile(path.join(tmpDir, 'projects', projectDir, `${sessionId}.jsonl`),
`${JSON.stringify(makeUserEntry('new message', crypto.randomUUID()))}\n`)
expect((await service.getSessionHistory(sessionId)).messages).toHaveLength(2)
expect(readSpy).toHaveBeenCalledTimes(2)
} finally {
readSpy.mockRestore()
}
})
it('streams transcript diagnostics while preserving malformed and missing evidence', async () => {
const filePath = path.join(tmpDir, 'diagnostics.jsonl')
await fs.writeFile(filePath, '{"type":"user"}\nmalformed\n\n{"type":"assistant"}')
const readSpy = spyOn(fs, 'readFile')
try {
const result = await (service as any).readJsonlFileWithDiagnostics(filePath)
expect(result).toEqual({
entries: [{ type: 'user' }, { type: 'assistant' }],
exists: true,
parseComplete: false,
})
expect(await (service as any).readJsonlFileWithDiagnostics(`${filePath}.missing`)).toEqual({
entries: [], exists: false, parseComplete: false,
})
expect(readSpy).not.toHaveBeenCalled()
} finally {
readSpy.mockRestore()
}
})
it('computes stable message signatures without reading transcript payloads', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const projectDir = '-tmp-project'
await writeSessionFile(projectDir, sessionId, [makeUserEntry('hello', crypto.randomUUID())])
const child = await writeSubagentTranscriptFile(projectDir, sessionId, 'abc123', [])
const streamSpy = spyOn(service as any, 'streamJsonlFile')
const readSpy = spyOn(fs, 'readFile')
try {
const before = await service.getSessionMessagesSignature(sessionId)
expect(await service.getSessionMessagesSignature(sessionId)).toBe(before)
await fs.appendFile(child, 'child append\n')
const appended = await service.getSessionMessagesSignature(sessionId)
expect(appended).not.toBe(before)
await fs.rm(child)
const removed = await service.getSessionMessagesSignature(sessionId)
expect(removed).not.toBe(appended)
await fs.appendFile(path.join(tmpDir, 'projects', projectDir, `${sessionId}.jsonl`), 'root append\n')
expect(await service.getSessionMessagesSignature(sessionId)).not.toBe(removed)
expect(streamSpy).not.toHaveBeenCalled()
expect(readSpy).not.toHaveBeenCalled()
} finally {
streamSpy.mockRestore()
readSpy.mockRestore()
}
})
it('should include linked subagent transcript changes in the message signature', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const projectDir = '-tmp-project'
+19 -1
View File
@@ -2,8 +2,9 @@
* Unit tests for TeamWatcher — real-time team status push via WebSocket
*/
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as syncFs from 'node:fs'
import * as path from 'node:path'
import * as os from 'node:os'
import type { ServerMessage } from '../ws/events.js'
@@ -664,3 +665,20 @@ describe('TeamWatcher broadcast', () => {
await cleanupTmpDir()
})
})
// A sparse fixture catches read-all-then-slice without retaining a large test string.
it('reads only the subagent name prefix of a large transcript', async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'watcher-prefix-'))
const file = path.join(directory, 'agent.jsonl')
await fs.writeFile(file, JSON.stringify({ agentName: 'bounded-agent' }) + '\n')
await fs.truncate(file, 64 * 1024 * 1024)
const readAll = spyOn(syncFs, 'readFileSync').mockImplementation(() => { throw new Error('Unbounded transcript read') })
try {
const watcher = new TeamWatcher()
expect((watcher as unknown as { extractSubagentName(path: string): string | null }).extractSubagentName(file)).toBe('bounded-agent')
expect(readAll).not.toHaveBeenCalled()
} finally {
readAll.mockRestore()
await fs.rm(directory, { recursive: true, force: true })
}
})
+24 -2
View File
@@ -2,7 +2,7 @@
* Unit tests for TeamService and Teams API
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
@@ -14,7 +14,7 @@ import {
teamIncarnationId,
} from '../services/teamService.js'
import type { TeamWorkbenchSnapshot } from '../services/teamService.js'
import type { MessageEntry } from '../services/sessionService.js'
import { sessionService, type MessageEntry } from '../services/sessionService.js'
import * as lockfile from '../../utils/lockfile.js'
import { getSessionCreatedTeams } from '../../bootstrap/state.js'
import {
@@ -1219,6 +1219,28 @@ describe('TeamService', () => {
expect(reopened?.snapshots.at(-1)).toEqual(live)
})
it('uses the bounded incremental Team projection instead of canonical history during workbench polling', async () => {
const filePath = path.join(tmpDir, 'poll-session.jsonl')
const unrelated = JSON.stringify({ uuid: 'ordinary', message: { role: 'assistant', content: 'x'.repeat(8192) } }) + '\n'
await fs.writeFile(filePath, unrelated.repeat(640))
const canonical = spyOn(sessionService, 'getSessionMessages').mockImplementation(async () => { throw new Error('canonical history must not be loaded') })
service = new TeamService({ sessionLocator: { findSessionFile: async () => ({ filePath, projectDir: '-tmp-project' }) } })
try {
expect(await service.getWorkbenchForSession('poll-session')).toBeNull()
expect(await service.getWorkbenchForSession('poll-session')).toBeNull()
await fs.appendFile(filePath, JSON.stringify({ uuid: 'team-create', timestamp: '2026-01-01T00:00:00Z', message: { role: 'assistant', content: [{ type: 'tool_use', id: 'create-team', name: 'TeamCreate', input: { team_name: 'bounded-team' } }] } }) + '\n')
expect(await service.getWorkbenchForSession('poll-session')).toMatchObject({ teamName: 'bounded-team', source: 'transcript' })
expect(canonical).not.toHaveBeenCalled()
} finally { canonical.mockRestore() }
})
it('reports an incomplete Team projection instead of pretending the session has no team', async () => {
const filePath = path.join(tmpDir, 'oversized-team.jsonl')
await fs.writeFile(filePath, JSON.stringify({ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'team', name: 'TeamCreate', input: { team_name: 'large', description: 'x'.repeat(2 * 1024 * 1024) } }] } }) + '\n')
service = new TeamService({ sessionLocator: { findSessionFile: async () => ({ filePath, projectDir: '-tmp-project' }) } })
await expect(service.getWorkbenchForSession('oversized-team')).rejects.toMatchObject({ statusCode: 503, code: 'TEAM_HISTORY_INCOMPLETE' })
})
it('reconciles the final task tail from the matching Team incarnation before deletion', async () => {
const teamName = 'delete-before-next-poll'
const leadSessionId = 'delete-before-next-poll-lead'
+116
View File
@@ -1338,6 +1338,34 @@ describe('session trace API', () => {
expect(body.events).toEqual([])
})
test('streams original trace bytes, rejects oversized detail, and validates overview offsets', async () => {
const sessionId = 'route-resource-bounds'
const traceDir = path.join(tmpDir, 'cc-haha', 'traces')
await fs.mkdir(traceDir, { recursive: true })
const raw = Buffer.from(JSON.stringify({ type: 'call', record: {
id: 'huge-call', sessionId, source: 'proxy', startedAt: '2026-01-01T00:00:00Z',
request: { method: 'POST', url: 'https://example.test', headers: {}, body: {
contentType: 'text', bytes: 3 * 1024 * 1024, sha256: '', truncated: false, preview: '字'.repeat(1024 * 1024),
} },
} }) + '\n')
const filePath = path.join(traceDir, `${sessionId}.jsonl`)
await fs.writeFile(filePath, raw)
const rawRequest = new Request(`http://localhost:3456/api/sessions/${sessionId}/trace/raw`)
const download = await handleApiRequest(rawRequest, new URL(rawRequest.url))
expect(download.status).toBe(200)
expect(download.headers.get('content-disposition')).toContain('attachment')
expect(Buffer.from(await download.arrayBuffer())).toEqual(raw)
const detailRequest = new Request(`http://localhost:3456/api/sessions/${sessionId}/trace/calls/huge-call`)
const detail = await handleApiRequest(detailRequest, new URL(detailRequest.url))
expect(detail.status).toBe(413)
expect(await detail.json()).toMatchObject({ error: 'TRACE_RECORD_TOO_LARGE' })
for (const offset of ['-1', '1.5', 'Infinity', '9007199254740992']) {
const request = new Request(`http://localhost:3456/api/sessions/${sessionId}/trace?offset=${offset}`)
expect((await handleApiRequest(request, new URL(request.url))).status).toBe(400)
}
expect(await fs.readFile(filePath)).toEqual(raw)
})
test('serves the session trace overview from the index without rereading the JSONL', async () => {
const recorded = await traceCaptureService.recordCall({
sessionId: 'session-trim-api',
@@ -1996,6 +2024,57 @@ describe('trace read cache', () => {
}
})
test('rebuilds cold trace summaries with bounded reads instead of hydrating the whole file', async () => {
const sessionId = 'session-streamed-projection'
const traceDir = path.join(tmpDir, 'cc-haha', 'traces')
const filePath = path.join(traceDir, `${sessionId}.jsonl`)
await fs.mkdir(traceDir, { recursive: true })
// Each UTF-8 record crosses chunk boundaries; the final version of call-a
// must win without changing its first insertion order.
const first = buildTraceCallLine('call-a', sessionId, '中文'.repeat(100_000))
const second = buildTraceCallLine('call-b', sessionId, 'x'.repeat(300_000))
const final = buildTraceCallLine('call-a', sessionId, 'updated')
await fs.writeFile(filePath, first + second + final + '{"incomplete":')
clearTraceCaptureStateForTests()
const originalOpen = mutableFs.open.bind(mutableFs)
let fullFileReads = 0
const readLengths: number[] = []
const openSpy = spyOn(mutableFs, 'open').mockImplementation(async (...args) => {
const handle = await originalOpen(...args)
if (String(args[0]) !== filePath) return handle
return new Proxy(handle, {
get(target, property) {
if (property === 'readFile') {
return async (...readArgs: Parameters<typeof target.readFile>) => {
fullFileReads += 1
return target.readFile(...readArgs)
}
}
if (property === 'read') {
return async (buffer: Uint8Array, offset: number, length: number, position: number) => {
readLengths.push(length)
return target.read(buffer, offset, length, position)
}
}
const value = Reflect.get(target, property, target)
return typeof value === 'function' ? value.bind(target) : value
},
})
})
try {
const overview = await traceCaptureService.getSessionTraceOverview(sessionId)
expect(overview.summary.apiCalls).toBe(2)
expect(overview.calls.map(call => call.id)).toEqual(['call-a', 'call-b'])
expect(fullFileReads).toBe(0)
expect(readLengths.length).toBeGreaterThan(3)
expect(Math.max(...readLengths)).toBeLessThanOrEqual(256 * 1024)
const call = await traceCaptureService.getSessionTraceCall(sessionId, 'call-a')
expect(call?.request.body.preview).toContain('updated')
} finally {
openSpy.mockRestore()
}
})
test('projects an external append from the stored boundary without rereading the prefix', async () => {
const traceDir = path.join(tmpDir, 'cc-haha', 'traces')
const filePath = path.join(traceDir, 'session-projection-external-append.jsonl')
@@ -2118,6 +2197,43 @@ describe('trace read cache', () => {
}
})
test.skipIf(process.platform === 'win32')('rebuilds when an append target is replaced after change detection with identical sampled windows', async () => {
const sessionId = 'session-append-replaced-inode'
const dir = path.join(tmpDir, 'cc-haha', 'traces')
const filePath = path.join(dir, `${sessionId}.jsonl`)
await fs.mkdir(dir, { recursive: true })
const middle = JSON.parse(buildTraceCallLine('middle', sessionId))
middle.record.model = 'model-old'
const original = buildTraceCallLine('first', sessionId, 'x'.repeat(100_000))
+ JSON.stringify(middle) + '\n'
+ buildTraceCallLine('last', sessionId, 'x'.repeat(100_000))
await fs.writeFile(filePath, original)
await traceCaptureService.getSessionTraceOverview(sessionId)
const append = buildTraceCallLine('appended', sessionId)
await fs.appendFile(filePath, append)
const replacementPath = `${filePath}.replacement`
await fs.writeFile(replacementPath, original.replace('model-old', 'model-new') + append)
const originalOpen = mutableFs.open.bind(mutableFs)
let opens = 0
let replaced = false
const openSpy = spyOn(mutableFs, 'open').mockImplementation(async (...args) => {
if (String(args[0]) === filePath && ++opens === 1) {
await fs.rename(replacementPath, filePath)
replaced = true
}
return originalOpen(...args)
})
try {
const overview = await traceCaptureService.getSessionTraceOverview(sessionId)
expect(replaced).toBe(true)
expect(overview.summary.models).toContainEqual({ model: 'model-new', calls: 1 })
expect(overview.summary.models).not.toContainEqual({ model: 'model-old', calls: 1 })
expect(overview.calls).toHaveLength(4)
} finally {
openSpy.mockRestore()
}
})
test('invalidates a projection after a same-size rewrite with restored mtime', async () => {
const traceDir = path.join(tmpDir, 'cc-haha', 'traces')
const filePath = path.join(traceDir, 'session-projection-same-size.jsonl')
@@ -9,7 +9,6 @@ import {
__markPrewarmedForTests,
__resetWebSocketHandlerStateForTests,
__resolveRuntimeRestartWorkDirForTests,
__settleActiveTurnForTests,
closeSessionConnection,
getActiveSessionIds,
handleWebSocket,
@@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import * as os from 'node:os'
import * as path from 'node:path'
import { ApiError } from '../middleware/errorHandler.js'
import { WorkspaceService } from '../services/workspaceService.js'
import {
clearFilesystemAccessRootsForTests,
@@ -144,6 +145,15 @@ describe('WorkspaceService outside-workspace preview', () => {
})
describe('WorkspaceService', () => {
it('surfaces bounded transcript failures instead of reporting a clean workspace', async () => {
const workDir = await makeTempDir('workspace-history-budget-')
const failure = new ApiError(413, 'History viewing budget exceeded', 'HISTORY_WORKSPACE_LIMIT')
const messages = new WorkspaceService(async () => workDir, async () => { throw failure })
const snapshots = new WorkspaceService(async () => workDir, async () => [], async () => { throw failure })
await expect(messages.getStatus('session')).rejects.toBe(failure)
await expect(snapshots.getStatus('session')).rejects.toBe(failure)
})
it('returns git status for modified, added, deleted, and untracked files', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null)
+63 -22
View File
@@ -80,8 +80,12 @@ const workspaceService = new WorkspaceService(
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
),
async (sessionId) => sessionService.getSessionMessages(sessionId),
async (sessionId) => sessionService.getSessionFileHistorySnapshots(sessionId),
async (sessionId) => {
const recovery = await sessionService.getSessionHistoryRecovery(sessionId)
if (!(recovery.completeness?.workspace ?? recovery.status === 'ready')) throw new ApiError(413, 'Workspace transcript exceeds the viewing budget', 'HISTORY_WORKSPACE_LIMIT')
return recovery.messages
},
async (sessionId) => sessionService.getSessionFileHistorySnapshots(sessionId, { bounded: true }),
)
const reviewService = new ReviewService(async (sessionId) => (
@@ -193,7 +197,11 @@ export async function handleSessionsApi(
{ status: 405 }
)
}
return await getSessionMessages(sessionId)
return await getSessionMessages(req, sessionId, url)
}
if (subResource === 'history-recovery' && req.method === 'GET') {
return Response.json(await sessionService.getSessionHistoryRecovery(sessionId, { signal: req.signal }))
}
if (subResource === 'trace') {
@@ -203,9 +211,18 @@ export async function handleSessionsApi(
{ status: 405 }
)
}
if (segments[4] === 'raw') {
const source = await traceCaptureService.getSessionTraceFile(sessionId)
if (!source) throw ApiError.notFound(`Trace not found: ${sessionId}`)
return new Response(Bun.file(source.path), { headers: {
'content-type': 'application/x-ndjson',
'content-disposition': 'attachment; filename="trace.jsonl"',
'cache-control': 'no-store',
} })
}
return segments[4] === 'calls'
? await getSessionTraceCall(sessionId, segments[5])
: await getSessionTrace(sessionId)
: await getSessionTrace(req, sessionId, url)
}
if (subResource === 'git-info') {
@@ -399,6 +416,11 @@ export async function handleSessionsApi(
)
}
} catch (error) {
const code = (error as { code?: string } | null)?.code
const status = code === 'TRACE_PAGE_STALE' || code === 'HISTORY_PAGE_STALE' ? 409
: code === 'TRACE_RECORD_TOO_LARGE' ? 413
: code === 'TRACE_INDEX_BUSY' ? 503 : code === 'HISTORY_QUEUE_FULL' ? 429 : undefined
if (status) return errorResponse(new ApiError(status, error instanceof Error ? error.message : code!, code))
return errorResponse(error)
}
}
@@ -448,27 +470,29 @@ async function listSessions(req: Request, url: URL): Promise<Response> {
}
async function getSession(sessionId: string): Promise<Response> {
// Browser/HTTP callers render the timeline, where the linked subagent tool
// stream is fetched per Agent card. Merging it in here is what pushed a real
// session past the 536,870,888-character response limit.
const detail = await sessionService.getSession(sessionId, { includeSubagents: false })
if (!detail) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
return Response.json(detail)
}
async function getSessionMessages(sessionId: string): Promise<Response> {
const [messages, taskNotifications] = await Promise.all([
sessionService.getSessionMessages(sessionId, { includeSubagents: false }),
sessionService.getSessionTaskNotifications(sessionId),
const [summary, history] = await Promise.all([
sessionService.getSessionSummary(sessionId),
sessionService.getSessionHistoryPage(sessionId),
])
return Response.json({ messages, taskNotifications })
if (!summary) throw ApiError.notFound(`Session not found: ${sessionId}`)
return Response.json({ ...summary, ...history })
}
async function getSessionTrace(sessionId: string): Promise<Response> {
async function getSessionMessages(req: Request, sessionId: string, url: URL): Promise<Response> {
return Response.json(await sessionService.getSessionHistoryPage(sessionId, {
cursor: url.searchParams.get('cursor') ?? undefined,
signal: req.signal,
}))
}
async function getSessionTrace(req: Request, sessionId: string, url: URL): Promise<Response> {
const [trace, sessionMeta, messageSignature] = await Promise.all([
traceCaptureService.getSessionTraceOverview(sessionId),
traceCaptureService.getSessionTraceOverview(sessionId, {
offset: parseTracePageOffset(url),
revisionToken: url.searchParams.get('revisionToken') ?? undefined,
scanCursor: url.searchParams.get('scanCursor') ?? undefined,
signal: req.signal,
}),
getSessionTraceMeta(sessionId),
sessionService.getSessionMessagesSignature(sessionId),
])
@@ -487,6 +511,14 @@ async function getSessionTrace(sessionId: string): Promise<Response> {
})
}
function parseTracePageOffset(url: URL): number {
const value = url.searchParams.get('offset') ?? '0'
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) {
throw ApiError.badRequest('Invalid trace offset')
}
return Number(value)
}
async function getSessionTraceMeta(sessionId: string): Promise<{
title: string
projectPath: string
@@ -1004,7 +1036,7 @@ async function getSessionInspection(req: Request, sessionId: string, url: URL):
let transcriptSnapshot: Awaited<ReturnType<typeof sessionService.getInspectionTranscriptSnapshot>> | undefined
const getTranscriptSnapshot = async () => {
if (transcriptSnapshot !== undefined) return transcriptSnapshot
transcriptSnapshot = await sessionService.getInspectionTranscriptSnapshot(sessionId).catch(() => null)
transcriptSnapshot = await sessionService.getInspectionTranscriptSnapshot(sessionId)
return transcriptSnapshot
}
@@ -1427,7 +1459,15 @@ async function branchSession(req: Request, sessionId: string): Promise<Response>
}
}
async function assertCheckpointPreviewBudget(sessionId: string): Promise<void> {
const found = await sessionService.findSessionFile(sessionId)
if (found && Bun.file(found.filePath).size > 16 * 1024 * 1024) {
throw new ApiError(413, 'This transcript exceeds the full checkpoint preview budget. Chat history remains available in pages.', 'HISTORY_CHECKPOINT_PREVIEW_LIMIT')
}
}
async function getTurnCheckpoints(req: Request, sessionId: string): Promise<Response> {
await assertCheckpointPreviewBudget(sessionId)
const checkpoints = await listSessionTurnCheckpoints(sessionId, req.signal, new URL(req.url).searchParams.get('frozen') === 'true')
// Make this turn's real changed files previewable even when they live outside
// the session workdir (e.g. the user told the model to write to an absolute
@@ -1441,6 +1481,7 @@ async function getTurnCheckpoints(req: Request, sessionId: string): Promise<Resp
}
async function getTurnCheckpointDiff(sessionId: string, url: URL): Promise<Response> {
await assertCheckpointPreviewBudget(sessionId)
const targetUserMessageId = url.searchParams.get('targetUserMessageId') || undefined
const userMessageIndexParam = url.searchParams.get('userMessageIndex')
const path = url.searchParams.get('path')
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtemp, rm, writeFile, appendFile, open, rename } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { readBoundedHistoryPage, streamBoundedHistory, withHistoryReadBudget, HISTORY_SCAN_BYTES, HISTORY_RECORD_BYTES } from './boundedSessionHistory.js'
let directory: string
let file: string
beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'history-budget-test-')); file = join(directory, 'session.jsonl') })
afterEach(async () => { await rm(directory, { recursive: true, force: true }) })
const row = (id: string, text = id) => JSON.stringify({ type: 'assistant', uuid: id, message: { role: 'assistant', content: [{ type: 'text', text }] } }) + '\n'
describe('bounded history pages', () => {
test('reads a bounded tail and pages every ordinary large record without loss', async () => {
const handle = await open(file, 'w')
for (let index = 0; index < 60; index++) await handle.write(row(String(index), 'x'.repeat(256 * 1024)))
await handle.close()
let cursor: string | undefined
const ids: string[] = []
do {
const result = await readBoundedHistoryPage(file, { cursor })
expect(result.page.scannedBytes).toBeLessThanOrEqual(HISTORY_SCAN_BYTES)
expect(result.page.omittedOversizedEntries).toBe(0)
expect(result.entries.length).toBeGreaterThan(0)
ids.unshift(...result.entries.map(item => item.entry.uuid as string))
cursor = result.page.nextCursor ?? undefined
} while (cursor)
expect(ids).toEqual(Array.from({ length: 60 }, (_, index) => String(index)))
})
test('skips giant lines across bounded windows and preserves both neighboring messages', async () => {
await writeFile(file, row('before'))
const handle = await open(file, 'a')
await handle.write('{"message":"')
for (let index = 0; index < 12; index++) await handle.write('x'.repeat(1024 * 1024))
await handle.write('"}\n' + row('after'))
await handle.close()
let cursor: string | undefined
const ids: string[] = []
let omissions = 0
let requests = 0
do {
const result = await readBoundedHistoryPage(file, { cursor })
expect(result.page.scannedBytes).toBeLessThanOrEqual(HISTORY_SCAN_BYTES)
omissions += result.page.omittedOversizedEntries
ids.unshift(...result.entries.map(item => item.entry.uuid as string))
cursor = result.page.nextCursor ?? undefined
requests++
expect(requests).toBeLessThan(8)
} while (cursor)
expect(ids).toEqual(['before', 'after'])
expect(omissions).toBe(1)
})
test('snapshot cursors tolerate appends but reject replacement and malformed cursors', async () => {
await writeFile(file, row('a') + row('b'))
const first = await readBoundedHistoryPage(file, { limit: 1 })
await appendFile(file, row('c'))
const second = await readBoundedHistoryPage(file, { cursor: first.page.nextCursor! })
expect(second.entries.map(item => item.entry.uuid)).toEqual(['a'])
await writeFile(`${file}.new`, row('replacement'))
await rename(`${file}.new`, file)
await expect(readBoundedHistoryPage(file, { cursor: first.page.nextCursor! })).rejects.toMatchObject({ statusCode: 409 })
await expect(readBoundedHistoryPage(file, { cursor: 'garbage' })).rejects.toThrow('Invalid history cursor')
})
test('forward recovery bounds single records, parses surrounding evidence, and aborts promptly', async () => {
await writeFile(file, row('a') + row('too-large', 'x'.repeat(HISTORY_RECORD_BYTES + 100)) + row('b'))
const ids: unknown[] = []
const result = await streamBoundedHistory(file, entry => ids.push(entry.uuid))
expect(ids).toEqual(['a', 'b'])
expect(result.omittedRecords).toBe(1)
const controller = new AbortController()
let visits = 0
await expect(streamBoundedHistory(file, () => { visits++; controller.abort() }, controller.signal)).rejects.toThrow()
expect(visits).toBe(1)
})
test('rejects queue overflow and removes aborted waiters without starving later reads', async () => {
let release!: () => void
const hold = new Promise<void>(resolve => { release = resolve })
const active = [withHistoryReadBudget(undefined, () => hold), withHistoryReadBudget(undefined, () => hold)]
const controller = new AbortController()
const cancelled = withHistoryReadBudget(controller.signal, async () => 'unreachable').catch(error => error)
const queued = Array.from({ length: 7 }, () => withHistoryReadBudget(undefined, async () => 'ok'))
await expect(withHistoryReadBudget(undefined, async () => 'overflow')).rejects.toMatchObject({ statusCode: 429 })
controller.abort()
await cancelled
const replacement = withHistoryReadBudget(undefined, async () => 'replacement')
release()
await Promise.all(active)
expect(await Promise.all(queued)).toEqual(Array(7).fill('ok'))
expect(await replacement).toBe('replacement')
})
})
@@ -0,0 +1,211 @@
import { open } from 'node:fs/promises'
import { ApiError } from '../middleware/errorHandler.js'
export const HISTORY_SCAN_BYTES = 4 * 1024 * 1024
export const HISTORY_RECORD_BYTES = 1024 * 1024
export const HISTORY_PAGE_BYTES = 1536 * 1024
export const HISTORY_PAGE_RECORDS = 100
type Cursor = { version: 1; dev: string; ino: string; size: number; mtime: string; offset: number; skipping: boolean }
export type HistoryPageInfo = {
nextCursor: string | null
hasMore: boolean
historyComplete: boolean
sourceVersion: string
scannedBytes: number
contextScanBytes?: number
omittedOversizedEntries: number
}
export type BoundedHistoryEntry = { entry: Record<string, unknown>; byteStart: number; byteEnd: number }
function aborted(signal?: AbortSignal) {
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError')
}
// A fixed admission budget prevents repeated tab switches from queuing unlimited
// scans. Waiters are removed on abort, not just ignored when their scan completes.
type Waiter = { start: () => void; reject: (reason: unknown) => void; signal?: AbortSignal; abort: () => void }
const pools = {
page: { active: 0, capacity: 2, queueCapacity: 8, waiting: [] as Waiter[] },
context: { active: 0, capacity: 1, queueCapacity: 4, waiting: [] as Waiter[] },
recovery: { active: 0, capacity: 1, queueCapacity: 2, waiting: [] as Waiter[] },
metadata: { active: 0, capacity: 2, queueCapacity: 128, waiting: [] as Waiter[] },
}
async function acquire(signal: AbortSignal | undefined, lane: keyof typeof pools): Promise<() => void> {
aborted(signal)
const pool = pools[lane]
if (pool.active >= pool.capacity) {
if (pool.waiting.length >= pool.queueCapacity) throw new ApiError(429, 'History reader is busy; retry shortly', 'HISTORY_BUSY')
await new Promise<void>((resolve, reject) => {
const item: Waiter = { start: resolve, reject, signal, abort: () => {} }
item.abort = () => {
const index = pool.waiting.indexOf(item)
if (index >= 0) pool.waiting.splice(index, 1)
reject(signal?.reason ?? new DOMException('Aborted', 'AbortError'))
}
pool.waiting.push(item)
signal?.addEventListener('abort', item.abort, { once: true })
})
} else pool.active++
return () => {
const next = pool.waiting.shift()
if (next) {
next.signal?.removeEventListener('abort', next.abort)
next.start()
} else pool.active--
}
}
export async function withHistoryReadBudget<T>(signal: AbortSignal | undefined, read: () => Promise<T>, lane: keyof typeof pools = 'page'): Promise<T> {
const release = await acquire(signal, lane)
try { aborted(signal); return await read() } finally { release() }
}
function decodeCursor(value: string): Cursor {
try {
if (value.length > 2048) throw new Error('long cursor')
const cursor = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Cursor
if (cursor.version !== 1 || typeof cursor.dev !== 'string' || typeof cursor.ino !== 'string' || typeof cursor.mtime !== 'string' ||
!Number.isSafeInteger(cursor.size) || cursor.size < 0 || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0 || cursor.offset > cursor.size || typeof cursor.skipping !== 'boolean') throw new Error('invalid cursor')
return cursor
} catch { throw ApiError.badRequest('Invalid history cursor') }
}
export async function readBoundedHistoryPage(filePath: string, options: { cursor?: string; limit?: number; signal?: AbortSignal } = {}): Promise<{ entries: BoundedHistoryEntry[]; page: HistoryPageInfo }> {
if (options.limit !== undefined && (!Number.isFinite(options.limit) || options.limit < 1)) throw new ApiError(400, 'History limit must be a positive finite number', 'INVALID_HISTORY_LIMIT')
return withHistoryReadBudget(options.signal, async () => {
const handle = await open(filePath, 'r')
try {
aborted(options.signal)
const stat = await handle.stat({ bigint: true })
const current = { dev: String(stat.dev), ino: String(stat.ino), size: Number(stat.size), mtime: String(stat.mtimeNs) }
const cursor: Cursor = options.cursor ? decodeCursor(options.cursor) : { version: 1, ...current, offset: current.size, skipping: false }
if (cursor.dev !== current.dev || cursor.ino !== current.ino || current.size < cursor.size || (current.size === cursor.size && current.mtime !== cursor.mtime)) {
throw new ApiError(409, 'Session history changed; reload the newest page', 'HISTORY_CHANGED')
}
const sourceVersion = `${cursor.dev}:${cursor.ino}:${cursor.size}:${cursor.mtime}`
const length = Math.min(HISTORY_SCAN_BYTES, cursor.offset)
const start = cursor.offset - length
const buffer = Buffer.allocUnsafe(length)
let read = 0
while (read < length) {
aborted(options.signal)
const result = await handle.read(buffer, read, length - read, start + read)
if (!result.bytesRead) throw new ApiError(409, 'Session history changed during read', 'HISTORY_CHANGED')
read += result.bytesRead
}
const limit = Math.max(1, Math.min(HISTORY_PAGE_RECORDS, Math.floor(options.limit ?? HISTORY_PAGE_RECORDS)))
const entries: BoundedHistoryEntry[] = []
let position = length
let outputBytes = 0
let skipping = cursor.skipping
let omitted = 0
while (position > 0 && entries.length < limit) {
aborted(options.signal)
const lineEnd = buffer[position - 1] === 10 ? position - 1 : position
const newline = buffer.lastIndexOf(10, lineEnd - 1)
const lineStart = newline + 1
const lineLength = lineEnd - lineStart
if (newline < 0 && start > 0) {
// Retry a small boundary-straddling record on the next page. A record
// spanning the whole scan budget is skipped without ever assembling it.
if (!skipping && lineLength <= HISTORY_RECORD_BYTES && entries.length > 0) break
if (!skipping) omitted++
skipping = true
position = 0
break
}
if (skipping) {
skipping = false
position = lineStart
continue
}
if (lineLength > HISTORY_RECORD_BYTES) {
omitted++
position = lineStart
continue
}
if (lineLength && outputBytes + lineLength > HISTORY_PAGE_BYTES && entries.length) break
position = lineStart
if (!lineLength) continue
try {
const entry = JSON.parse(buffer.subarray(lineStart, lineEnd).toString('utf8'))
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
entries.push({ entry, byteStart: start + lineStart, byteEnd: start + lineEnd })
outputBytes += lineLength
}
} catch { omitted++ }
// Each individual parse has a byte ceiling; also yield between records
// so a large page cannot monopolize the shared server event loop.
await new Promise<void>(resolve => setImmediate(resolve))
}
const offset = start + position
const after = await handle.stat({ bigint: true })
if (after.ino !== stat.ino || after.size < stat.size || (after.size === stat.size && after.mtimeNs !== stat.mtimeNs)) throw new ApiError(409, 'Session history changed during read', 'HISTORY_CHANGED')
const nextCursor = offset > 0 ? Buffer.from(JSON.stringify({ ...cursor, offset, skipping })).toString('base64url') : null
return {
entries: entries.reverse(),
page: { nextCursor, hasMore: offset > 0, historyComplete: !options.cursor && offset === 0 && omitted === 0, sourceVersion, scannedBytes: read, omittedOversizedEntries: omitted },
}
} finally { await handle.close() }
})
}
/** Forward reducer source: never retain an unbounded JSONL line or source file. */
export async function streamBoundedHistory(filePath: string, onEntry: (entry: Record<string, unknown>, completeLine: boolean, byteStart: number) => void, signal?: AbortSignal, options: { startOffset?: number; endOffset?: number; onSkipped?: () => void } = {}): Promise<{ sourceVersion: string; omittedRecords: number; oversizedRecords: number; scannedBytes: number; nextOffset: number }> {
const handle = await open(filePath, 'r')
try {
const stat = await handle.stat({ bigint: true })
const size = Math.min(Number(stat.size), options.endOffset ?? Number(stat.size))
const firstOffset = options.startOffset ?? 0
let lineStart = firstOffset
let nextOffset = firstOffset
const chunk = Buffer.allocUnsafe(64 * 1024)
let parts: Buffer[] = []
let length = 0
let skipping = false
let omittedRecords = 0
let oversizedRecords = 0
const flush = (completeLine = true) => {
if (skipping) { omittedRecords++; oversizedRecords++; options.onSkipped?.() }
else if (length) {
let entry: unknown
try {
entry = JSON.parse((parts.length === 1 ? parts[0]! : Buffer.concat(parts, length)).toString('utf8'))
} catch { omittedRecords++; options.onSkipped?.() }
// Consumer failures (limits, cancellation, I/O) must propagate. They
// are not malformed JSON and must never become a successful snapshot.
if (entry && typeof entry === 'object' && !Array.isArray(entry)) onEntry(entry as Record<string, unknown>, completeLine, lineStart)
}
parts = []; length = 0; skipping = false
}
for (let offset = firstOffset; offset < size;) {
aborted(signal)
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, size - offset), offset)
if (!bytesRead) throw new ApiError(409, 'Session history changed during recovery', 'HISTORY_CHANGED')
offset += bytesRead
let start = 0
while (start < bytesRead) {
aborted(signal)
const found = chunk.indexOf(10, start)
const end = found >= 0 && found < bytesRead ? found : bytesRead
if (!skipping) {
length += end - start
if (length > HISTORY_RECORD_BYTES) { skipping = true; parts = [] }
else parts.push(Buffer.from(chunk.subarray(start, end)))
}
start = end + 1
if (end < bytesRead) {
flush()
lineStart = offset - bytesRead + end + 1
nextOffset = lineStart
await new Promise<void>(resolve => setImmediate(resolve))
}
}
}
if (length || skipping) flush(false)
const after = await handle.stat({ bigint: true })
if (after.size < stat.size || (after.size === stat.size && after.mtimeNs !== stat.mtimeNs)) throw new ApiError(409, 'Session changed during recovery; retry', 'HISTORY_CHANGED')
return { sourceVersion: `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}`, omittedRecords, oversizedRecords, scannedBytes: size - firstOffset, nextOffset }
} finally { await handle.close() }
}
@@ -0,0 +1,21 @@
import { expect, test } from 'bun:test'
import { mkdtemp, rm, writeFile, truncate } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { LEGACY_TRANSCRIPT_BYTES, readLegacyTranscriptFiles } from './legacyTranscriptBudget.js'
test('rejects oversized single and combined fragments before body allocation', async () => {
const dir = await mkdtemp(join(tmpdir(), 'legacy-history-budget-'))
try {
const first = join(dir, 'first.jsonl')
const second = join(dir, 'second.jsonl')
await writeFile(first, '{}\n')
expect((await readLegacyTranscriptFiles([first]))[0]!.bytes.toString()).toBe('{}\n')
await truncate(first, 500 * 1024 * 1024)
await expect(readLegacyTranscriptFiles([first])).rejects.toMatchObject({ statusCode: 413, code: 'TEAM_TRANSCRIPT_TOO_LARGE' })
await truncate(first, LEGACY_TRANSCRIPT_BYTES / 2 + 1)
await writeFile(second, '')
await truncate(second, LEGACY_TRANSCRIPT_BYTES / 2 + 1)
await expect(readLegacyTranscriptFiles([first, second])).rejects.toMatchObject({ statusCode: 413 })
} finally { await rm(dir, { recursive: true, force: true }) }
})
@@ -0,0 +1,35 @@
import type { Stats } from 'node:fs'
import { open } from 'node:fs/promises'
import { ApiError } from '../middleware/errorHandler.js'
export const LEGACY_TRANSCRIPT_BYTES = 8 * 1024 * 1024
/** Legacy parsers may materialize only a small, fixed aggregate source budget. */
export async function readLegacyTranscriptFiles(paths: string[]): Promise<Array<{ bytes: Buffer; stat: Stats }>> {
if (paths.length > 128) throw new ApiError(413, 'Too many transcript fragments for legacy history', 'TEAM_TRANSCRIPT_TOO_LARGE')
const handles: Awaited<ReturnType<typeof open>>[] = []
try {
const sources = []
let total = 0
for (const filePath of paths) {
const handle = await open(filePath, 'r')
handles.push(handle)
const stat = await handle.stat()
total += stat.size
if (total > LEGACY_TRANSCRIPT_BYTES) throw new ApiError(413, 'Transcript exceeds the legacy history budget; use paged session history', 'TEAM_TRANSCRIPT_TOO_LARGE')
sources.push({ handle, stat })
}
const result = []
for (const source of sources) {
const bytes = Buffer.allocUnsafe(source.stat.size)
let position = 0
while (position < bytes.length) {
const { bytesRead } = await source.handle.read(bytes, position, Math.min(128 * 1024, bytes.length - position), position)
if (!bytesRead) throw new ApiError(409, 'Transcript changed while reading', 'HISTORY_CHANGED')
position += bytesRead
}
result.push({ bytes, stat: source.stat })
}
return result
} finally { await Promise.all(handles.map(handle => handle.close())) }
}
@@ -18,6 +18,8 @@ import { openLocalIndexDatabase } from './database.js'
import { createSessionIndex, type SessionIndex } from './sessionIndex.js'
import {
SESSION_SUMMARY_PARSER_VERSION,
MAX_PROJECTION_RECORD_BYTES,
MAX_PROJECTION_RECORDS,
createSessionProjector,
type SessionSourceCandidate,
} from './sessionProjector.js'
@@ -86,6 +88,41 @@ async function sourceHash(path: string): Promise<string> {
}
describe('session projector', () => {
it('rejects oversized records before concatenation and preserves the canonical file', async () => {
const root = await createTempDir('projector-record-budget')
const candidate = await createCandidate({ root, projectPath: '-repo', sessionId: 'large', content: line(user('x'.repeat(MAX_PROJECTION_RECORD_BYTES + 1), '2026-01-01T00:00:00Z')) })
const before = await sourceHash(candidate.path)
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
try {
const projector = createSessionProjector({ database, index: createSessionIndex(database), scope: root })
await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' })
expect(await sourceHash(candidate.path)).toBe(before)
} finally { database.close() }
})
it('bounds locator and reducer growth for arbitrarily many tiny records', async () => {
const root = await createTempDir('projector-count-budget')
const candidate = await createCandidate({ root, projectPath: '-repo', sessionId: 'many', content: line({ type: 'unknown' }).repeat(MAX_PROJECTION_RECORDS + 1) })
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
try {
const projector = createSessionProjector({ database, index: createSessionIndex(database), scope: root })
await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' })
} finally { database.close() }
})
it('keeps the record budget across incremental appends', async () => {
const root = await createTempDir('projector-append-budget')
const item = line({ type: 'unknown' })
const candidate = await createCandidate({ root, projectPath: '-repo', sessionId: 'append', content: item.repeat(MAX_PROJECTION_RECORDS) })
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
try {
const projector = createSessionProjector({ database, index: createSessionIndex(database), scope: root })
expect((await projector.projectSource(candidate)).kind).toBe('indexed')
await appendFile(candidate.path, item)
await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' })
} finally { database.close() }
})
it('fully builds without changing JSONL and preserves malformed/pending semantics', async () => {
const root = await createTempDir('projector-full')
const candidate = await createCandidate({
@@ -900,4 +937,35 @@ describe('session projector', () => {
database.close()
}
})
it('rejects oversized retained identities while accepting large discarded bodies', async () => {
const root = await createTempDir('metadata-budget')
const candidate = await createCandidate({ root, projectPath: '-repo-a', sessionId: 'metadata',
content: line({ ...user('x'.repeat(2 * 1024 * 1024), '2026-01-01T00:00:00.000Z'), uuid: 'ordinary' }),
})
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
const index = createSessionIndex(database)
const projector = createSessionProjector({ database, index, scope: root })
try {
expect(await projector.projectSource(candidate)).toMatchObject({ kind: 'indexed' })
await appendFile(candidate.path, line({ ...user('small', '2026-01-01T00:00:01.000Z'), uuid: 'x'.repeat(4097) }))
await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' })
} finally { database.close() }
})
it('enforces aggregate retained metadata across appended windows', async () => {
const root = await createTempDir('metadata-total-budget')
const record = (index: number) => line({ ...user('small', '2026-01-01T00:00:00.000Z'), uuid: `${index}:` + 'x'.repeat(3000) })
const candidate = await createCandidate({ root, projectPath: '-repo-a', sessionId: 'metadata-total',
content: Array.from({ length: 3000 }, (_, index) => record(index)).join(''),
})
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
const index = createSessionIndex(database)
const projector = createSessionProjector({ database, index, scope: root })
try {
expect(await projector.projectSource(candidate)).toMatchObject({ kind: 'indexed' })
await appendFile(candidate.path, Array.from({ length: 3000 }, (_, index) => record(index + 3000)).join(''))
await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' })
} finally { database.close() }
})
})
@@ -122,6 +122,20 @@ type SourceProjectionBundle = {
locatorWrite: 'append' | 'replace'
}
export const MAX_PROJECTION_RECORD_BYTES = 8 * 1024 * 1024
export const MAX_PROJECTION_RECORDS = 50_000
export const MAX_PROJECTION_METADATA_BYTES = 16 * 1024 * 1024
const projectionMetadataBytes = new WeakMap<TranscriptProjection, number>()
const projectionRecordCounts = new WeakMap<TranscriptProjection, number>()
const MAX_CACHED_PROJECTIONS = 8
class ProjectionLimitError extends Error {
readonly code = 'LOCAL_INDEX_SOURCE_LIMIT'
constructor() {
super('Transcript exceeds the bounded local-index projection budget')
}
}
const READ_BUFFER_BYTES = 64 * 1024
const REDUCE_CHUNK_LIMIT = 256
const REDUCE_BYTE_LIMIT = 1024 * 1024
@@ -207,6 +221,7 @@ function initialProjection(candidate: SessionSourceCandidate): TranscriptProject
}
function retryFrom(error: unknown): Extract<SourceChange, { kind: 'retry' }> {
if (error instanceof ProjectionLimitError) throw error
if (error instanceof SourceReadRetryError) return error.change
return { kind: 'retry', reason: 'transient-io' }
}
@@ -251,6 +266,8 @@ async function streamProjection(options: {
let pendingSegmentsLength = 0
let chunks: TranscriptChunk[] = []
let chunkBytes = 0
let recordsRead = projectionRecordCounts.get(options.seed) ?? 0
let metadataBytes = projectionMetadataBytes.get(options.seed) ?? 0
const entryLocators: TranscriptEntryLocator[] = []
const flush = (): void => {
@@ -260,7 +277,48 @@ async function streamProjection(options: {
const reduced = reduceTranscriptWithLocators(
chunks,
projection,
{ isSubagent: options.isSubagent },
{
isSubagent: options.isSubagent,
validateRetainedMetadata(entry) {
const fields = ['type', 'uuid', 'messageId', 'timestamp', 'parent_tool_use_id',
'cwd', 'workDir', 'runtimeProviderId', 'runtimeModelId', 'customTitle', 'aiTitle',
'repository', 'worktreeSession', 'requestId', 'version', 'sessionId']
const message = entry.message as Record<string, unknown> | undefined
const values: unknown[] = fields.map(field => entry[field])
values.push(message?.id, message?.role, message?.model)
const content = message?.content
if (Array.isArray(content)) for (const block of content) {
if (block?.type !== 'tool_use') continue
values.push(block.name, block.name === 'Skill' ? block.input?.skill : undefined)
if (values.length > 16_384) throw new ProjectionLimitError()
}
const iterations = (message?.usage as { iterations?: unknown } | undefined)?.iterations
if (Array.isArray(iterations)) for (const iteration of iterations) {
if (iteration?.type === 'advisor_message') values.push(iteration.model)
if (values.length > 16_384) throw new ProjectionLimitError()
}
// Charge only metadata that the reducer/locators can retain. Large text
// and tool bodies are deliberately excluded from this lifetime budget.
let visited = 0
while (values.length) {
const value = values.pop()
if (value === undefined || value === null) continue
if (++visited > 16_384) throw new ProjectionLimitError()
metadataBytes += 64
if (typeof value === 'string') {
if (value.length > 4096) throw new ProjectionLimitError()
metadataBytes += Buffer.byteLength(value)
} else if (typeof value === 'object') {
for (const key in value) {
metadataBytes += Buffer.byteLength(key)
values.push((value as Record<string, unknown>)[key])
if (values.length > 16_384) throw new ProjectionLimitError()
}
}
if (metadataBytes > MAX_PROJECTION_METADATA_BYTES) throw new ProjectionLimitError()
}
},
},
)
projection = reduced.projection
entryLocators.push(...reduced.locators)
@@ -282,6 +340,7 @@ async function streamProjection(options: {
const newline = bytes.indexOf(0x0a, segmentStart)
if (newline === -1) {
const segment = bytes.subarray(segmentStart)
if (pendingSegmentsLength + segment.length > MAX_PROJECTION_RECORD_BYTES) throw new ProjectionLimitError()
pendingSegments.push(segment)
pendingSegmentsLength += segment.length
work.maxBufferedBytes = Math.max(
@@ -292,6 +351,9 @@ async function streamProjection(options: {
}
const finalSegment = bytes.subarray(segmentStart, newline + 1)
if (pendingSegmentsLength + finalSegment.length > MAX_PROJECTION_RECORD_BYTES) throw new ProjectionLimitError()
recordsRead += 1
if (recordsRead > MAX_PROJECTION_RECORDS) throw new ProjectionLimitError()
let completeLine: Buffer
if (pendingSegments.length === 0) {
completeLine = finalSegment
@@ -322,6 +384,8 @@ async function streamProjection(options: {
}
}
position += bytesRead
// File reads may resolve from cache; explicitly let UI/API work run between chunks.
await new Promise<void>(resolve => setImmediate(resolve))
}
flush()
@@ -358,10 +422,13 @@ async function streamProjection(options: {
}
options.assertActive()
projectionMetadataBytes.set(projection, metadataBytes)
projectionRecordCounts.set(projection, recordsRead)
return { projection, entryLocators, work }
} catch (error) {
thrown = error
if (
error instanceof ProjectionLimitError ||
error instanceof SourceReadRetryError ||
error instanceof TranscriptRebuildRequiredError ||
error instanceof ProjectionGenerationCancelledError
@@ -814,7 +881,11 @@ export function createSessionProjector(options: SessionProjectorOptions): Sessio
}
throw error
}
projectionCache.delete(candidate.path)
projectionCache.set(candidate.path, built.projection)
while (projectionCache.size > MAX_CACHED_PROJECTIONS) {
projectionCache.delete(projectionCache.keys().next().value!)
}
return { kind: 'indexed', action, ...built }
},
@@ -958,7 +1029,11 @@ export function createSessionProjector(options: SessionProjectorOptions): Sessio
}
throw error
}
projectionCache.delete(candidate.path)
projectionCache.set(candidate.path, built.projection)
while (projectionCache.size > MAX_CACHED_PROJECTIONS) {
projectionCache.delete(projectionCache.keys().next().value!)
}
return { kind: 'indexed', action, ...built }
},
@@ -33,6 +33,7 @@ export type TraceIndexDatabase = {
}
type OwnedStatement = {
finalize(): void
get(...bindings: TraceIndexBinding[]): unknown
all(...bindings: TraceIndexBinding[]): unknown[]
run(...bindings: TraceIndexBinding[]): {
@@ -150,8 +151,11 @@ export function openTraceIndexDatabase(options?: {
},
close() {
if (closed) return
database.clearQueryCache()
// Bun may evict a query from its internal cache while our cache still owns
// it. Finalize every owned statement before the strict SQLite close.
for (const statement of statements.values()) statement.finalize()
statements.clear()
database.clearQueryCache()
database.close(true)
closed = true
},
@@ -87,6 +87,45 @@ describe('trace index', () => {
expect(allColumns.map(row => row.name)).not.toContain('pending_tail')
})
test('aggregates large replacement and append batches once after resolving duplicate calls', async () => {
await createTestIndex()
const target = database!
let summaryUpdates = 0
const index = createTraceIndex({
...target,
transaction: callback => target.transaction(operation => callback({
...operation,
run(statement, ...bindings) {
if (statement.includes('UPDATE trace_sessions SET')) summaryUpdates += 1
return operation.run(statement, ...bindings)
},
})),
})
const calls = Array.from({ length: 2_000 }, (_, ordinal) => ({
id: `call-${ordinal}`, ordinal, byteStart: ordinal, byteLength: 1,
startedAt: '2026-07-15T01:00:00.000Z', completedAt: null,
status: 'ok', source: 'proxy', model: ordinal % 2 ? 'model-b' : 'model-a',
durationMs: 2, failed: false, inputTokens: 3, outputTokens: 4,
}))
const source = { sessionId: 'batch', filePath: '/tmp/batch.jsonl', size: 2_002, indexedBytes: 2_002, mtimeMs: 1 }
index.replaceSession({ source, calls, events: [] })
expect(summaryUpdates).toBeLessThanOrEqual(2)
expect(index.getSummary('batch')?.summary).toMatchObject({
apiCalls: 2_000, totalInputTokens: 6_000,
models: [{ model: 'model-a', calls: 1_000 }, { model: 'model-b', calls: 1_000 }],
})
summaryUpdates = 0
index.appendEntries({ source, calls: [
{ ...calls[0]!, ordinal: 2_000, byteStart: 2_000, failed: true, inputTokens: 5 },
{ ...calls[1]!, ordinal: 2_001, byteStart: 2_001, model: 'model-a' },
], events: [] })
expect(summaryUpdates).toBeLessThanOrEqual(2)
expect(index.getSummary('batch')?.summary).toMatchObject({
apiCalls: 2_000, failedCalls: 1, totalInputTokens: 6_002,
models: [{ model: 'model-a', calls: 1_001 }, { model: 'model-b', calls: 999 }],
})
})
test('keeps the latest call locator and exposes revision-based changes without duplicates', async () => {
const index = await createTestIndex()
@@ -285,7 +324,7 @@ describe('trace index', () => {
const index = createTraceIndex(database)
expect(database.read(operation => operation.get<{ user_version: number }>('PRAGMA user_version')))
.toEqual({ user_version: 5 })
.toEqual({ user_version: 6 })
expect(index.getSession('frozen')).toMatchObject({
revision: 7,
lastResetRevision: 3,
@@ -352,6 +391,11 @@ describe('trace index', () => {
const frozen = new Database(databasePath)
frozen.exec(`
DROP INDEX trace_calls_page_order_idx;
DROP INDEX trace_calls_model_order_idx;
ALTER TABLE trace_sources DROP COLUMN oversized_records;
ALTER TABLE trace_sources DROP COLUMN scan_truncated;
ALTER TABLE trace_sources DROP COLUMN window_start_byte;
DROP INDEX trace_session_models_order_idx;
ALTER TABLE trace_session_models DROP COLUMN first_started_at;
ALTER TABLE trace_session_models DROP COLUMN first_ordinal;
@@ -370,14 +414,14 @@ describe('trace index', () => {
const migrated = createTraceIndex(candidate)
expect(candidate.read(operation => operation.get<{ user_version: number }>(
'PRAGMA user_version',
))).toEqual({ user_version: 5 })
))).toEqual({ user_version: 6 })
expect(migrated.getSummary(`frozen-v${version}`)?.summary.models).toEqual([
{ model: 'z-model', calls: 1 },
{ model: 'a-model', calls: 1 },
])
expect(migrated.getSource(`frozen-v${version}`)).toMatchObject({
state: 'degraded',
lastErrorCode: 'TRACE_INDEX_V5_REBUILD_REQUIRED',
lastErrorCode: 'TRACE_INDEX_V6_REBUILD_REQUIRED',
})
expect(candidate.read(operation => operation.all<{
ordinal: number
@@ -390,6 +434,35 @@ describe('trace index', () => {
}
})
test('migrates a v5 fixture to bounded windows while preserving existing rows and unknown tables', async () => {
const index = await createTestIndex()
index.replaceSession({
source: { sessionId: 'v5', filePath: '/tmp/v5.jsonl', size: 100, indexedBytes: 100, mtimeMs: 1 },
calls: [], events: [],
})
database!.write(operation => operation.exec(`
CREATE TABLE future_extension (value TEXT);
INSERT INTO future_extension VALUES ('preserve-me');
DROP INDEX trace_calls_page_order_idx;
DROP INDEX trace_calls_model_order_idx;
ALTER TABLE trace_sources DROP COLUMN oversized_records;
ALTER TABLE trace_sources DROP COLUMN scan_truncated;
ALTER TABLE trace_sources DROP COLUMN window_start_byte;
PRAGMA user_version = 5;
`))
database!.close()
database = openTraceIndexDatabase({ path: path.join(tmpDir!, 'trace-index-v1.sqlite') })
const upgraded = createTraceIndex(database)
expect(upgraded.getSource('v5')).toMatchObject({
size: 100, indexedBytes: 100, oversizedRecords: 0, scanTruncated: false, windowStartByte: 0,
state: 'degraded', lastErrorCode: 'TRACE_INDEX_V6_REBUILD_REQUIRED',
})
expect(database.read(operation => operation.get<{ value: string }>('SELECT value FROM future_extension')))
.toEqual({ value: 'preserve-me' })
expect(database.read(operation => operation.get<{ user_version: number }>('PRAGMA user_version')))
.toEqual({ user_version: 6 })
})
test('rolls back every v4 schema change when migration fails partway through', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'trace-index-v4-rollback-'))
const databasePath = path.join(tmpDir, 'trace-index-v3-broken.sqlite')
@@ -397,7 +470,12 @@ describe('trace index', () => {
initial.close()
const frozen = new Database(databasePath)
frozen.exec(`
DROP INDEX trace_session_models_order_idx;
DROP INDEX trace_calls_page_order_idx;
DROP INDEX trace_calls_model_order_idx;
ALTER TABLE trace_sources DROP COLUMN oversized_records;
ALTER TABLE trace_sources DROP COLUMN scan_truncated;
ALTER TABLE trace_sources DROP COLUMN window_start_byte;
DROP INDEX trace_session_models_order_idx;
ALTER TABLE trace_session_models DROP COLUMN first_started_at;
ALTER TABLE trace_session_models DROP COLUMN first_ordinal;
ALTER TABLE trace_calls DROP COLUMN first_ordinal;
@@ -416,3 +494,14 @@ describe('trace index', () => {
inspected.close()
})
})
test('strictly closes owned statements after Bun query-cache eviction', async () => {
await createTestIndex()
// Bun's internal query cache is bounded; our owned cache can outlive its entries.
for (let index = 0; index < 256; index += 1) {
expect(database!.read(operation => operation.get<{ value: number }>(`SELECT ${index} AS value`))).toEqual({ value: index })
}
expect(() => database!.close()).not.toThrow()
expect(() => database!.close()).not.toThrow()
expect(() => database!.read(operation => operation.get('SELECT 1'))).toThrow('Trace index database is closed')
})
+101 -21
View File
@@ -15,6 +15,10 @@ export type TraceSourceInput = {
fingerprint?: string | null
pendingTailBytes?: number
nextOrdinal?: number
oversizedRecords?: number
scanTruncated?: boolean
windowStartByte?: number
oversizedContinuation?: boolean
}
export type TraceCallLocator = {
@@ -77,6 +81,9 @@ export type TraceSourceRecord = Required<Pick<
revision: number
lastResetRevision: number
resetToken: string
oversizedRecords: number
scanTruncated: boolean
windowStartByte: number
state: 'ready' | 'degraded'
lastErrorCode: string | null
}
@@ -120,6 +127,7 @@ export interface TraceIndex {
event: TraceEventLocator
}): TraceSourceRecord
getSession(sessionId: string): TraceSessionProjection | null
getSessionPage(sessionId: string, offset: number, limit: number): (TraceSessionProjection & { totalCalls: number; totalEvents: number }) | null
getCallLocator(sessionId: string, callId: string): {
source: TraceSourceRecord
call: TraceCallLocator
@@ -149,6 +157,9 @@ type SourceRow = {
next_ordinal: number
state: 'ready' | 'degraded'
last_error_code: string | null
oversized_records: number
scan_truncated: number
window_start_byte: number
}
type SummaryRow = SourceRow & {
@@ -211,7 +222,10 @@ const SOURCE_COLUMNS = `
session.reset_token,
session.next_ordinal,
source.state,
source.last_error_code
source.last_error_code,
source.oversized_records,
source.scan_truncated,
source.window_start_byte
`
const SUMMARY_COLUMNS = `
@@ -228,8 +242,8 @@ const UPSERT_SOURCE_SQL = `
INSERT INTO trace_sources (
session_id, file_path, size_bytes, mtime_ms, indexed_bytes,
revision, last_reset_revision, state, last_error_code, updated_at_ms,
file_identity, fingerprint, pending_tail_bytes
) VALUES (?, ?, ?, ?, ?, ?, ?, 'ready', NULL, ?, ?, ?, ?)
file_identity, fingerprint, pending_tail_bytes, oversized_records, scan_truncated, window_start_byte
) VALUES (?, ?, ?, ?, ?, ?, ?, 'ready', ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
file_path = excluded.file_path,
size_bytes = excluded.size_bytes,
@@ -238,11 +252,14 @@ ON CONFLICT(session_id) DO UPDATE SET
revision = excluded.revision,
last_reset_revision = excluded.last_reset_revision,
state = 'ready',
last_error_code = NULL,
last_error_code = excluded.last_error_code,
updated_at_ms = excluded.updated_at_ms,
file_identity = excluded.file_identity,
fingerprint = excluded.fingerprint,
pending_tail_bytes = excluded.pending_tail_bytes
pending_tail_bytes = excluded.pending_tail_bytes,
oversized_records = excluded.oversized_records,
scan_truncated = excluded.scan_truncated,
window_start_byte = excluded.window_start_byte
`
const UPSERT_SESSION_SQL = `
@@ -320,6 +337,9 @@ function sourceFromRow(row: SourceRow): TraceSourceRecord {
resetToken: row.reset_token,
state: row.state,
lastErrorCode: row.last_error_code,
oversizedRecords: row.oversized_records,
scanTruncated: row.scan_truncated === 1,
windowStartByte: row.window_start_byte,
}
}
@@ -385,7 +405,7 @@ function modelsForSession(
`SELECT model, call_count
FROM trace_session_models
WHERE session_id = ?
ORDER BY first_started_at, first_ordinal, model`,
ORDER BY first_started_at, first_ordinal, model LIMIT 64`,
sessionId,
).map(row => ({ model: row.model, calls: row.call_count }))
}
@@ -432,12 +452,11 @@ function resolvedNextOrdinal(
events: TraceEventLocator[],
floor = 0,
): number {
const derived = Math.max(
floor,
...calls.map(call => call.ordinal + 1),
...events.map(event => event.ordinal + 1),
)
return source.nextOrdinal ?? derived
if (source.nextOrdinal !== undefined) return source.nextOrdinal
let derived = floor
for (const call of calls) derived = Math.max(derived, call.ordinal + 1)
for (const event of events) derived = Math.max(derived, event.ordinal + 1)
return derived
}
function normalizeSource(
@@ -455,7 +474,11 @@ function normalizeSource(
!safeInteger(pendingTailBytes) ||
pendingTailBytes !== source.size - source.indexedBytes ||
!safeInteger(nextOrdinal) ||
!Number.isFinite(source.mtimeMs)
!Number.isFinite(source.mtimeMs) ||
!safeInteger(source.oversizedRecords ?? 0) ||
!safeInteger(source.windowStartByte ?? 0) ||
(source.windowStartByte ?? 0) > source.indexedBytes ||
(source.scanTruncated !== undefined && typeof source.scanTruncated !== 'boolean')
) {
throw new Error('Invalid trace source progress')
}
@@ -465,6 +488,10 @@ function normalizeSource(
fingerprint: source.fingerprint ?? null,
pendingTailBytes,
nextOrdinal,
oversizedRecords: source.oversizedRecords ?? 0,
scanTruncated: source.scanTruncated ?? false,
windowStartByte: source.windowStartByte ?? 0,
oversizedContinuation: source.oversizedContinuation ?? false,
}
}
@@ -504,10 +531,14 @@ function writeSource(
source.indexedBytes,
revision,
lastResetRevision,
source.oversizedContinuation ? 'TRACE_OVERSIZED_RECORD_CONTINUATION' : null,
Date.now(),
source.fileIdentity,
source.fingerprint,
source.pendingTailBytes,
source.oversizedRecords,
source.scanTruncated ? 1 : 0,
source.windowStartByte,
)
operation.run(
UPSERT_SESSION_SQL,
@@ -711,25 +742,57 @@ function resetSummary(operation: TraceIndexWriteOperation, sessionId: string): v
operation.run('DELETE FROM trace_session_models WHERE session_id = ?', sessionId)
}
// A rebuild already has the final LWW rows. Recomputing model order and the
// latest call after every insertion repeatedly scans/sorts the growing table.
function rebuildSummary(operation: TraceIndexWriteOperation, sessionId: string): void {
operation.run(
`UPDATE trace_sessions SET
(api_calls, failed_calls, total_duration_ms, total_input_tokens, total_output_tokens) = (
SELECT COUNT(*), COALESCE(SUM(failed), 0), COALESCE(SUM(duration_ms), 0),
COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0)
FROM trace_calls WHERE session_id = ?
),
summary_updated_at = (
SELECT COALESCE(completed_at, started_at) FROM trace_calls
WHERE session_id = ? ORDER BY started_at DESC, first_ordinal DESC LIMIT 1
)
WHERE session_id = ?`,
sessionId, sessionId, sessionId,
)
operation.run(
`INSERT INTO trace_session_models (session_id, model, call_count, first_started_at, first_ordinal)
SELECT ?, model, call_count, started_at, first_ordinal FROM (
SELECT model, started_at, first_ordinal,
COUNT(*) OVER (PARTITION BY model) AS call_count,
ROW_NUMBER() OVER (PARTITION BY model ORDER BY started_at, first_ordinal) AS position
FROM trace_calls WHERE session_id = ? AND model IS NOT NULL AND model != ''
) WHERE position = 1`,
sessionId, sessionId,
)
}
function locatorRows(
operation: TraceIndexReadOperation,
sessionId: string,
revisionFloor?: number,
page?: { offset: number; limit: number },
): { calls: TraceCallLocator[]; events: TraceEventLocator[] } {
const revisionWhere = revisionFloor === undefined ? '' : ' AND revision > ?'
const bindings = revisionFloor === undefined
? [sessionId] as const
: [sessionId, revisionFloor] as const
const pageSql = page ? ' LIMIT ? OFFSET ?' : ''
const pageBindings = page ? [page.limit, page.offset] : []
return {
calls: operation.all<CallRow>(
`SELECT * FROM trace_calls
WHERE session_id = ?${revisionWhere}
ORDER BY started_at, first_ordinal`,
...bindings,
ORDER BY started_at, first_ordinal${pageSql}`,
...bindings, ...pageBindings,
).map(callFromRow),
events: operation.all<EventRow>(
`SELECT * FROM trace_events WHERE session_id = ?${revisionWhere} ORDER BY timestamp, ordinal`,
...bindings,
`SELECT * FROM trace_events WHERE session_id = ?${revisionWhere} ORDER BY timestamp, ordinal${pageSql}`,
...bindings, ...pageBindings,
).map(eventFromRow),
}
}
@@ -760,8 +823,8 @@ function replaceProjection(
for (const call of input.calls) {
validateLocator(call, source)
writeCall(operation, source.sessionId, call, revision)
updateSummaryForCall(operation, source.sessionId, null, call)
}
rebuildSummary(operation, source.sessionId)
for (const event of input.events) {
validateLocator(event, source)
writeEvent(operation, source.sessionId, event, revision)
@@ -797,13 +860,17 @@ function appendProjection(
)
for (const call of input.calls) {
validateLocator(call, source)
const previous = operation.get<CallRow>(
const previous = input.calls.length === 1 ? operation.get<CallRow>(
'SELECT * FROM trace_calls WHERE session_id = ? AND call_id = ?',
source.sessionId,
call.id,
)
) : null
writeCall(operation, source.sessionId, call, revision)
updateSummaryForCall(operation, source.sessionId, previous, call)
if (input.calls.length === 1) updateSummaryForCall(operation, source.sessionId, previous, call)
}
if (input.calls.length > 1) {
resetSummary(operation, source.sessionId)
rebuildSummary(operation, source.sessionId)
}
for (const event of input.events) {
validateLocator(event, source)
@@ -848,6 +915,19 @@ export function createTraceIndex(database: TraceIndexDatabase): TraceIndex {
return { ...overview, ...locatorRows(operation, sessionId) }
})
},
getSessionPage(sessionId, offset, limit) {
return database.read(operation => {
const overview = readOverview(operation, sessionId)
if (!overview) return null
const page = { offset: Math.max(0, Math.trunc(offset)), limit: Math.max(1, Math.min(100, Math.trunc(limit))) }
return {
...overview,
...locatorRows(operation, sessionId, undefined, page),
totalCalls: overview.summary.apiCalls,
totalEvents: operation.get<{ count: number }>('SELECT COUNT(*) AS count FROM trace_events WHERE session_id = ?', sessionId)?.count ?? 0,
}
})
},
getCallLocator(sessionId, callId) {
return database.read(operation => {
const source = readSource(operation, sessionId)
@@ -1,6 +1,6 @@
import type { Database } from 'bun:sqlite'
export const TRACE_INDEX_SCHEMA_VERSION = 5
export const TRACE_INDEX_SCHEMA_VERSION = 6
export const TRACE_INDEX_SCHEMA_UNSUPPORTED =
'TRACE_INDEX_SCHEMA_UNSUPPORTED' as const
@@ -197,12 +197,22 @@ UPDATE trace_sources
SET state = 'degraded', last_error_code = 'TRACE_INDEX_V5_REBUILD_REQUIRED';
`
const SCHEMA_V6 = `
ALTER TABLE trace_sources ADD COLUMN oversized_records INTEGER NOT NULL DEFAULT 0;
ALTER TABLE trace_sources ADD COLUMN scan_truncated INTEGER NOT NULL DEFAULT 0;
ALTER TABLE trace_sources ADD COLUMN window_start_byte INTEGER NOT NULL DEFAULT 0;
CREATE INDEX trace_calls_page_order_idx ON trace_calls(session_id, started_at, first_ordinal);
CREATE INDEX trace_calls_model_order_idx ON trace_calls(session_id, model, started_at, first_ordinal);
UPDATE trace_sources SET state = 'degraded', last_error_code = 'TRACE_INDEX_V6_REBUILD_REQUIRED';
`
const MIGRATIONS = [
{ version: 1, sql: SCHEMA_V1 },
{ version: 2, sql: SCHEMA_V2 },
{ version: 3, sql: SCHEMA_V3 },
{ version: 4, sql: SCHEMA_V4 },
{ version: 5, sql: SCHEMA_V5 },
{ version: 6, sql: SCHEMA_V6 },
] as const
export class UnsupportedTraceIndexSchemaError extends Error {
@@ -120,6 +120,7 @@ type ReducerState = {
export type TranscriptReductionOptions = {
isSubagent?: boolean
validateRetainedMetadata?: (entry: Record<string, unknown>) => void
}
const projectionStates = new WeakMap<TranscriptProjection, ReducerState>()
@@ -756,6 +757,9 @@ export function reduceTranscriptWithLocators(
malformedLineCount += 1
continue
}
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
options.validateRetainedMetadata?.(entry)
}
applyEntry(state, entry)
const locator = locatorFromEntry(entry, chunk, state, jsonlLine)
if (locator) {
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { appendFile, mkdtemp, rename, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { readHistoryContexts } from './sessionHistoryContext.js'
let directory: string
let file: string
beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'history-context-test-')); file = join(directory, 'session.jsonl') })
afterEach(async () => { await rm(directory, { recursive: true, force: true }) })
const row = (id: string, fields: object = {}) => JSON.stringify({ uuid: id, ...fields })
const version = async (filePath = file) => { const info = await stat(filePath, { bigint: true }); return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}` }
const classify = (entry: Record<string, unknown>) => ({ notification: entry.notification === true, reset: entry.reset === true, agentToolId: typeof entry.agent === 'string' ? entry.agent : undefined })
test('indexes EOF records and resumes at their boundary when a newline and new records are appended', async () => {
const first = row('notification', { notification: true }) + '\n'
const partial = row('user', { reset: true })
await writeFile(file, first + partial)
const before = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0, Buffer.byteLength(first)], classify })
expect(before.contexts.get(0)?.suppressed).toBe(true)
expect(before.contexts.get(Buffer.byteLength(first))?.suppressed).toBe(false)
const suffix = '\n' + row('assistant') + '\n'
await appendFile(file, suffix)
const after = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [Buffer.byteLength(first + partial + '\n')], classify })
expect(after.scannedBytes).toBe(Buffer.byteLength(partial + suffix))
expect([...after.contexts.values()]).toEqual([{ owner: undefined, suppressed: false }])
})
test('joins concurrent builds and cancelling one subscriber does not cancel the remaining reader', async () => {
await writeFile(file, Array.from({ length: 128 }, (_, index) => row(String(index)) + '\n').join(''))
const sourceVersion = await version()
const controller = new AbortController()
let visits = 0
const observed = (entry: Record<string, unknown>) => { visits++; if (visits === 1) controller.abort(); return classify(entry) }
const first = readHistoryContexts({ filePath: file, sourceVersion, offsets: [0], signal: controller.signal, classify: observed }).catch(error => error)
const second = readHistoryContexts({ filePath: file, sourceVersion, offsets: [0], classify: observed })
expect((await first).name).toBe('AbortError')
expect((await second).contexts.get(0)?.suppressed).toBe(false)
expect(visits).toBe(128)
expect((await readHistoryContexts({ filePath: file, sourceVersion, offsets: [0], classify })).scannedBytes).toBe(0)
})
test('replacing a transcript invalidates old scalar state and rejects stale page identities', async () => {
await writeFile(file, row('notice', { notification: true }) + '\n')
const oldVersion = await version()
await readHistoryContexts({ filePath: file, sourceVersion: oldVersion, offsets: [0], classify })
await writeFile(file + '.new', row('new') + '\n')
await rename(file + '.new', file)
const fresh = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0], classify })
expect(fresh.contexts.get(0)?.suppressed).toBe(false)
await expect(readHistoryContexts({ filePath: file, sourceVersion: oldVersion, offsets: [0], classify })).rejects.toMatchObject({ statusCode: 409 })
})
test('bounds queued file builds and cleans a fully cancelled scan for subsequent retry', async () => {
const files = Array.from({ length: 6 }, (_, index) => join(directory, `${index}.jsonl`))
await Promise.all(files.map(filePath => writeFile(filePath, row('one') + '\n' + row('two') + '\n')))
const versions = await Promise.all(files.map(filePath => version(filePath)))
const requests = files.slice(0, 5).map((filePath, index) => readHistoryContexts({ filePath, sourceVersion: versions[index]!, offsets: [0], classify }))
await expect(readHistoryContexts({ filePath: files[5]!, sourceVersion: versions[5]!, offsets: [0], classify })).rejects.toMatchObject({ statusCode: 429 })
await Promise.all(requests)
const controller = new AbortController()
await expect(readHistoryContexts({ filePath: files[5]!, sourceVersion: versions[5]!, offsets: [0], signal: controller.signal, classify: entry => { controller.abort(); return classify(entry) } })).rejects.toThrow()
expect((await readHistoryContexts({ filePath: files[5]!, sourceVersion: versions[5]!, offsets: [0], classify })).contexts.get(0)?.suppressed).toBe(false)
})
@@ -0,0 +1,140 @@
import { Database } from 'bun:sqlite'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { streamBoundedHistory, withHistoryReadBudget } from './boundedSessionHistory.js'
import { ApiError } from '../middleware/errorHandler.js'
type Context = { owner?: string; suppressed: boolean }
type Cache = { database: Database; directory: string; identity: string; size: number; mtime: string; offset: number; suppressed: boolean | null }
type Flight = { promise: Promise<void>; controller: AbortController; users: number }
const cache = new Map<string, Cache>()
const flights = new Map<string, Flight>()
process.once('exit', () => { for (const entry of cache.values()) { entry.database.close(); rmSync(entry.directory, { recursive: true, force: true }) } })
/** Disk-backed visibility/ownership scalars. Payload records never enter this
* index. Only append suffixes are scanned after the initial bounded build. */
export async function readHistoryContexts(options: {
filePath: string
sourceVersion: string
offsets: number[]
signal?: AbortSignal
classify: (entry: Record<string, unknown>) => { notification: boolean; reset: boolean; agentToolId?: string }
}): Promise<{ contexts: Map<number, Context>; scannedBytes: number }> {
if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError')
const [dev, ino, size, mtime] = options.sourceVersion.split(':')
const targetSize = Number(size)
const identity = `${dev}:${ino}`
let scannedBytes = 0
const ensure = async (signal: AbortSignal) => withHistoryReadBudget(signal, async () => {
const current = await stat(options.filePath, { bigint: true })
if (`${current.dev}:${current.ino}` !== identity || Number(current.size) < targetSize || (Number(current.size) === targetSize && String(current.mtimeNs) !== mtime)) {
throw new ApiError(409, 'Session history changed during context lookup', 'HISTORY_CHANGED')
}
let state = cache.get(options.filePath)
if (state && (state.identity !== identity || Number(current.size) < state.size || (Number(current.size) === state.size && String(current.mtimeNs) !== state.mtime))) {
cache.delete(options.filePath)
state.database.close()
await rm(state.directory, { recursive: true, force: true })
state = undefined
}
if (!state) {
while (cache.size >= 4) {
const key = cache.keys().next().value!
const evicted = cache.get(key)!
cache.delete(key)
evicted.database.close()
await rm(evicted.directory, { recursive: true, force: true })
}
const directory = await mkdtemp(join(tmpdir(), 'claude-history-context-'))
const database = new Database(join(directory, 'context.sqlite'))
database.exec('PRAGMA journal_mode=OFF; PRAGMA cache_size=-512; PRAGMA temp_store=FILE; CREATE TABLE parents (id TEXT PRIMARY KEY, chain TEXT); CREATE TABLE context (offset INTEGER PRIMARY KEY, owner TEXT, suppressed INTEGER)')
state = { database, directory, identity, size: 0, mtime: '', offset: 0, suppressed: false }
cache.set(options.filePath, state)
}
cache.delete(options.filePath)
cache.set(options.filePath, state)
if (state.size >= targetSize) return
const getParent = state.database.query('SELECT chain FROM parents WHERE id = ?')
const saveParent = state.database.query('INSERT OR REPLACE INTO parents VALUES (?, ?)')
const saveContext = state.database.query('INSERT OR REPLACE INTO context VALUES (?, ?, ?)')
const originalOffset = state.offset
let suppressed = state.suppressed
let completeSuppression = suppressed
try {
state.database.exec('BEGIN')
const result = await streamBoundedHistory(options.filePath, (entry, completeLine, offset) => {
const classification = options.classify(entry)
const inherited = typeof entry.parentUuid === 'string' ? (getParent.get(entry.parentUuid) as { chain?: string } | null)?.chain : undefined
const explicit = typeof entry.parent_tool_use_id === 'string' && entry.parent_tool_use_id ? entry.parent_tool_use_id : undefined
const owner = explicit ?? (entry.isSidechain === true ? inherited : undefined)
const chain = classification.agentToolId ?? inherited
if (typeof entry.uuid === 'string') saveParent.run(entry.uuid, chain ?? null)
if (classification.notification) suppressed = true
else if (classification.reset) suppressed = false
// Unknown sidechain ancestry is never promoted into the root transcript.
saveContext.run(offset, owner ?? null, suppressed !== false || (entry.isSidechain === true && !owner) ? 1 : 0)
if (completeLine) completeSuppression = suppressed
}, signal, { startOffset: originalOffset, endOffset: targetSize, onSkipped: () => { suppressed = null; completeSuppression = null } })
state.database.exec('COMMIT')
state.size = targetSize
state.mtime = mtime!
state.offset = result.nextOffset
state.suppressed = completeSuppression
scannedBytes += result.scannedBytes
} catch (error) {
state.database.exec('ROLLBACK')
// journal_mode=OFF cannot guarantee rollback restoration after a failed
// build; discard this regenerable index entirely.
cache.delete(options.filePath)
state.database.close()
await rm(state.directory, { recursive: true, force: true })
throw error
}
}, 'context')
// Join one file build. Each caller may cancel independently; the scan is
// aborted when the final interested caller goes away.
while (!cache.get(options.filePath) || cache.get(options.filePath)!.size < targetSize || cache.get(options.filePath)!.identity !== identity || (cache.get(options.filePath)!.size === targetSize && cache.get(options.filePath)!.mtime !== mtime)) {
let flight = flights.get(options.filePath)
if (flight?.controller.signal.aborted) {
await flight.promise.catch(() => {})
if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError')
continue
}
if (!flight) {
if (flights.size >= 5) throw new ApiError(429, 'History context reader is busy', 'HISTORY_BUSY')
const controller = new AbortController()
flight = { controller, users: 0, promise: Promise.resolve() }
const currentFlight = flight
flight.promise = ensure(controller.signal).finally(() => { if (flights.get(options.filePath) === currentFlight) flights.delete(options.filePath) })
flights.set(options.filePath, flight)
}
flight.users++
const joined = flight
await new Promise<void>((resolve, reject) => {
let done = false
const finish = (error?: unknown) => {
if (done) return
done = true
options.signal?.removeEventListener('abort', abort)
joined.users--
if (!joined.users && error) joined.controller.abort(error)
if (error) reject(error); else resolve()
}
const abort = () => finish(options.signal?.reason ?? new DOMException('Aborted', 'AbortError'))
options.signal?.addEventListener('abort', abort, { once: true })
joined.promise.then(() => finish(), error => finish(error))
if (options.signal?.aborted) abort()
})
}
const state = cache.get(options.filePath)!
const query = state.database.query('SELECT owner, suppressed FROM context WHERE offset = ?')
const contexts = new Map<number, Context>()
for (const offset of options.offsets) {
const row = query.get(offset) as { owner: string | null; suppressed: number } | null
if (!row) throw new ApiError(409, 'History context is unavailable; reload the page', 'HISTORY_CHANGED')
contexts.set(offset, { owner: row.owner ?? undefined, suppressed: row.suppressed === 1 })
}
return { contexts, scannedBytes }
}
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { mkdtemp, mkdir, writeFile, appendFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { SessionService } from './sessionService.js'
import { HISTORY_SCAN_BYTES } from './boundedSessionHistory.js'
let directory: string
let configBefore: string | undefined
let file: string
let service: SessionService
const id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const entry = (type: string, uuid: string, content: unknown, extra: object = {}) => ({ type, uuid, message: { role: type, content }, timestamp: '2026-01-01T00:00:00.000Z', ...extra })
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'recovery-history-test-'))
configBefore = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = directory
const project = join(directory, 'projects', '-tmp-history')
await mkdir(project, { recursive: true })
file = join(project, `${id}.jsonl`)
service = new SessionService({ getMode: () => 'off' } as any)
})
afterEach(async () => {
if (configBefore === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = configBefore
await rm(directory, { recursive: true, force: true })
})
test('recovery retains goal and todo evidence outside the visible tail, including dismissal boundary and exact usage deduplication', async () => {
const entries = [
{ type: 'system', subtype: 'local_command', uuid: 'goal', content: '<local-command-stdout>Goal set: finish project</local-command-stdout>', timestamp: '2026-01-01T00:00:00.000Z' },
entry('assistant', 'todo', [{ type: 'tool_use', id: 'todo-use', name: 'TodoWrite', input: { todos: [{ content: 'finish project', status: 'completed' }] } }]),
entry('user', 'after-todo', 'next task'),
...Array.from({ length: 24 }, (_, index) => entry('assistant', `body-${index}`, [{ type: 'text', text: 'x'.repeat(256 * 1024) }], {
version: '1.0.0', sessionId: id, requestId: 'one-request',
message: { role: 'assistant', id: 'one-reply', content: [{ type: 'text', text: 'x'.repeat(256 * 1024) }], usage: { input_tokens: 10, output_tokens: 20 } },
})),
]
await writeFile(file, entries.map(value => JSON.stringify(value)).join('\n') + '\n')
const page = await service.getSessionHistoryPage(id)
expect(page.page.hasMore).toBe(true)
expect(page.page.scannedBytes).toBeLessThanOrEqual(HISTORY_SCAN_BYTES)
expect(Buffer.byteLength(JSON.stringify(page))).toBeLessThan(2 * 1024 * 1024)
expect(page.messages.every(message => message.id.startsWith('body-'))).toBe(true)
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.status).toBe('ready')
expect(recovery.messages.map(message => message.id)).toEqual(['goal', 'todo', 'after-todo'])
expect(recovery.tokenUsage).toMatchObject({ input_tokens: 10, output_tokens: 20 })
expect(Buffer.byteLength(JSON.stringify(recovery))).toBeLessThan(16 * 1024)
})
test('recovery retains tool lifecycle ownership and does not revive child tools as root work', async () => {
await writeFile(file, [
entry('assistant', 'root-call', [{ type: 'tool_use', id: 'agent-1', name: 'Agent', input: { description: 'inspect' } }]),
entry('assistant', 'child-call', [{ type: 'tool_use', id: 'child-bash', name: 'Bash', input: { command: 'sleep 1', run_in_background: true } }], { parent_tool_use_id: 'agent-1' }),
entry('user', 'child-result', [{ type: 'tool_result', tool_use_id: 'child-bash', content: 'background task' }], { parentUuid: 'child-call', isSidechain: true }),
entry('user', 'root-result', [{ type: 'tool_result', tool_use_id: 'agent-1', content: 'done' }]),
].map(value => JSON.stringify(value)).join('\n') + '\n')
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.status).toBe('ready')
expect(recovery.messages.map(message => message.id)).toEqual(['root-call'])
})
test('oversized evidence is explicitly incomplete and cancellation never returns an authoritative snapshot', async () => {
await writeFile(file, JSON.stringify(entry('assistant', 'huge', [{ type: 'text', text: 'x'.repeat(2 * 1024 * 1024) }])) + '\n')
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.status).toBe('incomplete')
expect(recovery.omittedRecords).toBe(1)
const controller = new AbortController()
controller.abort()
await expect(service.getSessionHistoryRecovery(id, { signal: controller.signal })).rejects.toThrow()
})
test('large foreground shell output and old Todo writes do not poison scalar recovery; workspace limits are independent', async () => {
await writeFile(file, [
...Array.from({ length: 80 }, (_, index) => entry('assistant', `todo-${index}`, [{ type: 'tool_use', id: `todo-${index}`, name: 'TodoWrite', input: { todos: [{ content: `todo ${index}`, status: 'pending' }] } }])),
entry('assistant', 'shell', [{ type: 'tool_use', id: 'shell', name: 'Bash', input: { command: 'cat file' } }]),
entry('user', 'shell-result', [{ type: 'tool_result', tool_use_id: 'shell', content: 'x'.repeat(256 * 1024) }]),
entry('assistant', 'write', [{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: '/tmp/file', content: 'x'.repeat(128 * 1024) } }]),
].map(value => JSON.stringify(value)).join('\n') + '\n')
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.completeness).toEqual({ goal: true, todos: true, usage: true, activity: true, workspace: false })
expect(recovery.messages.filter(message => message.id.startsWith('todo-')).map(message => message.id)).toEqual(['todo-79'])
expect(Buffer.byteLength(JSON.stringify(recovery))).toBeLessThan(16 * 1024)
})
test('launch metadata, title, work directory and metadata appends never materialize transcript history', async () => {
await writeFile(file, [
{ type: 'session-meta', workDir: '/tmp/history' },
{ type: 'custom-title', customTitle: 'large history' },
...Array.from({ length: 24 }, (_, index) => entry('assistant', `body-${index}`, 'x'.repeat(256 * 1024))),
].map(value => JSON.stringify(value)).join('\n') + '\n')
;(service as any).readJsonlFile = () => { throw new Error('unbounded transcript read') }
const [launch, workDir, title] = await Promise.all([service.getSessionLaunchInfo(id), service.getSessionWorkDir(id), service.getCustomTitle(id)])
expect(launch?.transcriptMessageCount).toBe(24)
expect(workDir).toBe('/tmp/history')
expect(title).toBe('large history')
await service.appendSessionMetadata(id, { workDir: '/tmp/history', customTitle: 'updated title' })
expect(await service.getCustomTitle(id)).toBe('updated title')
})
test('history pages preserve cross-page notification suppression and sidechain ownership, and index only appended bytes', async () => {
const notification = '<task-notification><task-id>task</task-id><tool-use-id>agent</tool-use-id><status>completed</status></task-notification>'
await writeFile(file, [
entry('assistant', 'owner', [{ type: 'tool_use', id: 'agent', name: 'Agent', input: {} }]),
entry('assistant', 'child', 'child response', { isSidechain: true, parentUuid: 'owner' }),
entry('user', 'notice', notification),
entry('assistant', 'hidden', 'internal notification response'),
].map(value => JSON.stringify(value)).join('\n') + '\n')
const latest = await service.getSessionHistoryPage(id, { limit: 1 })
expect(latest.messages).toEqual([])
expect(latest.page.contextScanBytes).toBeGreaterThan(0)
const noticePage = await service.getSessionHistoryPage(id, { limit: 1, cursor: latest.page.nextCursor! })
expect(noticePage.messages).toEqual([])
const childPage = await service.getSessionHistoryPage(id, { limit: 1, cursor: noticePage.page.nextCursor! })
expect(childPage.messages).toMatchObject([{ id: 'child', parentToolUseId: 'agent' }])
expect(childPage.page.contextScanBytes).toBe(0)
const suffix = JSON.stringify(entry('user', 'real-user', 'continue', { parentUuid: 'child' })) + '\n'
await appendFile(file, suffix)
const appended = await service.getSessionHistoryPage(id, { limit: 1 })
expect(appended.messages).toMatchObject([{ id: 'real-user' }])
expect(appended.messages[0]!.parentToolUseId).toBeUndefined()
expect(appended.page.contextScanBytes).toBe(Buffer.byteLength(suffix))
})
test('recovery resolves sidechain ancestry through Agent calls without attaching ordinary root descendants', async () => {
await writeFile(file, [
entry('assistant', 'owner', [{ type: 'tool_use', id: 'agent', name: 'Agent', input: {} }]),
entry('assistant', 'child', [{ type: 'tool_use', id: 'child-todo', name: 'TodoWrite', input: { todos: [{ content: 'child work', status: 'pending' }] } }], { isSidechain: true, parentUuid: 'owner' }),
entry('assistant', 'root', [{ type: 'tool_use', id: 'root-todo', name: 'TodoWrite', input: { todos: [{ content: 'root work', status: 'pending' }] } }], { parentUuid: 'child' }),
].map(value => JSON.stringify(value)).join('\n') + '\n')
const recovered = await service.getSessionHistoryRecovery(id)
expect(recovered.messages.map(message => message.id)).toEqual(['owner', 'root'])
})
@@ -0,0 +1,215 @@
import { Database } from 'bun:sqlite'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { streamBoundedHistory, withHistoryReadBudget } from './boundedSessionHistory.js'
import type { MessageEntry, SessionTaskNotification } from './sessionService.js'
const RECOVERY_BYTES = 3 * 1024 * 1024
const STATE_RECORD_BYTES = 64 * 1024
const TASK_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
const WORKSPACE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
const BACKGROUND_RESULT = /\bCommand (?:running in background|was manually backgrounded by user|exceeded[^\n]*?\band was moved to the background) with ID:\s*([A-Za-z0-9_-]+)/i
export type SessionHistoryRecovery = {
sourceVersion: string
status: 'ready' | 'incomplete'
completeness?: { goal: boolean; todos: boolean; activity: boolean; usage: boolean; workspace?: boolean }
messages: MessageEntry[]
taskNotifications: SessionTaskNotification[]
tokenUsage: { input_tokens: number; output_tokens: number; cache_read_tokens: number; cache_creation_tokens: number } | null
omittedRecords: number
}
type Evidence = { ordinal: number; message: MessageEntry }
const record = (value: unknown): Record<string, any> | undefined => value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, any> : undefined
const text = (value: unknown): string => typeof value === 'string' ? value : Array.isArray(value) ? value.filter(block => block?.type === 'text').map(block => block.text ?? '').join('\n') : ''
const brief = (value: unknown): string | undefined => typeof value === 'string' ? value.slice(0, 4096) : undefined
/** Whole-source state reduction with bounded resident memory. Large historical
* tool outputs are never recovery state; preserve their task identity/status,
* and leave their bodies available through the separately paged transcript. */
export async function recoverBoundedSessionHistory(options: {
filePath: string
signal?: AbortSignal
toMessage: (entry: Record<string, unknown>, parentToolUseId?: string) => MessageEntry | null
notifications: (entry: Record<string, unknown>) => SessionTaskNotification[]
}): Promise<SessionHistoryRecovery> {
return withHistoryReadBudget(options.signal, async () => {
const directory = await mkdtemp(join(tmpdir(), 'claude-history-recovery-'))
let database: Database | undefined
try {
database = new Database(join(directory, 'state.sqlite'))
database.exec('PRAGMA journal_mode=OFF; PRAGMA cache_size=-512; PRAGMA temp_store=FILE; CREATE TABLE usage_keys (id TEXT PRIMARY KEY); CREATE TABLE parents (id TEXT PRIMARY KEY, owner TEXT); CREATE TABLE tools (id TEXT PRIMARY KEY, ordinal INTEGER, json TEXT, legacy INTEGER, name TEXT); CREATE TABLE evidence (id TEXT PRIMARY KEY, ordinal INTEGER, json TEXT, category TEXT); CREATE TABLE notices (id TEXT PRIMARY KEY, ordinal INTEGER, json TEXT); CREATE TABLE teammates (name TEXT PRIMARY KEY)')
database.exec('BEGIN')
const usageKey = database.query('INSERT OR IGNORE INTO usage_keys VALUES (?)')
const saveParent = database.query('INSERT OR REPLACE INTO parents VALUES (?, ?)')
const getParent = database.query('SELECT owner FROM parents WHERE id = ?')
const saveTool = database.query('INSERT OR REPLACE INTO tools VALUES (?, ?, ?, ?, ?)')
const getTool = database.query('SELECT ordinal, json, legacy, name FROM tools WHERE id = ?')
const saveEvidence = database.query('INSERT OR REPLACE INTO evidence VALUES (?, ?, ?, ?)')
const saveNotice = database.query('INSERT OR REPLACE INTO notices VALUES (?, ?, ?)')
const teammate = database.query('INSERT OR IGNORE INTO teammates VALUES (?)')
let ordinal = 0
let lastUser: Evidence | undefined
let lastTodo: Evidence | undefined
let lastTask: Evidence | undefined
let goalBase: Evidence | undefined
let goalStatus: Evidence | undefined
let omitted = 0
let suppressTaskNotificationResponse = false
const completeness = { goal: true, todos: true, activity: true, usage: true, workspace: true }
const usage = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }
const saveActivity = (evidence: Evidence, category: 'activity' | 'workspace' = 'activity') => {
const json = JSON.stringify(evidence.message)
if (Buffer.byteLength(json) > STATE_RECORD_BYTES) { omitted++; completeness[category] = false; return }
saveEvidence.run(evidence.message.id, evidence.ordinal, json, category)
}
const scan = await streamBoundedHistory(options.filePath, (entry) => {
ordinal++
const inherited = typeof entry.parentUuid === 'string' ? (getParent.get(entry.parentUuid) as { owner?: string } | null)?.owner : undefined
const owner = typeof entry.parent_tool_use_id === 'string' && entry.parent_tool_use_id
? entry.parent_tool_use_id : entry.isSidechain === true ? inherited : undefined
const rawContent = (entry.message as { content?: unknown } | undefined)?.content
const agentCall = Array.isArray(rawContent) ? rawContent.find(block => block?.type === 'tool_use' && (block.name === 'Agent' || block.name === 'Task') && typeof block.id === 'string') : undefined
if (typeof entry.uuid === 'string') saveParent.run(entry.uuid, agentCall?.id ?? inherited ?? null)
const notifications = options.notifications(entry)
for (const notice of notifications) {
const compact = { ...notice, ...(notice.summary ? { summary: brief(notice.summary) } : {}), ...(notice.result ? { result: brief(notice.result) } : {}) }
const json = JSON.stringify(compact)
if (Buffer.byteLength(json) > STATE_RECORD_BYTES) { omitted++; completeness.activity = false; continue }
saveNotice.run(JSON.stringify([notice.ownerAgentId ?? null, notice.toolUseId]), ordinal, json)
}
const rawMessage = entry.message as { role?: string; content?: unknown } | undefined
const notificationUser = rawMessage?.role === 'user' && notifications.length > 0
const hasToolResult = Array.isArray(rawMessage?.content) && rawMessage.content.some((block: any) => block?.type === 'tool_result')
if (notificationUser) { suppressTaskNotificationResponse = true; return }
if (rawMessage?.role === 'user' && !hasToolResult) suppressTaskNotificationResponse = false
else if (suppressTaskNotificationResponse) return
const message = options.toMessage(entry, owner)
if (!message) return
if (message.usage && (!message.usageKey || usageKey.run(message.usageKey).changes > 0)) {
const amount = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
usage.input_tokens += amount(message.usage.input_tokens)
usage.output_tokens += amount(message.usage.output_tokens)
usage.cache_read_tokens += amount(message.usage.cache_read_input_tokens)
usage.cache_creation_tokens += amount(message.usage.cache_creation_input_tokens)
}
if (owner || entry.isSidechain === true) return
const base = { ...message, usage: undefined, toolUseResult: undefined }
if (message.type === 'user') {
lastUser = { ordinal, message: { ...base, content: '[user message]' } }
const teammateParts: string[] = []
for (const match of text(message.content).matchAll(/<teammate-message\s+teammate_id="([^"]+)"[^>]*>\n?([\s\S]*?)\n?<\/teammate-message>/g)) {
const body = match[2]!.trim()
// Lifecycle JSON is not a meaningful completed task result.
try {
const lifecycle = JSON.parse(body)
if (['shutdown_approved', 'shutdown_rejected', 'shutdown_request', 'teammate_terminated', 'idle_notification'].includes(lifecycle?.type)) continue
} catch { /* Plain text is a meaningful teammate response. */ }
if (teammate.run(match[1]!).changes) teammateParts.push(`<teammate-message teammate_id="${match[1]}">${body.slice(0, 4096)}</teammate-message>`)
}
if (teammateParts.length) saveActivity({ ordinal, message: { ...base, content: teammateParts.join('\n') } })
}
if (message.type === 'system' && typeof message.content === 'string') {
const output = message.content.match(/<local-command-(?:stdout|stderr)>([\s\S]*?)<\/local-command-(?:stdout|stderr)>/)?.[1]?.trim()
if (output && /^(?:Goal set:|Goal cleared[.:]|No active goal\.|Goal continuing:|Goal marked complete\.)/.test(output)) {
const evidence = { ordinal, message: { ...base, content: `<local-command-stdout>${output}</local-command-stdout>` } }
if (/^(?:Goal set:|Goal cleared[.:]|No active goal\.)/.test(output)) {
goalBase = undefined; goalStatus = undefined; completeness.goal = true
if (Buffer.byteLength(JSON.stringify(evidence)) <= STATE_RECORD_BYTES) goalBase = evidence
else { omitted++; completeness.goal = false }
} else if (Buffer.byteLength(JSON.stringify(evidence)) <= STATE_RECORD_BYTES) goalStatus = evidence
else { omitted++; completeness.goal = false }
}
return
}
if (!Array.isArray(message.content)) return
const agentBlocks: unknown[] = []
const workspaceCalls: Array<Record<string, any>> = []
const workspaceResults: Array<Record<string, any>> = []
for (const block of message.content as Array<Record<string, any>>) {
if (block?.type === 'tool_use' && typeof block.id === 'string') {
const input = record(block.input) ?? {}
if (WORKSPACE_TOOLS.has(block.name)) workspaceCalls.push(block)
if (TASK_TOOLS.has(block.name)) {
lastTask = { ordinal, message: { ...base, content: [{ type: 'tool_use', id: block.id, name: block.name, input: {} }] } }
if (block.name === 'TodoWrite' && Array.isArray(input.todos)) {
const evidence = { ordinal, message: { ...base, content: [block] } }
completeness.todos = Buffer.byteLength(JSON.stringify(evidence)) <= STATE_RECORD_BYTES
lastTodo = completeness.todos ? evidence : undefined
if (!completeness.todos) omitted++
}
}
if (block.name === 'Agent' || block.name === 'Task') {
agentBlocks.push({ type: 'tool_use', id: block.id, name: block.name, input: { name: input.name, description: brief(input.description), subagent_type: input.subagent_type, run_in_background: input.run_in_background } })
}
if (SHELL_TOOLS.has(block.name)) {
const compact = { ...base, id: `${base.id}:background:${block.id}`, content: [{ type: 'tool_use', id: block.id, name: block.name, input: { description: brief(input.description), command: brief(input.command), run_in_background: input.run_in_background } }] }
const json = JSON.stringify(compact)
if (Buffer.byteLength(json) <= STATE_RECORD_BYTES) saveTool.run(block.id, ordinal, json, input.run_in_background === true ? 1 : 0, block.name)
else { omitted++; completeness.activity = false }
}
} else if (block?.type === 'tool_result' && typeof block.tool_use_id === 'string') {
const tool = getTool.get(block.tool_use_id) as { ordinal: number; json: string; legacy: number; name: string } | null
if (!tool) continue
if (WORKSPACE_TOOLS.has(tool.name)) { workspaceResults.push(block); continue }
const structured = record(message.toolUseResult)
const taskId = structured
? structured.backgroundTaskId ?? structured.background_task_id
: tool.legacy ? text(block.content).match(BACKGROUND_RESULT)?.[1] : undefined
if (typeof taskId !== 'string' || !taskId) continue
saveActivity({ ordinal: tool.ordinal, message: JSON.parse(tool.json) })
saveActivity({ ordinal, message: { ...base, content: [{ type: 'tool_result', tool_use_id: block.tool_use_id, content: '' }], toolUseResult: { backgroundTaskId: taskId } } })
}
}
if (workspaceCalls.length) {
const compact = { ...base, content: workspaceCalls }
const json = JSON.stringify(compact)
if (Buffer.byteLength(json) > STATE_RECORD_BYTES) { omitted++; completeness.workspace = false }
else {
saveActivity({ ordinal, message: compact }, 'workspace')
for (const block of workspaceCalls) saveTool.run(block.id, ordinal, json, 0, block.name)
}
}
if (workspaceResults.length) saveActivity({ ordinal, message: { ...base, content: workspaceResults, toolUseResult: message.toolUseResult } }, 'workspace')
if (agentBlocks.length) saveActivity({ ordinal, message: { ...base, content: agentBlocks } })
}, options.signal)
database.exec('COMMIT')
const priority = [goalBase, goalStatus, lastTodo, lastTask?.message.id === lastTodo?.message.id ? undefined : lastTask, lastUser].filter((value): value is Evidence => Boolean(value))
const messages = new Map(priority.map(evidence => [evidence.message.id, evidence]))
const taskNotifications: SessionTaskNotification[] = []
let bytes = Buffer.byteLength(JSON.stringify(priority))
for (const row of database.query('SELECT ordinal, json, category FROM evidence ORDER BY ordinal DESC').iterate() as Iterable<{ ordinal: number; json: string; category: 'activity' | 'workspace' }>) {
if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError')
bytes += Buffer.byteLength(row.json)
if (bytes > RECOVERY_BYTES || messages.size >= 2048) { omitted++; completeness.activity = false; completeness.workspace = false; break }
const message = JSON.parse(row.json)
messages.set(message.id, { ordinal: row.ordinal, message })
if (messages.size % 64 === 0) await new Promise<void>(resolve => setImmediate(resolve))
}
for (const row of database.query('SELECT json FROM notices ORDER BY ordinal DESC').iterate() as Iterable<{ json: string }>) {
bytes += Buffer.byteLength(row.json)
if (bytes > RECOVERY_BYTES || taskNotifications.length >= 2048) { omitted++; completeness.activity = false; break }
if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError')
taskNotifications.push(JSON.parse(row.json))
if (taskNotifications.length % 64 === 0) await new Promise<void>(resolve => setImmediate(resolve))
}
if (scan.omittedRecords) {
omitted += scan.omittedRecords
completeness.goal = completeness.todos = completeness.activity = completeness.usage = completeness.workspace = false
}
return {
sourceVersion: scan.sourceVersion,
status: Object.values(completeness).every(Boolean) ? 'ready' : 'incomplete',
completeness,
messages: [...messages.values()].sort((a, b) => a.ordinal - b.ordinal).map(item => item.message),
taskNotifications: taskNotifications.reverse(),
tokenUsage: Object.values(usage).some(value => value > 0) ? usage : null,
omittedRecords: omitted,
}
} finally {
database?.close()
await rm(directory, { recursive: true, force: true })
}
}, 'recovery')
}
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -11,7 +11,7 @@ import {
resolveSubagentRunFromMessages,
truncateSubagentMessages,
} from './subagentRunService.js'
import type { MessageEntry } from './sessionService.js'
import { sessionService, type MessageEntry } from './sessionService.js'
let tmpDir: string | null = null
@@ -311,6 +311,36 @@ describe('getSubagentRunByTool', () => {
delete process.env.CLAUDE_CONFIG_DIR
})
it('bounds parent lookup and large child display without claiming complete activity or partial token totals', async () => {
await setupTmpConfigDir()
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const projectDir = '-tmp-subagent-large'
const body = Array.from({ length: 24 }, (_, index) => ({ type: 'assistant', uuid: `body-${index}`, timestamp: '2026-01-01T00:00:05.000Z', message: { role: 'assistant', content: 'x'.repeat(256 * 1024) } }))
await writeSessionFile(projectDir, sessionId, [makeAgentToolUseEntry('tool-1'), ...body, makeAgentToolResultEntry('tool-1', 'abc123')])
await writeSubagentTranscriptFile(projectDir, sessionId, 'abc123', body)
const original = (sessionService as any).readJsonlFile
;(sessionService as any).readJsonlFile = () => { throw new Error('unbounded transcript read') }
try {
const result = await getSubagentRunByTool(sessionId, 'tool-1')
expect(result).toMatchObject({ agentId: 'abc123', truncated: true, historyComplete: false, activityComplete: false })
expect(result!.messages.length).toBeGreaterThan(0)
expect(result!.messages.length).toBeLessThan(24)
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(2 * 1024 * 1024)
expect(result!.messages.at(-1)!.id).toBe('body-23')
} finally {
;(sessionService as any).readJsonlFile = original
}
})
it('rejects oversized launch sidecars before parsing them', async () => {
await setupTmpConfigDir()
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const projectDir = '-tmp-subagent-sidecar'
await writeSessionFile(projectDir, sessionId, [makeAgentToolUseEntry('tool-1')])
await writeSubagentLaunchMetadata(projectDir, sessionId, 'abc123', { agentType: 'general', toolUseId: 'tool-1', description: 'x'.repeat(128 * 1024) })
await expect(getSubagentRunByTool(sessionId, 'tool-1')).rejects.toMatchObject({ statusCode: 413, code: 'SUBAGENT_METADATA_LIMIT' })
})
it('returns parent metadata and visible persisted subagent transcript messages', async () => {
await setupTmpConfigDir()
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
+17 -10
View File
@@ -42,6 +42,8 @@ export type SubagentRunResponse = {
/** Notifications whose nested Agent ids match `activityMessages`. */
activityTaskNotifications: SessionTaskNotification[]
truncated: boolean
historyComplete?: boolean
activityComplete?: boolean
updatedAt?: string
source: SubagentRunSource
/**
@@ -518,13 +520,14 @@ async function resolveTranscript(
agentId: string | null
messages: MessageEntry[]
taskNotifications: SessionTaskNotification[]
historyComplete?: boolean
}> {
const seen = new Set<string>()
for (const candidate of candidates) {
const agentId = normalizeAgentIdHint(candidate ?? undefined)
if (!agentId || seen.has(agentId)) continue
seen.add(agentId)
const transcript = await sessionService.getSubagentTranscript(sessionId, agentId)
const transcript = await sessionService.getSubagentTranscript(sessionId, agentId, { bounded: true })
if (transcript.messages.length > 0 || transcript.taskNotifications.length > 0) {
return { agentId, ...transcript }
}
@@ -564,6 +567,7 @@ async function resolveRunFromToolRef(
const parentTranscript = await sessionService.getSubagentTranscript(
sessionId,
strictParentAgentId,
{ bounded: true, toolUseId: nestedRef.leafToolUseId },
)
const nestedResolution = resolveSubagentRunFromMessages(
parentTranscript.messages,
@@ -590,6 +594,7 @@ async function resolveRunFromToolRef(
const fragments = await sessionService.getSubagentTranscriptFragmentsByAgentType(
sessionId,
teammateName,
{ bounded: true, toolUseId: nestedRef.leafToolUseId },
)
for (let index = fragments.length - 1; index >= 0; index -= 1) {
const fragment = fragments[index]!
@@ -659,7 +664,7 @@ export async function getSubagentRunByAgentId(
taskNotifications: transcript.taskNotifications,
})
const truncated = truncateSubagentMessages(activity.messages)
const usage = usageFromTranscriptMessages(messages)
const usage = transcript.historyComplete === false ? undefined : usageFromTranscriptMessages(messages)
const updatedAt = latestTimestamp(
...messages.map(message =>
isRecord(message) && typeof message.timestamp === 'string'
@@ -693,7 +698,9 @@ export async function getSubagentRunByAgentId(
...(truncated.truncated ? { activityMessages: activity.messages } : {}),
taskNotifications: transcript.taskNotifications,
activityTaskNotifications: activity.taskNotifications,
truncated: truncated.truncated,
truncated: truncated.truncated || transcript.historyComplete === false,
historyComplete: transcript.historyComplete !== false,
activityComplete: transcript.historyComplete !== false,
...(updatedAt ? { updatedAt } : {}),
source: 'subagent-jsonl',
// A workflow agent answers once into its script and is gone; there is no
@@ -710,10 +717,7 @@ export async function getSubagentRunByTool(
// Only the root-level `Agent` tool call is resolved here — its child
// transcript is read separately below. Pulling the merged view instead would
// re-materialize every linked subagent on each card open.
const [parentMessages, taskNotifications] = await Promise.all([
sessionService.getSessionMessages(sessionId, { includeSubagents: false }),
sessionService.getSessionTaskNotifications(sessionId),
])
const { messages: parentMessages, taskNotifications } = await sessionService.getSubagentRunLookup(sessionId, toolUseId)
const resolvedToolRef = await resolveRunFromToolRef(sessionId, parentMessages, toolUseId)
if (!resolvedToolRef) return null
const { resolution, lookupToolUseId, expectedOwnerAgentId } = resolvedToolRef
@@ -728,7 +732,7 @@ export async function getSubagentRunByTool(
? resolution.agentId.split('@')[0]
: undefined
const teammateFragments = teammateName
? await sessionService.getSubagentTranscriptFragmentsByAgentType(sessionId, teammateName)
? await sessionService.getSubagentTranscriptFragmentsByAgentType(sessionId, teammateName, { bounded: true })
: []
// Sidecar metadata is written before the agent starts, so it is the only
// hint that exists while the run is still streaming. The other candidates
@@ -742,6 +746,7 @@ export async function getSubagentRunByTool(
)
const transcript = teammateFragments.length > 0
? {
historyComplete: teammateFragments.every(fragment => fragment.historyComplete !== false),
agentId: teammateFragments[teammateFragments.length - 1]!.agentId,
messages: mergeTeammateTranscriptFragments(teammateFragments),
taskNotifications: mergeTeammateTranscriptTaskNotifications(teammateFragments),
@@ -778,7 +783,7 @@ export async function getSubagentRunByTool(
)
const transcriptMessages = transcript.messages
const truncated = truncateSubagentMessages(activity.messages)
const transcriptUsage = usageFromTranscriptMessages(transcriptMessages)
const transcriptUsage = transcript.historyComplete === false ? undefined : usageFromTranscriptMessages(transcriptMessages)
const usage = mergeUsage(resolution.usage, transcriptUsage)
const latestTranscriptTimestamp = latestTimestamp(
...transcriptMessages.map((message) => (
@@ -809,7 +814,9 @@ export async function getSubagentRunByTool(
...(truncated.truncated ? { activityMessages: activity.messages } : {}),
taskNotifications: transcript.taskNotifications,
activityTaskNotifications: activity.taskNotifications,
truncated: truncated.truncated,
truncated: truncated.truncated || transcript.historyComplete === false,
historyComplete: transcript.historyComplete !== false,
activityComplete: transcript.historyComplete !== false,
...(latestTimestamp(resolution.updatedAt, notification?.timestamp, latestTranscriptTimestamp)
? { updatedAt: latestTimestamp(resolution.updatedAt, notification?.timestamp, latestTranscriptTimestamp) }
: {}),
+30 -13
View File
@@ -1,3 +1,4 @@
import { readLegacyTranscriptFiles } from './legacyTranscriptBudget.js'
/**
* TeamService — 读取 CLI 生成的 Agent Teams 配置
*
@@ -13,6 +14,7 @@ import * as path from 'path'
import * as os from 'os'
import * as crypto from 'node:crypto'
import { ApiError } from '../middleware/errorHandler.js'
import { readTeamTranscriptProjection } from './teamTranscriptProjection.js'
import { writeToMailbox } from '../../utils/teammateMailbox.js'
import {
sessionService,
@@ -1413,7 +1415,7 @@ type TeamFileRaw = {
export class TeamService {
private readonly sessionLocator: Pick<typeof sessionService, 'findSessionFile'>
private readonly sessionReader: Pick<typeof sessionService, 'getSessionMessages'>
private readonly sessionReader: Pick<typeof sessionService, 'getSessionMessages'> | undefined
private readonly localIndexGateway: LocalIndexGateway
private readonly targetedEntryReader: typeof readSessionEntriesByLocator
private readonly archiveWriteLocks = new Map<string, Promise<unknown>>()
@@ -1425,11 +1427,26 @@ export class TeamService {
targetedEntryReader?: typeof readSessionEntriesByLocator
} = {}) {
this.sessionLocator = options.sessionLocator ?? sessionService
this.sessionReader = options.sessionReader ?? sessionService
this.sessionReader = options.sessionReader
this.localIndexGateway = options.localIndexGateway ?? localIndexCoordinator
this.targetedEntryReader = options.targetedEntryReader ?? readSessionEntriesByLocator
}
private async readTeamHistory(sessionId: string): Promise<SessionMessageEntry[]> {
// Injected fixture readers keep the migration/replay seam. Production never
// routes periodic workbench polling through the canonical full transcript.
if (this.sessionReader) return this.sessionReader.getSessionMessages(sessionId)
const found = await this.sessionLocator.findSessionFile(sessionId)
if (!found) return []
const projection = await readTeamTranscriptProjection(found.filePath).catch(() => {
throw new ApiError(503, 'Team history is changing; retry shortly', 'TEAM_HISTORY_INCOMPLETE')
})
if (!projection.complete) {
throw new ApiError(503, 'Team history projection is incomplete; existing workbench state was preserved', 'TEAM_HISTORY_INCOMPLETE')
}
return projection.messages
}
private getConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
@@ -1889,8 +1906,9 @@ export class TeamService {
let messages: SessionMessageEntry[]
try {
messages = await this.sessionReader.getSessionMessages(sessionId)
} catch {
messages = await this.readTeamHistory(sessionId)
} catch (error) {
if (error instanceof ApiError && error.code === 'TEAM_HISTORY_INCOMPLETE') throw error
return null
}
const projected = projectTeamWorkbenchesFromTranscript(sessionId, messages)
@@ -1996,7 +2014,7 @@ export class TeamService {
let messages: SessionMessageEntry[] | undefined
let lifecycle: TaskListLifecycleState | undefined
try {
messages = await this.sessionReader.getSessionMessages(sessionId)
messages = await this.readTeamHistory(sessionId)
} catch {
// The archive tombstone must still be written when a legacy or partially
// persisted lead transcript cannot be read.
@@ -3101,8 +3119,8 @@ export class TeamService {
options: TeamTranscriptPageOptions,
projection: TranscriptFragmentProjection = {},
): Promise<TeamTranscriptPage> {
const bytes = await fs.readFile(filePath)
const stat = await fs.stat(filePath)
const source = (await readLegacyTranscriptFiles([filePath]))[0]!
const { bytes, stat } = source
return this.parseTranscriptBufferPage(
bytes,
options,
@@ -3116,12 +3134,11 @@ export class TeamService {
sources: Array<{ filePath: string; ownerAgentId?: string }>,
options: TeamTranscriptPageOptions,
): Promise<TeamTranscriptPage> {
const allFragments = await Promise.all(sources.map(async source => {
const [bytes, stat] = await Promise.all([
fs.readFile(source.filePath),
fs.stat(source.filePath),
])
return { ...source, bytes, ctimeMs: stat.ctimeMs }
const boundedFiles = await readLegacyTranscriptFiles(sources.map(source => source.filePath))
const allFragments = sources.map((source, index) => ({
...source,
bytes: boundedFiles[index]!.bytes,
ctimeMs: boundedFiles[index]!.stat.ctimeMs,
}))
const fragments = dropSupersededTranscriptFragments(allFragments)
const ownerAgentIdByOrdinal: Array<string | undefined> = []
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtemp, rm, writeFile, appendFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { readTeamTranscriptProjection } from './teamTranscriptProjection.js'
let dir: string
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'team-projection-')) })
afterEach(async () => { await rm(dir, { recursive: true, force: true }) })
const tool = (id: string, name: string, input: unknown) => JSON.stringify({ uuid: id, timestamp: '2026-01-01T00:00:00Z', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } }) + '\n'
describe('bounded Team transcript projection', () => {
test('caches empty large histories and only scans subsequently appended bytes', async () => {
const file = join(dir, 'session.jsonl')
const unrelated = JSON.stringify({ message: { role: 'assistant', content: [{ type: 'text', text: 'x'.repeat(8192) }] } }) + '\n'
await writeFile(file, unrelated.repeat(640))
const [first, concurrent] = await Promise.all([readTeamTranscriptProjection(file), readTeamTranscriptProjection(file)])
expect(first).toBe(concurrent)
expect(first.complete).toBe(true)
expect(first.messages).toEqual([])
expect(first.readBytes).toBeGreaterThan(5 * 1024 * 1024)
expect((await readTeamTranscriptProjection(file)).readBytes).toBe(0)
const appended = tool('create', 'TeamCreate', { team_name: 'test' })
await appendFile(file, appended)
const changed = await readTeamTranscriptProjection(file)
expect(changed.readBytes).toBe(Buffer.byteLength(appended))
expect(changed.messages).toHaveLength(1)
})
test('preserves paired task results and Team lifecycle evidence across appends', async () => {
const file = join(dir, 'session.jsonl')
await writeFile(file, tool('team', 'TeamCreate', { team_name: 'test' }) + tool('task', 'TaskCreate', { subject: 'Keep task' }))
await readTeamTranscriptProjection(file)
await appendFile(file, JSON.stringify({ uuid: 'result', timestamp: '2026-01-01T00:00:01Z', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'task', content: '{"task":{"id":"1"}}' }] }, toolUseResult: { task: { id: '1' }, taskListMutationRevision: 2 } }) + '\n' + tool('delete', 'TeamDelete', {}))
const projection = await readTeamTranscriptProjection(file)
expect(projection.messages).toHaveLength(4)
expect(projection.messages[2]?.toolUseResult).toEqual({ task: { id: '1' }, taskListMutationRevision: 2 })
})
test('rebuilds after truncation and retries a partial tail after completion', async () => {
const file = join(dir, 'session.jsonl')
const create = tool('team', 'TeamCreate', { team_name: 'first' })
await writeFile(file, create.slice(0, -2))
expect((await readTeamTranscriptProjection(file)).complete).toBe(false)
await appendFile(file, create.slice(-2))
expect((await readTeamTranscriptProjection(file)).messages).toHaveLength(1)
expect((await readTeamTranscriptProjection(file)).complete).toBe(true)
await writeFile(file, '{}\n')
expect((await readTeamTranscriptProjection(file)).messages).toEqual([])
})
test('signals incomplete evidence instead of claiming a missing team for oversized input', async () => {
const file = join(dir, 'session.jsonl')
await writeFile(file, tool('large', 'TeamCreate', { team_name: 'test', description: 'x'.repeat(2 * 1024 * 1024) }))
const projection = await readTeamTranscriptProjection(file)
expect(projection.complete).toBe(false)
expect(projection.messages).toEqual([])
expect((await readTeamTranscriptProjection(file)).readBytes).toBe(0)
})
})
@@ -0,0 +1,152 @@
import { open, stat } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { withHistoryReadBudget } from './boundedSessionHistory.js'
import type { MessageEntry } from './sessionService.js'
const MAX_RECORD_BYTES = 1024 * 1024
const MAX_PROJECTION_BYTES = 3 * 1024 * 1024
const MAX_PROJECTION_ENTRIES = 10_000
const WINDOW_BYTES = 4096
const TEAM_TOOLS = new Set(['TeamCreate', 'TeamDelete', 'Agent', 'TaskCreate', 'TaskUpdate', 'TaskList', 'SendMessage'])
type Projection = {
messages: MessageEntry[]
complete: boolean
sourceVersion: string
readBytes: number
}
type CachedProjection = Projection & {
completeRecords: boolean
teamSeen: boolean
identity: string
size: number
mtime: string
indexedBytes: number
firstHash: string
boundaryHash: string
bytes: number
toolIds: Set<string>
}
const cache = new Map<string, CachedProjection>()
const pending = new Map<string, Promise<Projection>>()
/** A bounded, incremental source for legacy Team migration and terminal replay. */
export async function readTeamTranscriptProjection(filePath: string): Promise<Projection> {
const existing = pending.get(filePath)
if (existing) return existing
const operation = withHistoryReadBudget(undefined, () => readProjection(filePath), 'metadata')
pending.set(filePath, operation)
try { return await operation } finally { if (pending.get(filePath) === operation) pending.delete(filePath) }
}
async function readProjection(filePath: string): Promise<Projection> {
const handle = await open(filePath, 'r')
try {
const before = await handle.stat({ bigint: true })
const size = Number(before.size)
const identity = `${before.dev}:${before.ino}`
const mtime = String(before.mtimeNs)
const previous = cache.get(filePath)
if (previous?.identity === identity && previous.size === size && previous.mtime === mtime) {
cache.delete(filePath)
cache.set(filePath, previous)
return { ...previous, readBytes: 0 }
}
async function windowHash(end: number) {
const length = Math.min(WINDOW_BYTES, end)
const bytes = Buffer.alloc(length)
const result = await handle.read(bytes, 0, length, end - length)
if (result.bytesRead !== length) throw new Error('Team transcript changed during projection')
return createHash('sha256').update(bytes).digest('hex')
}
const canAppend = previous?.identity === identity && size > previous.size &&
await windowHash(Math.min(WINDOW_BYTES, previous.size)) === previous.firstHash &&
await windowHash(previous.indexedBytes) === previous.boundaryHash
const state: CachedProjection = {
messages: canAppend ? [...previous.messages] : [],
toolIds: new Set(canAppend ? previous.toolIds : []),
complete: canAppend ? previous.completeRecords : true,
completeRecords: canAppend ? previous.completeRecords : true,
teamSeen: canAppend ? previous.teamSeen : false,
bytes: canAppend ? previous.bytes : 0,
indexedBytes: canAppend ? previous.indexedBytes : 0,
sourceVersion: `${identity}:${size}:${mtime}`,
identity, size, mtime, firstHash: '', boundaryHash: '', readBytes: 0,
}
let position = state.indexedBytes
let parts: Buffer[] = []
let lineBytes = 0
let skipping = false
const chunk = Buffer.allocUnsafe(128 * 1024)
while (position < size) {
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, size - position), position)
if (!bytesRead) throw new Error('Team transcript changed during projection')
state.readBytes += bytesRead
let start = 0
while (start < bytesRead) {
const newline = chunk.indexOf(10, start)
const end = newline >= 0 && newline < bytesRead ? newline : bytesRead
lineBytes += end - start
if (lineBytes > MAX_RECORD_BYTES) { skipping = true; parts = [] }
if (!skipping) parts.push(Buffer.from(chunk.subarray(start, end)))
if (end < bytesRead) {
if (skipping) state.complete = false
else if (lineBytes) {
let entry: unknown
try { entry = JSON.parse(Buffer.concat(parts, lineBytes).toString('utf8')) } catch { state.complete = false }
if (entry && typeof entry === 'object') collectTeamEvidence(entry as Record<string, unknown>, state)
}
state.indexedBytes = position + end + 1
parts = []; lineBytes = 0; skipping = false
}
start = end + 1
}
position += bytesRead
await new Promise<void>(resolve => setImmediate(resolve))
}
// A partial tail is retried from its start on the next append.
state.completeRecords = state.complete
if (lineBytes) state.complete = false
const after = await handle.stat({ bigint: true })
const current = await stat(filePath, { bigint: true })
if (after.dev !== before.dev || after.ino !== before.ino || current.dev !== before.dev || current.ino !== before.ino ||
after.size < before.size || current.size < before.size ||
(after.size === before.size && after.mtimeNs !== before.mtimeNs) ||
(current.size === before.size && current.mtimeNs !== before.mtimeNs)) {
throw new Error('Team transcript changed during projection')
}
state.firstHash = await windowHash(Math.min(WINDOW_BYTES, size))
state.boundaryHash = await windowHash(state.indexedBytes)
cache.delete(filePath)
cache.set(filePath, state)
while (cache.size > 4) cache.delete(cache.keys().next().value!)
return state
} finally { await handle.close() }
}
function collectTeamEvidence(entry: Record<string, unknown>, state: CachedProjection) {
if (state.bytes >= MAX_PROJECTION_BYTES || state.messages.length >= MAX_PROJECTION_ENTRIES) { state.complete = false; return }
const raw = entry.message as { role?: string; content?: unknown } | undefined
if (!raw || typeof raw !== 'object') return
const blocks = Array.isArray(raw.content) ? raw.content : []
if (blocks.some(block => block?.type === 'tool_use' && block.name === 'TeamCreate')) state.teamSeen = true
const tools = blocks.filter((block): block is Record<string, unknown> => Boolean(block && typeof block === 'object' && block.type === 'tool_use' && state.teamSeen && TEAM_TOOLS.has(block.name)))
for (const block of tools) if (typeof block.id === 'string') state.toolIds.add(block.id)
const results = blocks.filter((block): block is Record<string, unknown> => Boolean(block && typeof block === 'object' && block.type === 'tool_result' && state.toolIds.has(block.tool_use_id)))
const text = typeof raw.content === 'string' ? raw.content : blocks.filter(block => block?.type === 'text').map(block => block.text ?? '').join('\n')
const teammate = raw.role === 'user' && text.includes('<teammate-message')
const content = tools.length ? tools : results.length ? results : teammate ? raw.content : undefined
if (content === undefined) return
const message: MessageEntry = {
id: typeof entry.uuid === 'string' ? entry.uuid : `team:${state.indexedBytes}:${state.messages.length}`,
type: tools.length ? 'tool_use' : results.length ? 'tool_result' : 'user',
content,
timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : new Date(0).toISOString(),
...(typeof entry.cwd === 'string' ? { cwd: entry.cwd } : {}),
...(entry.toolUseResult !== undefined ? { toolUseResult: entry.toolUseResult } : {}),
...(typeof entry.parent_tool_use_id === 'string' ? { parentToolUseId: entry.parent_tool_use_id } : {}),
}
const bytes = Buffer.byteLength(JSON.stringify(message))
if (state.bytes + bytes > MAX_PROJECTION_BYTES) { state.complete = false; state.bytes = MAX_PROJECTION_BYTES; return }
state.bytes += bytes
state.messages.push(message)
}
+5 -1
View File
@@ -528,7 +528,11 @@ export class TeamWatcher {
private extractSubagentName(filePath: string): string | null {
try {
const head = fs.readFileSync(filePath, 'utf-8').slice(0, 8192)
const fd = fs.openSync(filePath, 'r')
const prefix = Buffer.allocUnsafe(8192)
let length: number
try { length = fs.readSync(fd, prefix, 0, prefix.length, 0) } finally { fs.closeSync(fd) }
const head = prefix.toString('utf8', 0, length)
const lines = head.split('\n').filter((line) => line.trim().length > 0)
for (const line of lines) {
@@ -34,6 +34,8 @@ export type {
TraceRequestSemantic,
TraceResponseCapture,
TraceSession,
TraceOverviewOptions,
TraceSessionWindow,
TraceSessionDeleteResult,
TraceSessionFileItem,
TraceSessionFileList,
+15 -13
View File
@@ -34,6 +34,7 @@ import type {
WorkflowPhaseMeta,
WorkflowProgressEvent,
} from '../../utils/workflows/types.js'
import { readWorkflowTranscriptProjection } from './workflowTranscriptProjection.js'
import { ApiError } from '../middleware/errorHandler.js'
import {
sessionService,
@@ -941,24 +942,25 @@ class WorkflowService {
}
const seenPaths = new Set<string>()
let evidenceBytes = 0
let evidenceRecords = 0
for (const file of files.sort((left, right) => left.modifiedAt - right.modifiedAt)) {
if (seenPaths.has(file.filePath)) continue
seenPaths.add(file.filePath)
let raw: string
let projection
try {
raw = await fs.readFile(file.filePath, 'utf8')
} catch {
continue
projection = await readWorkflowTranscriptProjection(file.filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
throw error
}
for (const line of raw.split('\n')) {
if (!line.trim()) continue
let entry: Record<string, unknown>
try {
const parsed = JSON.parse(line)
if (!isObjectRecord(parsed)) continue
entry = parsed
} catch {
continue
if (!projection.complete) {
throw new ApiError(503, 'Workflow history exceeds the bounded reconstruction budget', 'WORKFLOW_HISTORY_INCOMPLETE')
}
for (const entry of projection.messages) {
evidenceBytes += Buffer.byteLength(JSON.stringify(entry))
if (++evidenceRecords > 10_000 || evidenceBytes > 3 * 1024 * 1024) {
throw new ApiError(503, 'Workflow history exceeds the bounded reconstruction budget', 'WORKFLOW_HISTORY_INCOMPLETE')
}
this.collectWorkflowLifecycleEntry(entry, file.ownerAgentId, lifecycle)
}
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, expect, test, spyOn } from 'bun:test'
import { mkdtemp, rm, writeFile, appendFile, rename } from 'node:fs/promises'
import * as fs from 'node:fs/promises'
import { workflowService } from './workflowService.js'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { readWorkflowTranscriptProjection } from './workflowTranscriptProjection.js'
let dir: string
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'workflow-projection-')) })
afterEach(async () => { await rm(dir, { recursive: true, force: true }) })
const launch = JSON.stringify({ toolUseResult: { runId: 'wf_run', taskId: 'task', taskType: 'local_workflow', status: 'async_launched' } }) + '\n'
test('large histories without workflows retain no bodies and subsequent polls read zero bytes', async () => {
const file = join(dir, 'session.jsonl')
await writeFile(file, (JSON.stringify({ message: { content: 'x'.repeat(8192) } }) + '\n').repeat(640))
const [first, concurrent] = await Promise.all([readWorkflowTranscriptProjection(file), readWorkflowTranscriptProjection(file)])
expect(first).toBe(concurrent)
expect(first.complete).toBe(true)
expect(first.messages).toEqual([])
expect(first.readBytes).toBeGreaterThan(5 * 1024 * 1024)
expect((await readWorkflowTranscriptProjection(file)).readBytes).toBe(0)
await appendFile(file, launch)
const appended = await readWorkflowTranscriptProjection(file)
expect(appended.readBytes).toBe(Buffer.byteLength(launch))
expect(appended.messages).toHaveLength(1)
})
test('retains structured launch, persisted terminal and XML terminal evidence', async () => {
const file = join(dir, 'session.jsonl')
const terminal = { type: 'cc-haha-task-notification', taskNotification: { taskId: 'task', status: 'completed' } }
const xml = { message: { content: [{ type: 'text', text: '<task-notification><task-id>task</task-id><status>completed</status></task-notification>' }] } }
await writeFile(file, launch + JSON.stringify(terminal) + '\n' + JSON.stringify(xml) + '\n')
expect((await readWorkflowTranscriptProjection(file)).messages).toEqual([JSON.parse(launch), terminal, xml])
await writeFile(join(dir, 'replacement'), '{}\n')
await rename(join(dir, 'replacement'), file)
expect((await readWorkflowTranscriptProjection(file)).messages).toEqual([])
})
test('oversized records and excessive lifecycle evidence explicitly report incomplete', async () => {
const file = join(dir, 'session.jsonl')
await writeFile(file, JSON.stringify({ toolUseResult: { taskType: 'local_workflow', body: 'x'.repeat(2 * 1024 * 1024) } }) + '\n')
expect((await readWorkflowTranscriptProjection(file)).complete).toBe(false)
expect((await readWorkflowTranscriptProjection(file)).readBytes).toBe(0)
await writeFile(file, launch.repeat(10_001))
const projection = await readWorkflowTranscriptProjection(file)
expect(projection.complete).toBe(false)
expect(projection.messages).toHaveLength(10_000)
})
test('workflow reconstruction consumes projected evidence without a whole-file read fallback', async () => {
const file = join(dir, 'session.jsonl')
await writeFile(file, launch)
const readAll = spyOn(fs, 'readFile').mockImplementation(() => { throw new Error('Unbounded read forbidden') })
try {
const lifecycle = await (workflowService as unknown as {
readSessionWorkflowLifecycle(id: string, dirs: Array<{ sessionId: string; dir: string }>): Promise<{ launchesByRunId: Map<string, unknown> }>
}).readSessionWorkflowLifecycle('session', [{ sessionId: 'session', dir: join(dir, 'session') }])
expect(lifecycle.launchesByRunId.has('wf_run')).toBe(true)
expect(readAll).not.toHaveBeenCalled()
} finally { readAll.mockRestore() }
})
@@ -0,0 +1,143 @@
import { open, stat } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { withHistoryReadBudget } from './boundedSessionHistory.js'
const MAX_RECORD_BYTES = 1024 * 1024
const MAX_PROJECTION_BYTES = 3 * 1024 * 1024
const MAX_PROJECTION_ENTRIES = 10_000
const WINDOW_BYTES = 4096
type Projection = {
messages: Record<string, unknown>[]
complete: boolean
sourceVersion: string
readBytes: number
}
type CachedProjection = Projection & {
completeRecords: boolean
identity: string
size: number
mtime: string
indexedBytes: number
firstHash: string
boundaryHash: string
bytes: number
}
const cache = new Map<string, CachedProjection>()
const pending = new Map<string, Promise<Projection>>()
/** A bounded, incremental source for workflow lifecycle reconstruction. */
export async function readWorkflowTranscriptProjection(filePath: string): Promise<Projection> {
const existing = pending.get(filePath)
if (existing) return existing
const operation = withHistoryReadBudget(undefined, () => readProjection(filePath), 'metadata')
pending.set(filePath, operation)
try { return await operation } finally { if (pending.get(filePath) === operation) pending.delete(filePath) }
}
async function readProjection(filePath: string): Promise<Projection> {
const handle = await open(filePath, 'r')
try {
const before = await handle.stat({ bigint: true })
const size = Number(before.size)
const identity = `${before.dev}:${before.ino}`
const mtime = String(before.mtimeNs)
const previous = cache.get(filePath)
if (previous?.identity === identity && previous.size === size && previous.mtime === mtime) {
cache.delete(filePath)
cache.set(filePath, previous)
return { ...previous, readBytes: 0 }
}
async function windowHash(end: number) {
const length = Math.min(WINDOW_BYTES, end)
const bytes = Buffer.alloc(length)
const result = await handle.read(bytes, 0, length, end - length)
if (result.bytesRead !== length) throw new Error('Workflow transcript changed during projection')
return createHash('sha256').update(bytes).digest('hex')
}
const canAppend = previous?.identity === identity && size > previous.size &&
await windowHash(Math.min(WINDOW_BYTES, previous.size)) === previous.firstHash &&
await windowHash(previous.indexedBytes) === previous.boundaryHash
const state: CachedProjection = {
messages: canAppend ? [...previous.messages] : [],
complete: canAppend ? previous.completeRecords : true,
completeRecords: canAppend ? previous.completeRecords : true,
bytes: canAppend ? previous.bytes : 0,
indexedBytes: canAppend ? previous.indexedBytes : 0,
sourceVersion: `${identity}:${size}:${mtime}`,
identity, size, mtime, firstHash: '', boundaryHash: '', readBytes: 0,
}
let position = state.indexedBytes
let parts: Buffer[] = []
let lineBytes = 0
let skipping = false
const chunk = Buffer.allocUnsafe(128 * 1024)
while (position < size) {
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, size - position), position)
if (!bytesRead) throw new Error('Workflow transcript changed during projection')
state.readBytes += bytesRead
let start = 0
while (start < bytesRead) {
const newline = chunk.indexOf(10, start)
const end = newline >= 0 && newline < bytesRead ? newline : bytesRead
lineBytes += end - start
if (lineBytes > MAX_RECORD_BYTES) { skipping = true; parts = [] }
if (!skipping) parts.push(Buffer.from(chunk.subarray(start, end)))
if (end < bytesRead) {
if (skipping) state.complete = false
else if (lineBytes) {
let entry: unknown
try { entry = JSON.parse(Buffer.concat(parts, lineBytes).toString('utf8')) } catch { state.complete = false }
if (entry && typeof entry === 'object') collectWorkflowEvidence(entry as Record<string, unknown>, state)
}
state.indexedBytes = position + end + 1
parts = []; lineBytes = 0; skipping = false
}
start = end + 1
}
position += bytesRead
await new Promise<void>(resolve => setImmediate(resolve))
}
// A partial tail is retried from its start on the next append.
state.completeRecords = state.complete
if (lineBytes) state.complete = false
const after = await handle.stat({ bigint: true })
const current = await stat(filePath, { bigint: true })
if (after.dev !== before.dev || after.ino !== before.ino || current.dev !== before.dev || current.ino !== before.ino ||
after.size < before.size || current.size < before.size ||
(after.size === before.size && after.mtimeNs !== before.mtimeNs) ||
(current.size === before.size && current.mtimeNs !== before.mtimeNs)) {
throw new Error('Workflow transcript changed during projection')
}
state.firstHash = await windowHash(Math.min(WINDOW_BYTES, size))
state.boundaryHash = await windowHash(state.indexedBytes)
cache.delete(filePath)
cache.set(filePath, state)
while (cache.size > 4) cache.delete(cache.keys().next().value!)
return state
} finally { await handle.close() }
}
function collectWorkflowEvidence(entry: Record<string, unknown>, state: CachedProjection) {
// Parse only bounded records; retain lifecycle evidence, never conversation bodies.
// The existing service remains the structured interpreter for these candidates.
const pending: unknown[] = [entry]
let relevant = entry.type === 'cc-haha-task-notification'
let nodes = 0
while (pending.length && !relevant && nodes++ < 16_384) {
const value = pending.pop()
if (typeof value === 'string') {
relevant = value.includes('<task-notification>') || value.includes('local_workflow') || value.includes('async_launched')
} else if (value && typeof value === 'object') {
for (const nested of Object.values(value)) pending.push(nested)
}
}
if (pending.length && !relevant) state.complete = false
if (!relevant) return
const bytes = Buffer.byteLength(JSON.stringify(entry))
if (state.bytes + bytes > MAX_PROJECTION_BYTES || state.messages.length >= MAX_PROJECTION_ENTRIES) {
state.complete = false
return
}
state.bytes += bytes
state.messages.push(entry)
}
+5 -2
View File
@@ -1,3 +1,4 @@
import { ApiError } from '../middleware/errorHandler.js'
import * as fs from 'node:fs/promises'
import { watch, type FSWatcher } from 'node:fs'
import { execFile as execFileCallback } from 'node:child_process'
@@ -791,7 +792,8 @@ export class WorkspaceService {
let messages: MessageEntry[]
try {
messages = await this.resolveSessionMessages(sessionId)
} catch {
} catch (error) {
if (error instanceof ApiError && error.statusCode === 413) throw error
return []
}
@@ -841,7 +843,8 @@ export class WorkspaceService {
let snapshots: FileHistorySnapshot[]
try {
snapshots = await this.resolveSessionFileHistorySnapshots(sessionId)
} catch {
} catch (error) {
if (error instanceof ApiError && error.statusCode === 413) throw error
return []
}
if (snapshots.length === 0) return []
+9 -6
View File
@@ -1735,12 +1735,9 @@ async function handleSetRuntimeConfig(
return
}
if (conversationService.hasSession(sessionId)) {
await persistSessionRuntimeConfig(sessionId, nextOverride)
await restartSessionWithRuntimeConfig(ws, sessionId)
return
}
// A spawned process is already registered before its SDK startup settles.
// Wait for that startup before restarting, otherwise stopping it rejects
// the first turn that is still awaiting the same startup promise.
const pendingStartup = sessionStartupPromises.get(sessionId)
if (pendingStartup) {
const startupRuntimeVersion = sessionStartupRuntimeVersions.get(sessionId) ?? 0
@@ -1767,6 +1764,12 @@ async function handleSetRuntimeConfig(
return
}
if (conversationService.hasSession(sessionId)) {
await persistSessionRuntimeConfig(sessionId, nextOverride)
await restartSessionWithRuntimeConfig(ws, sessionId)
return
}
await persistSessionRuntimeConfig(sessionId, nextOverride)
broadcastAppliedRuntimeConfig(sessionId)
})
@@ -0,0 +1,278 @@
import { expect, spyOn, test } from 'bun:test'
import { promises as fs } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
clearTraceCaptureStateForTests,
traceCaptureService,
TRACE_RECORD_BYTES_LIMIT,
TRACE_WINDOW_RECORD_LIMIT,
TRACE_WINDOW_BYTES_LIMIT,
captureResponseTraceSnapshot,
TRACE_STREAM_CAPTURE_BYTES,
} from './traceCapture.js'
async function fixture(run: (filePath: string) => Promise<void>) {
const previousConfig = process.env.CLAUDE_CONFIG_DIR
const previousMode = process.env.CC_HAHA_LOCAL_INDEX
const scope = await fs.mkdtemp(join(tmpdir(), 'trace-hard-bounds-'))
process.env.CLAUDE_CONFIG_DIR = scope
process.env.CC_HAHA_LOCAL_INDEX = 'on'
clearTraceCaptureStateForTests()
try {
const dir = join(scope, 'cc-haha', 'traces')
await fs.mkdir(dir, { recursive: true })
await run(join(dir, 'fixture.jsonl'))
} finally {
clearTraceCaptureStateForTests()
if (previousConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfig
if (previousMode === undefined) delete process.env.CC_HAHA_LOCAL_INDEX
else process.env.CC_HAHA_LOCAL_INDEX = previousMode
await fs.rm(scope, { recursive: true, force: true })
}
}
function callLine(id: string, preview = '') {
return JSON.stringify({ type: 'call', record: {
id, sessionId: 'fixture', source: 'proxy', status: 'ok', startedAt: '2026-01-01T00:00:00Z',
request: { method: 'POST', url: 'https://example.test', headers: {}, body: {
bytes: preview.length, contentType: 'text', preview, sha256: '', truncated: false,
} },
} }) + '\n'
}
test('skips oversized JSONL records before parse and keeps raw bytes available', () => fixture(async filePath => {
await fs.writeFile(filePath, callLine('huge', 'x'.repeat(TRACE_RECORD_BYTES_LIMIT * 3)) + callLine('small'))
const bytesBefore = (await fs.stat(filePath)).size
const parse = JSON.parse.bind(JSON)
let largestParsedString = 0
const parseSpy = spyOn(JSON, 'parse').mockImplementation((value, reviver) => {
largestParsedString = Math.max(largestParsedString, typeof value === 'string' ? Buffer.byteLength(value) : 0)
return parse(value, reviver)
})
try {
const overview = await traceCaptureService.getSessionTraceOverview('fixture')
expect(overview.calls.map(call => call.id)).toEqual(['small'])
expect(overview.window).toMatchObject({ state: 'limited', oversizedRecords: 1, recordBytesLimit: TRACE_RECORD_BYTES_LIMIT })
expect(largestParsedString).toBeLessThanOrEqual(TRACE_RECORD_BYTES_LIMIT)
await expect(traceCaptureService.getSessionTraceCall('fixture', 'huge')).rejects.toMatchObject({ code: 'TRACE_RECORD_TOO_LARGE' })
expect((await fs.stat(filePath)).size).toBe(bytesBefore)
expect(await traceCaptureService.getSessionTraceFile('fixture')).toEqual({ path: filePath })
} finally { parseSpy.mockRestore() }
}))
test('pages stable bounded rows and continues beyond the first index window', () => fixture(async filePath => {
const handle = await fs.open(filePath, 'w')
for (let i = 0; i < TRACE_WINDOW_RECORD_LIMIT + 20; i++) await handle.write(callLine(`call-${i}`))
await handle.close()
const first = await traceCaptureService.getSessionTraceOverview('fixture', { limit: 7 })
expect(first.calls).toHaveLength(7)
expect(first.window).toMatchObject({ totalCalls: TRACE_WINDOW_RECORD_LIMIT, hasMore: true, state: 'limited', startByte: 0 })
expect(first.window?.nextScanCursor).toBeString()
const nextPage = await traceCaptureService.getSessionTraceOverview('fixture', { offset: 7, limit: 7, revisionToken: first.window!.revisionToken })
expect(nextPage.calls[0]?.id).toBe('call-7')
const nextWindow = await traceCaptureService.getSessionTraceOverview('fixture', { scanCursor: first.window!.nextScanCursor })
expect(nextWindow.calls).toHaveLength(20)
expect(nextWindow.calls[0]?.id).toBe(`call-${TRACE_WINDOW_RECORD_LIMIT}`)
expect(nextWindow.window).toMatchObject({ totalCalls: 20, hasMore: false, state: 'ready' })
expect(nextWindow.window!.startByte).toBeGreaterThan(0)
await traceCaptureService.getSessionTraceRevision('fixture')
expect((await traceCaptureService.getSessionTraceCall('fixture', `call-${TRACE_WINDOW_RECORD_LIMIT}`))?.id).toBe(`call-${TRACE_WINDOW_RECORD_LIMIT}`)
await fs.appendFile(filePath, callLine('new'))
await expect(traceCaptureService.getSessionTraceOverview('fixture', { scanCursor: first.window!.nextScanCursor })).rejects.toMatchObject({ code: 'TRACE_PAGE_STALE' })
await expect(traceCaptureService.getSessionTraceOverview('fixture', { revisionToken: first.window!.revisionToken, offset: 7 })).rejects.toMatchObject({ code: 'TRACE_PAGE_STALE' })
}))
test('bounds a window by bytes as well as record count and permits continuation', () => fixture(async filePath => {
const handle = await fs.open(filePath, 'w')
const line = Buffer.alloc(1024 * 1024, 0x20)
line[line.length - 1] = 0x0a
for (let i = 0; i < 66; i++) await handle.write(line)
await handle.write(callLine('after-byte-window'))
await handle.close()
const first = await traceCaptureService.getSessionTraceOverview('fixture')
expect(first.window?.scannedBytes).toBe(TRACE_WINDOW_BYTES_LIMIT)
expect(first.window?.nextScanCursor).toBeString()
const next = await traceCaptureService.getSessionTraceOverview('fixture', { scanCursor: first.window!.nextScanCursor })
expect(next.calls[0]?.id).toBe('after-byte-window')
}))
test('aborting the last overview consumer stops physical reads and closes its handle', () => fixture(async filePath => {
await fs.writeFile(filePath, callLine('huge', 'x'.repeat(8 * 1024 * 1024)))
const controller = new AbortController()
const originalOpen = fs.open.bind(fs)
let reads = 0
let closed = false
const openSpy = spyOn(fs, 'open').mockImplementation(async (...args) => {
const handle = await originalOpen(...args)
if (String(args[0]) !== filePath) return handle
return new Proxy(handle, {
get(target, property) {
if (property === 'read') return async (buffer: Uint8Array, offset: number, length: number, position: number) => {
const result = await target.read(buffer, offset, length, position)
reads += 1
if (reads === 2) controller.abort()
return result
}
if (property === 'close') return async () => { closed = true; await target.close() }
const value = Reflect.get(target, property, target)
return typeof value === 'function' ? value.bind(target) : value
},
})
})
try {
await expect(traceCaptureService.getSessionTraceOverview('fixture', { signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' })
await new Promise<void>(resolve => setImmediate(resolve))
expect(reads).toBe(2)
expect(closed).toBe(true)
} finally { openSpy.mockRestore() }
}))
test('shares cold work and keeps reading while another consumer still needs it', () => fixture(async filePath => {
await fs.writeFile(filePath, callLine('huge', 'x'.repeat(6 * 1024 * 1024)) + callLine('small'))
const fileBytes = (await fs.stat(filePath)).size
const controller = new AbortController()
const originalOpen = fs.open.bind(fs)
let reads = 0
const openSpy = spyOn(fs, 'open').mockImplementation(async (...args) => {
const handle = await originalOpen(...args)
if (String(args[0]) !== filePath) return handle
return new Proxy(handle, {
get(target, property) {
if (property === 'read') return async (buffer: Uint8Array, offset: number, length: number, position: number) => {
const result = await target.read(buffer, offset, length, position)
reads += 1
if (reads === 2) controller.abort()
return result
}
const value = Reflect.get(target, property, target)
return typeof value === 'function' ? value.bind(target) : value
},
})
})
try {
const abandoned = traceCaptureService.getSessionTraceOverview('fixture', { signal: controller.signal })
const retained = traceCaptureService.getSessionTraceOverview('fixture')
await expect(abandoned).rejects.toMatchObject({ name: 'AbortError' })
expect((await retained).calls[0]?.id).toBe('small')
expect(reads).toBe(Math.ceil(fileBytes / (256 * 1024)))
} finally { openSpy.mockRestore() }
}))
test('bounds queued cold jobs and cancels queued work before opening source files', () => fixture(async filePath => {
for (let i = 0; i < 9; i++) await fs.writeFile(filePath.replace('fixture.jsonl', `queued-${i}.jsonl`), callLine(`call-${i}`))
const originalOpen = fs.open.bind(fs)
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
let entered!: () => void
const started = new Promise<void>(resolve => { entered = resolve })
let reads = 0
const openSpy = spyOn(fs, 'open').mockImplementation(async (...args) => {
const handle = await originalOpen(...args)
if (!String(args[0]).includes('/queued-')) return handle
return new Proxy(handle, {
get(target, property) {
if (property === 'read') return async (buffer: Uint8Array, offset: number, length: number, position: number) => {
reads += 1
entered()
await gate
return target.read(buffer, offset, length, position)
}
const value = Reflect.get(target, property, target)
return typeof value === 'function' ? value.bind(target) : value
},
})
})
const controllers = Array.from({ length: 9 }, () => new AbortController())
try {
const requests = controllers.map((controller, index) => traceCaptureService.getSessionTraceOverview(`queued-${index}`, { signal: controller.signal })
.then(value => ({ value, error: null }), error => ({ value: null, error })))
await started
expect((await Promise.race(requests))?.error?.code).toBe('TRACE_INDEX_BUSY')
controllers.forEach(controller => controller.abort())
release()
await Promise.all(requests)
await new Promise<void>(resolve => setImmediate(resolve))
expect(reads).toBe(1)
} finally {
release()
openSpy.mockRestore()
}
}))
test('live event metadata is bounded without invoking accessors', () => fixture(async filePath => {
let getterCalls = 0
await traceCaptureService.recordEvent({ sessionId: 'fixture', phase: 'fixture', metadata: {
get expensive() { getterCalls += 1; return 'x'.repeat(8 * 1024 * 1024) },
} })
const raw = await fs.readFile(filePath, 'utf8')
expect(getterCalls).toBe(0)
expect(Buffer.byteLength(raw)).toBeLessThan(TRACE_RECORD_BYTES_LIMIT)
expect(JSON.parse(raw).event.metadata).toMatchObject({ traceCaptureOmitted: { reason: 'accessor' } })
}))
test('an oversized response chunk is sliced before decoding and stops trace capture', async () => {
let cancelled = false
const response = new Response(new ReadableStream<Uint8Array>({
start(controller) { controller.enqueue(new Uint8Array(8 * 1024 * 1024).fill(0x78)) },
cancel() { cancelled = true },
}))
const originalDecode = TextDecoder.prototype.decode
let largestDecoded = 0
const decodeSpy = spyOn(TextDecoder.prototype, 'decode').mockImplementation(function (input, options) {
largestDecoded = Math.max(largestDecoded, input?.byteLength ?? 0)
return originalDecode.call(this, input, options)
})
try {
const result = await captureResponseTraceSnapshot(response)
expect(largestDecoded).toBeLessThanOrEqual(TRACE_STREAM_CAPTURE_BYTES)
expect(result.snapshot.truncated).toBe(true)
expect(result.aborted).toBe(false)
expect(cancelled).toBe(true)
} finally { decodeSpy.mockRestore() }
})
test('the legacy full snapshot helper rejects giant files before reading and directs callers to pages', () => fixture(async filePath => {
await fs.writeFile(filePath, '')
await fs.truncate(filePath, 16 * 1024 * 1024)
await expect(traceCaptureService.getSessionTrace('fixture')).rejects.toMatchObject({ code: 'TRACE_RECORD_TOO_LARGE' })
expect((await fs.stat(filePath)).size).toBe(16 * 1024 * 1024)
}))
test('a single huge line yields bounded byte windows and resumes skipping until the next record', () => fixture(async filePath => {
const handle = await fs.open(filePath, 'w')
await handle.write('{"padding":"')
const block = Buffer.alloc(1024 * 1024, 0x78)
for (let i = 0; i < 128; i++) await handle.write(block)
await handle.write('"}\n' + callLine('after-huge-line'))
await handle.close()
let page = await traceCaptureService.getSessionTraceOverview('fixture')
expect(page.window?.scannedBytes).toBeLessThanOrEqual(TRACE_WINDOW_BYTES_LIMIT + 256 * 1024)
expect(page.window?.oversizedRecords).toBe(1)
expect(page.window?.nextScanCursor).toBeString()
let windows = 1
while (page.window?.nextScanCursor) {
page = await traceCaptureService.getSessionTraceOverview('fixture', { scanCursor: page.window.nextScanCursor })
expect(page.window?.scannedBytes).toBeLessThanOrEqual(TRACE_WINDOW_BYTES_LIMIT + 256 * 1024)
windows += 1
expect(windows).toBeLessThanOrEqual(3)
}
expect(windows).toBe(3)
expect(page.calls.map(call => call.id)).toEqual(['after-huge-line'])
}))
test('appends cannot grow a full metadata window beyond its row budget', () => fixture(async filePath => {
await fs.writeFile(filePath, Array.from({ length: TRACE_WINDOW_RECORD_LIMIT }, (_, i) => callLine(`call-${i}`)).join(''))
const first = await traceCaptureService.getSessionTraceOverview('fixture')
expect(first.window?.totalCalls).toBe(TRACE_WINDOW_RECORD_LIMIT)
expect(first.window?.nextScanCursor).toBeUndefined()
await fs.appendFile(filePath, callLine('new-1') + callLine('new-2'))
const full = await traceCaptureService.getSessionTraceOverview('fixture')
expect(full.window?.totalCalls).toBe(TRACE_WINDOW_RECORD_LIMIT)
expect(full.window?.nextScanCursor).toBeString()
const next = await traceCaptureService.getSessionTraceOverview('fixture', { scanCursor: full.window?.nextScanCursor })
expect(next.calls.map(call => call.id)).toEqual(['new-1', 'new-2'])
}))
@@ -0,0 +1,84 @@
import { expect, spyOn, test } from 'bun:test'
import { promises as fs } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { clearTraceCaptureStateForTests, traceCaptureService } from './traceCapture.js'
for (const mode of ['on', 'off', 'shadow']) {
test(`trace overview streams locator shells with index ${mode}`, async () => {
const previousConfig = process.env.CLAUDE_CONFIG_DIR
const previousMode = process.env.CC_HAHA_LOCAL_INDEX
const scope = await fs.mkdtemp(join(tmpdir(), 'trace-bounded-'))
process.env.CLAUDE_CONFIG_DIR = scope
process.env.CC_HAHA_LOCAL_INDEX = mode
clearTraceCaptureStateForTests()
const dir = join(scope, 'cc-haha', 'traces')
const filePath = join(dir, 'fixture.jsonl')
const line = (id: string) => JSON.stringify({ type: 'call', record: {
id, sessionId: 'fixture', source: 'proxy', status: 'error',
startedAt: '2026-06-09T08:00:00.000Z',
request: { method: 'POST', url: 'https://example.test', headers: {},
body: { bytes: 900_000, contentType: 'text', preview: '中文'.repeat(150_000), sha256: '', truncated: false } },
} }) + '\n'
const originalOpen = fs.open.bind(fs)
let fullReads = 0
let largestRead = 0
let removeBeforeNextRead = false
const openSpy = spyOn(fs, 'open').mockImplementation(async (...args) => {
if (removeBeforeNextRead && String(args[0]) === filePath) {
removeBeforeNextRead = false
await fs.unlink(filePath)
}
const handle = await originalOpen(...args)
if (String(args[0]) !== filePath) return handle
return new Proxy(handle, {
get(target, property) {
if (property === 'readFile') return async () => { fullReads++; return target.readFile() }
if (property === 'read') return async (buffer: Uint8Array, offset: number, length: number, position: number) => {
largestRead = Math.max(largestRead, length)
return target.read(buffer, offset, length, position)
}
const value = Reflect.get(target, property, target)
return typeof value === 'function' ? value.bind(target) : value
},
})
})
try {
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(filePath, line('first'))
const first = await traceCaptureService.getSessionTraceOverview('fixture')
expect(first.summary.failedCalls).toBe(1)
expect(first.calls[0].request.body.preview).toBe('')
await fs.appendFile(filePath, line('second') + line('third'))
const appended = await traceCaptureService.getSessionTraceOverview('fixture')
expect(appended.calls).toHaveLength(3)
expect(appended.summary.failedCalls).toBe(3)
expect(fullReads).toBe(0)
expect(largestRead).toBeLessThanOrEqual(256 * 1024)
const list = await traceCaptureService.listSessionTraces()
expect(list.traces[0]?.summary.apiCalls).toBe(3)
expect(fullReads).toBe(0)
expect(await traceCaptureService.getSessionTraceCall('fixture', 'missing')).toBeNull()
expect(fullReads).toBe(0)
expect(largestRead).toBeLessThanOrEqual(256 * 1024)
const detail = await traceCaptureService.getSessionTraceCall('fixture', 'second')
expect(detail?.request.body.preview).toBe('中文'.repeat(150_000))
expect(fullReads).toBe(0)
if (mode === 'off') {
// Deletion between directory discovery and the stream open must not
// turn a trace-list refresh into a failed request.
removeBeforeNextRead = true
const removed = await traceCaptureService.listSessionTraces()
expect(removed.traces[0]?.summary.apiCalls).toBe(0)
}
} finally {
openSpy.mockRestore()
clearTraceCaptureStateForTests()
if (previousConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfig
if (previousMode === undefined) delete process.env.CC_HAHA_LOCAL_INDEX
else process.env.CC_HAHA_LOCAL_INDEX = previousMode
await fs.rm(scope, { recursive: true, force: true })
}
})
}
+32 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { createTraceRequestSemantic } from './traceCapture.js'
import { createTraceRequestSemantic, createTraceBodySnapshot, TRACE_CAPTURE_NODE_LIMIT, TRACE_CAPTURE_CHAR_LIMIT, TRACE_CAPTURE_DEPTH_LIMIT } from './traceCapture.js'
describe('createTraceRequestSemantic', () => {
test('keeps proxy request structure while replacing Computer Use image data with metadata', () => {
@@ -49,3 +49,34 @@ describe('createTraceRequestSemantic', () => {
expect(JSON.stringify(semantic)).not.toContain('upstream')
})
})
test('capture budgets omit oversized trees without evaluating getters or array elements', () => {
let visited = 0
const hugeMessages = new Proxy(new Array(TRACE_CAPTURE_NODE_LIMIT + 1), {
getOwnPropertyDescriptor(target, key) {
visited += 1
return Reflect.getOwnPropertyDescriptor(target, key)
},
})
const body = { model: 'unchanged', messages: hugeMessages }
const semantic = createTraceRequestSemantic(body, 'anthropic')
expect(semantic?.request).toMatchObject({ traceCaptureOmitted: { reason: 'node-budget' } })
expect(visited).toBe(0)
expect(body.messages).toBe(hugeMessages)
let getterCalls = 0
const metadata = { get expensive() { getterCalls += 1; return 'unreachable' } }
expect(createTraceBodySnapshot(metadata).captureOmitted).toBe('accessor')
expect(getterCalls).toBe(0)
let toJsonCalls = 0
const withSerializer = { toJSON() { toJsonCalls += 1; return 'unreachable' } }
expect(createTraceBodySnapshot(withSerializer).captureOmitted).toBe('unsupported-value')
expect(toJsonCalls).toBe(0)
const wide = createTraceBodySnapshot({ text: 'x'.repeat(TRACE_CAPTURE_CHAR_LIMIT + 1) })
expect(wide.captureOmitted).toBe('string-budget')
expect(wide.truncated).toBe(true)
expect(wide.preview.length).toBeLessThan(1024)
let nested: unknown = { value: 'end' }
for (let i = 0; i < TRACE_CAPTURE_DEPTH_LIMIT + 2; i++) nested = { nested }
expect(createTraceBodySnapshot(nested).captureOmitted).toBe('depth-budget')
})
File diff suppressed because it is too large Load Diff