mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "一覧に戻る",
|
||||
|
||||
@@ -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": "둘러보기로 돌아가기",
|
||||
|
||||
@@ -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": "返回瀏覽",
|
||||
|
||||
@@ -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": "返回浏览",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) }))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]!,
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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) }
|
||||
: {}),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 []
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
+720
-259
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user