fix(perf): restore continuous history scrolling and bound search indexing

This commit is contained in:
程序员阿江(Relakkes)
2026-09-20 03:02:26 +08:00
parent 22e050f463
commit d16aabcf74
34 changed files with 2009 additions and 379 deletions
+2
View File
@@ -33,7 +33,9 @@ type MessagesResponse = {
export type SessionHistoryPage = MessagesResponse & {
page?: {
nextCursor: string | null
previousCursor?: string | null
hasMore: boolean
contentTruncated?: boolean
historyComplete: boolean
sourceVersion: string
scannedBytes: number
@@ -1,4 +1,4 @@
import { Check, Copy, GitFork } from 'lucide-react'
import { Check, Copy, GitFork, Undo2 } from 'lucide-react'
import type { ReactNode } from 'react'
import { useSettingsStore } from '../../stores/settingsStore'
import { formatExactMessageTimestamp, formatMessageHoverTime } from '../../lib/formatMessageTimestamp'
@@ -11,6 +11,12 @@ export type MessageBranchAction = {
onBranch: () => void
}
export type MessageRewindAction = {
label: string
loading?: boolean
onRewind: () => void
}
/**
* The copy chip and the branch chip sit side by side and must look identical.
* The branch one is an `IconButton size="sm" tone="muted" shape="circle"`;
@@ -27,6 +33,7 @@ type Props = {
copyText?: string
copyLabel: string
branchAction?: MessageBranchAction
rewindAction?: MessageRewindAction
align?: 'start' | 'end'
timestamp?: number
/** Inline metadata that shares the same compact row as the actions. */
@@ -43,6 +50,7 @@ export function MessageActionBar({
copyText,
copyLabel,
branchAction,
rewindAction,
align = 'start',
timestamp,
metadata,
@@ -57,7 +65,7 @@ export function MessageActionBar({
? formatExactMessageTimestamp(timestamp, locale)
: ''
if (!hasCopy && !branchAction && !metadata) return null
if (!hasCopy && !branchAction && !rewindAction && !metadata) return null
return (
<div
@@ -92,6 +100,18 @@ export function MessageActionBar({
onPointerUp={(event) => event.currentTarget.blur()}
/>
) : null}
{rewindAction ? (
<IconButton
icon={<Undo2 size={13} strokeWidth={2.2} aria-hidden="true" />}
label={rewindAction.label}
size="sm"
tone="muted"
shape="circle"
disabled={rewindAction.loading}
onClick={rewindAction.onRewind}
onPointerUp={(event) => event.currentTarget.blur()}
/>
) : null}
{metadata ? (
<span className={hasCopy || branchAction ? 'ml-3 min-w-0' : 'min-w-0'}>
{metadata}
@@ -367,7 +367,7 @@ describe('MessageList nested tool calls', () => {
messages, historyWindowed: true, historyPage: partialPage,
}) } }))
expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Load undo checkpoints' }))
fireEvent.click(screen.getByRole('button', { name: 'Roll back conversation' }))
await waitFor(() => expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalledTimes(1))
})
@@ -382,29 +382,81 @@ describe('MessageList nested tool calls', () => {
}] })
render(<MessageList />)
expect(sessionsApi.getTurnCheckpoints).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Load undo checkpoints' }))
fireEvent.click(screen.getByRole('button', { name: 'Roll back conversation' }))
await waitFor(() => expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalledTimes(1))
await waitFor(() => expect(screen.getByRole('button', { name: 'Load undo checkpoints' }).hasAttribute('disabled')).toBe(false))
await waitFor(() => expect(screen.getByRole('button', { name: 'Roll back conversation' }).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 () => {
it('scrolls into older history without buttons or replacing live state and jumps back to live', 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(screen.queryByTestId('history-window-notice')).toBeNull()
expect(screen.queryByRole('button', { name: 'Older messages' })).toBeNull()
expect(getPage).not.toHaveBeenCalled()
const scroller = screen.getByTestId('message-list').firstElementChild as HTMLElement
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 })
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1800 })
scroller.scrollTop = 100
fireEvent.wheel(scroller, { deltaY: -100 })
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' }))
fireEvent.click(screen.getByRole('button', { name: 'Latest' }))
expect(await screen.findByText('latest page message')).toBeTruthy()
expect(useChatStore.getState().sessions[ACTIVE_TAB]?.historyBrowseMessages).toBeUndefined()
})
it('scrolls forward through history and does not mistake the historical window bottom for the live tail', async () => {
const loadNewer = vi.spyOn(useChatStore.getState(), 'loadNewerHistory').mockResolvedValue()
const page = { nextCursor: 'older', previousCursor: 'newer', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 100, omittedOversizedEntries: 0 }
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({
historyPage: page, historyWindowed: true, historyViewingOlder: true,
messages: [{ id: 'live', type: 'assistant_text', content: 'Live content', timestamp: 10 }],
historyBrowseMessages: [{ id: 'old', type: 'assistant_text', content: 'Historical content', timestamp: 1 }],
}) } })
render(<MessageList />)
const scroller = screen.getByTestId('message-list').firstElementChild as HTMLElement
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 })
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1800 })
scroller.scrollTop = 1200
fireEvent.wheel(scroller, { deltaY: 100 })
await waitFor(() => expect(loadNewer).toHaveBeenCalledWith(ACTIVE_TAB))
expect(screen.getByRole('button', { name: 'Latest' })).toBeTruthy()
expect(screen.getByText('Historical content')).toBeTruthy()
expect(screen.queryByText('Live content')).toBeNull()
})
it('loads disk history when live retention created a gap after an initially complete page', async () => {
const loadOlder = vi.spyOn(useChatStore.getState(), 'loadOlderHistory').mockResolvedValue()
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({
historyLiveGap: true, historyWindowed: true,
historyPage: { nextCursor: null, hasMore: false, historyComplete: true, sourceVersion: 'v1', scannedBytes: 100, omittedOversizedEntries: 0 },
messages: [{ id: 'recent', type: 'assistant_text', content: 'Recent live message', timestamp: 10 }],
}) } })
render(<MessageList />)
expect(loadOlder).not.toHaveBeenCalled()
const scroller = screen.getByTestId('message-list').firstElementChild as HTMLElement
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 })
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1800 })
scroller.scrollTop = 100
fireEvent.wheel(scroller, { deltaY: -100 })
await waitFor(() => expect(loadOlder).toHaveBeenCalledWith(ACTIVE_TAB))
})
it('does not render internal recovery state as a history banner', () => {
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ historyWindowed: true, historyRecoveryStatus: 'incomplete' }) } })
render(<MessageList />)
expect(screen.queryByTestId('history-window-notice')).toBeNull()
expect(screen.queryByRole('button', { name: 'Latest messages' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Load undo checkpoints' })).toBeNull()
expect(screen.queryByText(/Session state recovery/)).toBeNull()
})
it('windows long transcripts instead of mounting every historical message at once', () => {
useChatStore.setState({
sessions: {
+119 -47
View File
@@ -1,6 +1,7 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback, useDeferredValue, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, MessageCircle, Settings, Target, Undo2, XCircle } from 'lucide-react'
import { useContinuousChatHistory } from '../../hooks/useContinuousChatHistory'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionRewindMode, type SessionTurnCheckpoint } from '../../api/sessions'
import { listPendingPermissions, useChatStore } from '../../stores/chatStore'
@@ -13,6 +14,7 @@ import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n/locales/en'
import { UserMessage } from './UserMessage'
import type { MessageRewindAction } from './MessageActionBar'
import { AssistantMessage } from './AssistantMessage'
import { ThinkingBlock } from './ThinkingBlock'
import { ToolCallBlock } from './ToolCallBlock'
@@ -1668,7 +1670,7 @@ function isNearScrollBottom(element: HTMLElement) {
)
}
function rememberSessionScroll(sessionId: string, element: HTMLElement) {
function rememberSessionScroll(sessionId: string, element: HTMLElement, wasAtBottom = isNearScrollBottom(element)) {
if (sessionScrollSnapshots.size >= MAX_SCROLL_SNAPSHOTS && !sessionScrollSnapshots.has(sessionId)) {
const oldestSessionId = sessionScrollSnapshots.keys().next().value
if (oldestSessionId) {
@@ -1678,7 +1680,7 @@ function rememberSessionScroll(sessionId: string, element: HTMLElement) {
sessionScrollSnapshots.set(sessionId, {
scrollTop: element.scrollTop,
wasAtBottom: isNearScrollBottom(element),
wasAtBottom,
})
}
@@ -2256,6 +2258,8 @@ export function MessageList({
const stopGeneration = useChatStore((s) => s.stopGeneration)
const reloadHistory = useChatStore((s) => s.reloadHistory)
const loadOlderHistory = useChatStore((s) => s.loadOlderHistory)
const loadNewerHistory = useChatStore((s) => s.loadNewerHistory)
const prefetchHistory = useChatStore((s) => s.prefetchHistory)
const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill)
const memberSessionTeam = useTeamStore((s) => (
resolvedSessionId ? s.getTeamByMemberSessionId(resolvedSessionId) : null
@@ -2300,7 +2304,7 @@ export function MessageList({
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 [requestedWindowCheckpoints, setRequestedWindowCheckpoints] = useState<{ key: string; revision: number; targetId?: string } | null>(null)
const chatState = sessionState?.chatState ?? 'idle'
const isPreparingTurn = Boolean(sessionState?.isPreparingTurn)
const historyMutationEpoch = sessionState?.historyMutationEpoch ?? 0
@@ -2336,6 +2340,8 @@ export function MessageList({
(chatState === 'thinking' && Boolean(activeThinkingId))
const messageListRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const historyScrollHandlers = useRef<{ onScroll: () => void; onUserIntent: (direction?: 'older' | 'newer') => void; cancelAnchor: () => void }>({ onScroll: () => {}, onUserIntent: () => {}, cancelAnchor: () => {} })
const lastTailWindowRevision = useRef(sessionState?.historyWindowRevision ?? 0)
const scrollContentRef = useRef<HTMLDivElement>(null)
const virtualItemHeightsRef = useRef<Map<string, number>>(
resolvedSessionId ? getHeightsForSession(resolvedSessionId) : new Map<string, number>(),
@@ -2486,7 +2492,7 @@ export function MessageList({
}, [resolvedSessionId])
const requestLiveFollow = useCallback(() => {
if (!shouldAutoScrollRef.current || liveFollowFrameRef.current !== null) return
if (sessionState?.historyViewingOlder || !shouldAutoScrollRef.current || liveFollowFrameRef.current !== null) return
liveFollowFrameRef.current = requestAnimationFrame(() => {
liveFollowFrameRef.current = null
@@ -2518,7 +2524,7 @@ export function MessageList({
}
setIsAwayFromLatest(false)
})
}, [resolvedSessionId])
}, [resolvedSessionId, sessionState?.historyViewingOlder])
const flushMeasuredHeightVersion = useCallback(() => {
if (!pendingMeasuredHeightsRef.current) return
@@ -2575,8 +2581,9 @@ export function MessageList({
if (performance.now() < userScrollIntentUntilRef.current) {
setProgrammaticNavigationItemId(null)
}
historyScrollHandlers.current.onScroll()
syncVirtualViewportFromContainer(container)
const isAtBottom = isNearScrollBottom(container)
const isAtBottom = !sessionState?.historyViewingOlder && isNearScrollBottom(container)
const isPermissionLayoutShift =
hasPendingPermissionCard &&
shouldAutoScrollRef.current &&
@@ -2588,9 +2595,9 @@ export function MessageList({
setIsAwayFromLatest(!isAtBottom)
if (resolvedSessionId) {
rememberSessionScroll(resolvedSessionId, container)
rememberSessionScroll(resolvedSessionId, container, isAtBottom)
}
}, [hasPendingPermissionCard, resolvedSessionId, syncVirtualViewportFromContainer])
}, [hasPendingPermissionCard, resolvedSessionId, sessionState?.historyViewingOlder, syncVirtualViewportFromContainer])
/**
* Expanding a collapsed block is the reader rearranging their own view, not
@@ -2620,19 +2627,21 @@ export function MessageList({
disclosureLayoutUntilRef.current = 0
const container = scrollContainerRef.current
if (!container) return
const atBottom = isNearScrollBottom(container)
const atBottom = !sessionState?.historyViewingOlder && isNearScrollBottom(container)
shouldAutoScrollRef.current = atBottom
setIsAwayFromLatest(!atBottom)
syncVirtualViewportFromContainer(container)
})
}, [syncVirtualViewportFromContainer])
}, [syncVirtualViewportFromContainer, sessionState?.historyViewingOlder])
const markUserScrollIntent = useCallback(() => {
userScrollIntentUntilRef.current = performance.now() + USER_SCROLL_INTENT_WINDOW_MS
historyScrollHandlers.current.onUserIntent()
}, [])
const handleWheelScrollIntent = useCallback((event: { deltaY: number }) => {
markUserScrollIntent()
if (event.deltaY !== 0) historyScrollHandlers.current.onUserIntent(event.deltaY < 0 ? 'older' : 'newer')
if (event.deltaY < 0) {
shouldAutoScrollRef.current = false
setIsAwayFromLatest(true)
@@ -2653,6 +2662,7 @@ export function MessageList({
if (!isScrollKey) return
markUserScrollIntent()
historyScrollHandlers.current.onUserIntent(isUpwardScrollKey ? 'older' : 'newer')
if (isUpwardScrollKey) {
shouldAutoScrollRef.current = false
setIsAwayFromLatest(true)
@@ -2662,7 +2672,7 @@ export function MessageList({
useLayoutEffect(() => {
if (lastSessionIdRef.current !== resolvedSessionId) {
const snapshot = resolvedSessionId ? sessionScrollSnapshots.get(resolvedSessionId) : undefined
shouldAutoScrollRef.current = snapshot?.wasAtBottom ?? true
shouldAutoScrollRef.current = snapshot?.wasAtBottom ?? !sessionState?.historyViewingOlder
lastSessionIdRef.current = resolvedSessionId
setProgrammaticNavigationItemId(null)
virtualItemHeightsRef.current = resolvedSessionId
@@ -2700,17 +2710,17 @@ export function MessageList({
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
ignoreProgrammaticScrollTopRef.current = null
lastAutoScrollAtRef.current = performance.now()
shouldAutoScrollRef.current = true
shouldAutoScrollRef.current = !sessionState?.historyViewingOlder
setScrollToBottomWithoutLayoutRead(container)
setVirtualViewport((current) => ({
scrollTop: SCROLL_BOTTOM_SENTINEL,
viewportHeight: container.clientHeight || current.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
}))
setIsAwayFromLatest(false)
setIsAwayFromLatest(Boolean(sessionState?.historyViewingOlder))
if (resolvedSessionId) {
sessionScrollSnapshots.set(resolvedSessionId, {
scrollTop: container.scrollTop,
wasAtBottom: true,
wasAtBottom: !sessionState?.historyViewingOlder,
})
}
} else {
@@ -2719,7 +2729,7 @@ export function MessageList({
scrollToBottom()
}
}
}, [resolvedSessionId, scrollToBottom])
}, [resolvedSessionId, scrollToBottom, sessionState?.historyViewingOlder])
const tailMessage = messages[messages.length - 1] ?? null
const tailMessageId = tailMessage?.id ?? null
@@ -2728,14 +2738,17 @@ export function MessageList({
useEffect(() => {
if (!resolvedSessionId) return
const revision = sessionState?.historyWindowRevision ?? 0
const windowChanged = lastTailWindowRevision.current !== revision
lastTailWindowRevision.current = revision
const previousTailMessageId = lastTailMessageIdBySessionRef.current.get(resolvedSessionId)
lastTailMessageIdBySessionRef.current.set(resolvedSessionId, tailMessageId)
if (previousTailMessageId === undefined || previousTailMessageId === tailMessageId) return
if (windowChanged || sessionState?.historyViewingOlder || previousTailMessageId === undefined || previousTailMessageId === tailMessageId) return
if (tailMessageType === 'user_text') {
scrollToBottom()
}
}, [resolvedSessionId, scrollToBottom, tailMessageId, tailMessageType])
}, [resolvedSessionId, scrollToBottom, tailMessageId, tailMessageType, sessionState?.historyWindowRevision, sessionState?.historyViewingOlder])
useEffect(() => {
const previousInput = lastLiveFollowInputRef.current
@@ -2766,9 +2779,12 @@ export function MessageList({
}, [messages.length, requestLiveFollow, resolvedSessionId, streamingText, streamingToolInput])
const handleJumpToLatest = useCallback(() => {
historyScrollHandlers.current.cancelAnchor()
setProgrammaticNavigationItemId(null)
scrollToBottom()
}, [scrollToBottom])
if (resolvedSessionId && sessionState?.historyViewingOlder) {
void loadOlderHistory(resolvedSessionId, true).then(() => requestAnimationFrame(scrollToBottom))
} else scrollToBottom()
}, [scrollToBottom, resolvedSessionId, sessionState?.historyViewingOlder, loadOlderHistory])
useEffect(() => {
const content = scrollContentRef.current
@@ -2884,11 +2900,11 @@ export function MessageList({
// The rail is the progress indicator: whichever segment the turn is currently
// working in carries the running state, so it sits next to the work it
// describes instead of in a separate strip somewhere else on screen.
const showsTurnStatusLine = hasApiRetry
const showsTurnStatusLine = !sessionState?.historyViewingOlder && (hasApiRetry
|| hasStreamingFallback
|| isPreparingTurn
|| chatState === 'tool_executing'
|| (chatState === 'thinking' && !activeThinkingId)
|| (chatState === 'thinking' && !activeThinkingId))
const renderItemKeys = useMemo(
() => renderItems.map(getRenderItemKey),
[renderItems],
@@ -2932,6 +2948,41 @@ export function MessageList({
),
[measuredItemsVersion, renderItemKeys, renderItemMetrics, renderItems, virtualViewport],
)
const historyAnchorIdentities = useMemo(() => new Map(renderItems.map((item) => [
getRenderItemKey(item),
item.kind === 'tool_group' ? item.toolCalls.map((tool) => tool.id)
: item.kind === 'team_card' ? item.coordinationToolCalls.map((tool) => tool.id)
: [item.message.id],
])), [renderItems])
const preserveHistoryReading = useCallback(() => {
shouldAutoScrollRef.current = false
setIsAwayFromLatest(true)
}, [])
const syncHistoryViewport = useCallback((container: HTMLElement) => {
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
ignoreProgrammaticScrollTopRef.current = container.scrollTop
syncVirtualViewportFromContainer(container)
if (resolvedSessionId) rememberSessionScroll(resolvedSessionId, container, false)
}, [resolvedSessionId, syncVirtualViewportFromContainer])
const continuousHistory = useContinuousChatHistory({
sessionId: resolvedSessionId ?? undefined,
revision: sessionState?.historyWindowRevision ?? 0,
ready: sessionState?.historyStatus === 'ready' && sessionState?.historyHydrated === true,
loading: sessionState?.historyPageLoading === true,
error: sessionState?.historyError,
olderCursor: sessionState?.historyPage?.nextCursor ?? (sessionState?.historyLiveGap && !sessionState.historyBrowseMessages ? '__live_gap__' : null),
newerCursor: sessionState?.historyPage?.previousCursor ?? (sessionState?.historyBrowseMessages ? '__live__' : null),
container: scrollContainerRef,
keys: renderItemKeys,
offsets: virtualTranscriptWindow.offsets,
identities: historyAnchorIdentities,
load: (direction) => resolvedSessionId ? (direction === 'older' ? loadOlderHistory(resolvedSessionId) : loadNewerHistory(resolvedSessionId)) : Promise.resolve(),
prefetch: (direction) => resolvedSessionId ? prefetchHistory(resolvedSessionId, direction) : Promise.resolve(),
syncViewport: syncHistoryViewport,
preserveReading: preserveHistoryReading,
})
historyScrollHandlers.current = continuousHistory
const activeConversationNavigationItemId = useMemo(
() => isAwayFromLatest
? getActiveConversationNavigationItemId(
@@ -3059,8 +3110,7 @@ export function MessageList({
completedTurnTargets.map((target) => [target.userMessageIndex, target] as const),
)
setTurnChangeCards(
normalizeTurnCheckpoints(checkpointResponse).flatMap((checkpoint) => {
const nextCards = normalizeTurnCheckpoints(checkpointResponse).flatMap((checkpoint) => {
const target =
targetByMessageId.get(checkpoint.target.targetUserMessageId) ??
(sessionState?.historyWindowed ? undefined : targetByUserMessageIndex.get(checkpoint.target.userMessageIndex))
@@ -3073,8 +3123,11 @@ export function MessageList({
workDir: checkpoint.workDir ?? workspaceStatus?.workDir ?? null,
isLatest: target.messageId === latestCompletedTurnId,
}]
}),
)
})
setTurnChangeCards(nextCards)
if (requestedWindowCheckpoints?.targetId && nextCards.some((card) => card.target.messageId === requestedWindowCheckpoints.targetId)) {
setTurnUndoConfirmTargetId(requestedWindowCheckpoints.targetId)
}
})
.catch((error) => {
if (cancelled) return
@@ -3484,6 +3537,21 @@ export function MessageList({
})
}, [isWorkspacePanelOpen, resolvedSessionId, restoreWorkspaceOrigin, workspacePanelOrigin])
const rewindActionByMessageId = useMemo(() => {
const actions = new Map<string, MessageRewindAction>()
if (!resolvedSessionId || !checkpointRequiresRequest) return actions
const available = new Set(turnChangeCards.map((card) => card.target.messageId))
for (const target of completedTurnTargets) {
if (available.has(target.messageId)) continue
actions.set(target.messageId, {
label: t('chat.conversationRewindAction'),
loading: isLoadingTurnChangeCards || chatState !== 'idle' || hasRunningBackgroundTasks,
onRewind: () => setRequestedWindowCheckpoints((current) => ({ key: historyWindowKey, revision: (current?.revision ?? 0) + 1, targetId: target.messageId })),
})
}
return actions
}, [resolvedSessionId, checkpointRequiresRequest, turnChangeCards, completedTurnTargets, t, isLoadingTurnChangeCards, chatState, hasRunningBackgroundTasks, historyWindowKey])
const renderTranscriptItem = (item: RenderItem, index: number) => {
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
@@ -3509,7 +3577,7 @@ export function MessageList({
// is finished, whatever any individual tool's state looks like this
// instant — which is why this, and not `isStreaming`, decides
// whether a run stands open.
isLive={chatState !== 'idle' && index === renderItems.length - 1 && !hasTrailingStreamingItem}
isLive={!sessionState?.historyViewingOlder && chatState !== 'idle' && index === renderItems.length - 1 && !hasTrailingStreamingItem}
/>
) : item.kind === 'team_card' ? (
resolvedSessionId ? (() => {
@@ -3546,6 +3614,7 @@ export function MessageList({
: null
}
branchAction={branchActionByMessageId.get(item.message.id)}
rewindAction={rewindActionByMessageId.get(item.message.id)}
turnChangedFiles={changedFilesByRenderIndex.get(index)}
isTurnOutputOwner={turnOutputOwnerIndexes.has(index)}
turnCompletion={turnCompletionByMessageId.get(item.message.id)}
@@ -3553,6 +3622,10 @@ export function MessageList({
/>
)}
{item.kind === 'message' && requestedWindowCheckpoints?.targetId === item.message.id && turnChangeLoadError ? (
<span role="alert" className="text-xs text-[var(--color-error)]">{turnChangeLoadError}</span>
) : null}
{resolvedSessionId && cardsForItem.map((card) => {
const error = turnActionErrors[card.target.messageId] ?? null
const openUndoDialog = () => {
@@ -3623,25 +3696,15 @@ 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}
{sessionState?.historyPageLoading && sessionState.historyPageDirection !== 'newer' ? (
<div className="flex items-center justify-center gap-2 py-2 text-xs text-[var(--color-text-tertiary)]" role="status">
<LoaderCircle size={12} className="animate-spin" />{t('chat.history.loading')}
</div>
) : null}
{sessionState?.historyError ? (
<div className="flex items-center justify-center gap-2 py-2 text-xs text-[var(--color-text-secondary)]" role="alert">
<span>{t('chat.history.loadFailed')}</span>
<Button variant="ghost" size="xs" onClick={() => sessionState?.historyPageDirection === 'latest' ? handleJumpToLatest() : continuousHistory.retry()}>{t('chat.history.retry')}</Button>
</div>
) : null}
{virtualTranscriptWindow.enabled ? (
@@ -3679,6 +3742,12 @@ export function MessageList({
<VirtualSpacer height={virtualTranscriptWindow.afterHeight} position="bottom" />
) : null}
{sessionState?.historyPageLoading && sessionState.historyPageDirection === 'newer' ? (
<div className="flex items-center justify-center gap-2 py-2 text-xs text-[var(--color-text-tertiary)]" role="status">
<LoaderCircle size={12} className="animate-spin" />{t('chat.history.loading')}
</div>
) : null}
{streamingText.trim() && (
<div
data-chat-render-item-key={STREAMING_ASSISTANT_NAVIGATION_KEY}
@@ -3689,7 +3758,7 @@ export function MessageList({
</div>
)}
{chatState === 'compacting' && !hasCompactingDivider && (
{!sessionState?.historyViewingOlder && chatState === 'compacting' && !hasCompactingDivider && (
<CompactStatusDivider state="compacting" />
)}
@@ -3708,7 +3777,7 @@ export function MessageList({
</div>
)}
{!isLoadingTurnChangeCards && visibleTurnChangeCards.length === 0 && turnChangeLoadError && (
{!requestedWindowCheckpoints?.targetId && !isLoadingTurnChangeCards && visibleTurnChangeCards.length === 0 && turnChangeLoadError && (
<div className="mx-auto mb-5 w-full max-w-[900px] rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-4 py-3 text-xs text-[var(--color-on-error-container)]">
{turnChangeLoadError}
</div>
@@ -3770,6 +3839,7 @@ export const MessageBlock = memo(function MessageBlock({
agentTaskNotifications,
toolResult,
branchAction,
rewindAction,
turnChangedFiles,
isTurnOutputOwner,
turnCompletion,
@@ -3786,6 +3856,7 @@ export const MessageBlock = memo(function MessageBlock({
loading?: boolean
onBranch: () => void
}
rewindAction?: MessageRewindAction
turnChangedFiles?: string[]
isTurnOutputOwner?: boolean
turnCompletion?: TurnCompletion
@@ -3818,6 +3889,7 @@ export const MessageBlock = memo(function MessageBlock({
content={message.content}
attachments={message.attachments}
branchAction={branchAction}
rewindAction={rewindAction}
timestamp={message.timestamp}
sessionId={sessionId ?? undefined}
teammateFrom={message.teammateFrom}
@@ -19,6 +19,25 @@ describe('UserMessage', () => {
openPreviewLink.mockClear().mockReturnValue(true)
})
it('places rollback beside copy and fork in the existing hover and keyboard-focus action row', () => {
useSettingsStore.setState({ locale: 'en' })
const rollback = vi.fn()
const { container } = render(<UserMessage content="A prompt" branchAction={{ label: 'Fork', onBranch: vi.fn() }} rewindAction={{ label: 'Roll back conversation', onRewind: rollback }} />)
const button = screen.getByRole('button', { name: 'Roll back conversation' })
const actions = button.closest('[data-message-actions]')
expect(actions).toBeTruthy()
expect(actions?.className).toContain('opacity-0')
expect(actions?.className).toContain('group-hover:opacity-100')
expect(actions?.className).toContain('group-focus-within:opacity-100')
expect(container.querySelectorAll('[data-message-actions]')).toHaveLength(1)
expect(screen.getByRole('button', { name: 'Fork' }).closest('[data-message-actions]')).toBe(actions)
expect(button.textContent).toBe('')
button.focus()
expect(document.activeElement).toBe(button)
fireEvent.click(button)
expect(rollback).toHaveBeenCalledOnce()
})
it('keeps long URLs inside the message bubble', () => {
const longUrl = `https://cn.bing.com/search?q=${'encoded'.repeat(60)}`
+4 -1
View File
@@ -6,13 +6,14 @@ import { useTranslation } from '../../i18n'
import { openPreviewLink } from '../../lib/openPreviewLink'
import { splitTextByUrls } from '../../lib/urlBoundary'
import { AttachmentGallery } from './AttachmentGallery'
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
import { MessageActionBar, type MessageBranchAction, type MessageRewindAction } from './MessageActionBar'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
type Props = {
content: string
attachments?: UIAttachment[]
branchAction?: MessageBranchAction
rewindAction?: MessageRewindAction
timestamp?: number
sessionId?: string
/** Set when this turn came from another agent rather than from the user. */
@@ -26,6 +27,7 @@ export const UserMessage = memo(function UserMessage({
content,
attachments,
branchAction,
rewindAction,
timestamp,
sessionId,
teammateFrom,
@@ -170,6 +172,7 @@ export const UserMessage = memo(function UserMessage({
copyText={content}
copyLabel={t('chat.copyPrompt')}
branchAction={branchAction}
rewindAction={rewindAction}
align="end"
timestamp={timestamp}
/>
@@ -0,0 +1,162 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useContinuousChatHistory } from './useContinuousChatHistory'
function fixture() {
const container = document.createElement('div')
document.body.appendChild(container)
Object.defineProperties(container, { clientHeight: { value: 300 }, scrollHeight: { configurable: true, value: 2000 } })
container.getBoundingClientRect = () => ({ top: 0, bottom: 300, height: 300 } as DOMRect)
function rows(keys: string[]) {
container.replaceChildren(...keys.map((key, index) => {
const node = document.createElement('div')
node.dataset.chatRenderItemKey = key
node.getBoundingClientRect = () => ({ top: index * 200 - container.scrollTop, bottom: (index + 1) * 200 - container.scrollTop, height: 200 } as DOMRect)
return node
}))
}
const options = {
sessionId: 'session', revision: 0, ready: true, loading: false,
olderCursor: 'older', newerCursor: 'newer', container: { current: container },
keys: ['a', 'b', 'c', 'd'], offsets: [0, 200, 400, 600, 800],
load: vi.fn(async (_direction: 'older' | 'newer') => {}),
prefetch: vi.fn(async (_direction: 'older' | 'newer') => {}),
syncViewport: vi.fn(), preserveReading: vi.fn(),
}
rows(options.keys)
return { container, options, rows }
}
afterEach(() => { cleanup(); document.body.replaceChildren(); vi.restoreAllMocks() })
describe('continuous bounded history scrolling', () => {
it('does not scan on mount or programmatic scroll and prefetches once ahead of an intentional boundary', () => {
const { container, options } = fixture()
const view = renderHook(() => useContinuousChatHistory(options))
container.scrollTop = 400
act(() => view.result.current.onScroll())
expect(options.load).not.toHaveBeenCalled()
expect(options.prefetch).not.toHaveBeenCalled()
act(() => view.result.current.onUserIntent('older'))
act(() => view.result.current.onUserIntent('older'))
expect(options.prefetch).toHaveBeenCalledTimes(1)
expect(options.prefetch).toHaveBeenCalledWith('older')
expect(options.load).not.toHaveBeenCalled()
})
it('loads both boundaries only once while a request is outstanding', async () => {
const { container, options } = fixture()
let resolve!: () => void
options.load.mockImplementationOnce(() => new Promise<void>((done) => { resolve = done }))
const view = renderHook(() => useContinuousChatHistory(options))
container.scrollTop = 100
act(() => view.result.current.onUserIntent('older'))
act(() => view.result.current.onUserIntent('older'))
expect(options.load).toHaveBeenCalledTimes(1)
await act(async () => { resolve() })
container.scrollTop = 1600
act(() => view.result.current.onUserIntent('newer'))
expect(options.load).toHaveBeenLastCalledWith('newer')
})
it('preserves the visible row pixel offset after prepending', () => {
const { container, options, rows } = fixture()
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
options.load.mockImplementation(() => new Promise(() => {}))
container.scrollTop = 220
const view = renderHook((props) => useContinuousChatHistory(props), { initialProps: options })
act(() => view.result.current.onUserIntent('older'))
const keys = ['older', ...options.keys]
rows(keys)
view.rerender({ ...options, revision: 1, keys, offsets: [0, 200, 400, 600, 800, 1000] })
expect(container.scrollTop).toBe(420)
expect(container.querySelector<HTMLElement>('[data-chat-render-item-key="b"]')!.getBoundingClientRect().top).toBe(-20)
expect(options.syncViewport).toHaveBeenCalled()
expect(options.load).toHaveBeenCalledTimes(1)
})
it('preserves the row offset when scrolling forward evicts rows above it', () => {
const { container, options, rows } = fixture()
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
Object.defineProperty(container, 'scrollHeight', { value: 1000 })
const keys = ['old', ...options.keys]
rows(keys)
container.scrollTop = 620
const props = { ...options, keys, offsets: [0, 200, 400, 600, 800, 1000] }
const view = renderHook((next) => useContinuousChatHistory(next), { initialProps: props })
act(() => view.result.current.onUserIntent('newer'))
const nextKeys = [...options.keys, 'new']
rows(nextKeys)
view.rerender({ ...props, revision: 1, keys: nextKeys })
expect(container.scrollTop).toBe(420)
expect(container.querySelector<HTMLElement>('[data-chat-render-item-key="c"]')!.getBoundingClientRect().top).toBe(-20)
})
it('anchors a merged tool group by its surviving tool identity', () => {
const { container, options, rows } = fixture()
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
rows(['group-tool-a'])
container.scrollTop = 20
const props = { ...options, keys: ['group-tool-a'], offsets: [0, 200], identities: new Map([['group-tool-a', ['tool-a']]]) }
const view = renderHook((next) => useContinuousChatHistory(next), { initialProps: props })
act(() => view.result.current.onUserIntent('older'))
rows(['older-message', 'group-tool-before-a'])
view.rerender({ ...props, revision: 1, keys: ['older-message', 'group-tool-before-a'], offsets: [0, 200, 400], identities: new Map([['group-tool-before-a', ['tool-before', 'tool-a']]]) })
expect(container.scrollTop).toBe(220)
})
it('captures the latest reading position when the user reverses direction during a slow request', () => {
const { container, options, rows } = fixture()
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
options.load.mockImplementation(() => new Promise(() => {}))
container.scrollTop = 220
const view = renderHook((next) => useContinuousChatHistory(next), { initialProps: options })
act(() => view.result.current.onUserIntent('older'))
act(() => view.result.current.onUserIntent('newer'))
container.scrollTop = 420
act(() => view.result.current.onScroll())
const keys = ['older', ...options.keys]
rows(keys)
view.rerender({ ...options, revision: 1, keys, offsets: [0, 200, 400, 600, 800, 1000] })
expect(container.scrollTop).toBe(620)
expect(container.querySelector<HTMLElement>('[data-chat-render-item-key="c"]')!.getBoundingClientRect().top).toBe(-20)
expect(options.load).toHaveBeenCalledTimes(1)
})
it('releases a historical anchor when the reader explicitly jumps to live messages', () => {
const { container, options, rows } = fixture()
options.load.mockImplementation(() => new Promise(() => {}))
container.scrollTop = 220
const view = renderHook((next) => useContinuousChatHistory(next), { initialProps: options })
act(() => view.result.current.onUserIntent('older'))
act(() => view.result.current.cancelAnchor())
rows(['older', ...options.keys])
view.rerender({ ...options, revision: 1, keys: ['older', ...options.keys], offsets: [0, 200, 400, 600, 800, 1000] })
expect(container.scrollTop).toBe(220)
expect(options.syncViewport).not.toHaveBeenCalled()
})
it('keeps late responses for a prior session from releasing a new session request', async () => {
const { container, options } = fixture()
let resolveOld!: () => void
options.load.mockImplementationOnce(() => new Promise<void>((done) => { resolveOld = done }))
.mockImplementation(() => new Promise(() => {}))
const view = renderHook((props) => useContinuousChatHistory(props), { initialProps: options })
container.scrollTop = 100
act(() => view.result.current.onUserIntent('older'))
view.rerender({ ...options, sessionId: 'other' })
act(() => view.result.current.onUserIntent('older'))
await act(async () => { resolveOld() })
act(() => view.result.current.onUserIntent('older'))
expect(options.load).toHaveBeenCalledTimes(2)
})
it('pauses automatic retry after a failed page and allows explicit retry', () => {
const { options } = fixture()
const view = renderHook(() => useContinuousChatHistory({ ...options, error: 'failed' }))
act(() => view.result.current.onUserIntent('older'))
expect(options.load).not.toHaveBeenCalled()
act(() => view.result.current.retry())
expect(options.load).toHaveBeenCalledWith('older')
})
})
@@ -0,0 +1,177 @@
import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react'
type Direction = 'older' | 'newer'
type Anchor = { key: string; top: number; offset: number; identities?: string[] }
type Options = {
sessionId?: string
revision: number
ready: boolean
loading: boolean
error?: string | null
olderCursor?: string | null
newerCursor?: string | null
container: RefObject<HTMLDivElement>
keys: string[]
offsets: number[]
identities?: Map<string, string[]>
load: (direction: Direction) => Promise<void>
prefetch: (direction: Direction) => Promise<void>
syncViewport: (container: HTMLElement) => void
preserveReading: () => void
}
/** Retains a visible row while the bounded transcript window moves underneath it. */
export function useContinuousChatHistory(options: Options) {
const current = useRef(options)
current.current = options
const intentUntil = useRef(0)
const lastScrollTop = useRef(0)
const pending = useRef<{ sessionId?: string; anchors: Anchor[]; scrollTop: number } | null>(null)
const requestInFlight = useRef(false)
const requestGeneration = useRef(0)
const lastPrefetch = useRef('')
const failedDirection = useRef<Direction>('older')
const previous = useRef({ sessionId: options.sessionId, revision: options.revision })
const correctionFrame = useRef<number | null>(null)
const correcting = useRef(false)
const capture = useCallback(() => {
const state = current.current
const container = state.container.current
if (!container) return null
const top = container.getBoundingClientRect().top
const height = container.clientHeight || 800
const anchors: Anchor[] = []
for (const node of container.querySelectorAll<HTMLElement>('[data-chat-render-item-key]')) {
const rect = node.getBoundingClientRect()
if (rect.bottom <= top || rect.top >= top + height) continue
const key = node.dataset.chatRenderItemKey!
const index = state.keys.indexOf(key)
if (index < 0) continue
anchors.push({ key, top: rect.top - top, offset: state.offsets[index] ?? 0, identities: state.identities?.get(key) })
if (anchors.length === 3) break
}
return { sessionId: state.sessionId, anchors, scrollTop: container.scrollTop }
}, [])
const request = useCallback(async (direction: Direction) => {
const state = current.current
if (!state.sessionId || !state.ready || state.loading || requestInFlight.current) return
const cursor = direction === 'older' ? state.olderCursor : state.newerCursor
if (!cursor) return
pending.current = capture()
requestInFlight.current = true
const generation = ++requestGeneration.current
failedDirection.current = direction
state.preserveReading()
try {
await state.load(direction)
} catch {
// The store owns the visible retry state.
} finally {
if (requestGeneration.current === generation) requestInFlight.current = false
}
}, [capture])
const checkBoundary = useCallback((direction: Direction) => {
const state = current.current
const container = state.container.current
if (!container || !state.ready || state.loading || state.error || requestInFlight.current || correcting.current) return
const distance = direction === 'older' ? container.scrollTop : container.scrollHeight - container.clientHeight - container.scrollTop
const height = container.clientHeight || 800
const cursor = direction === 'older' ? state.olderCursor : state.newerCursor
if (!cursor) return
if (distance <= height) {
void request(direction)
} else if (distance <= height * 1.5) {
const key = `${state.sessionId}:${direction}:${cursor}`
if (lastPrefetch.current !== key) {
lastPrefetch.current = key
void state.prefetch(direction).catch(() => {})
}
}
}, [request])
const onUserIntent = useCallback((direction?: Direction) => {
intentUntil.current = performance.now() + 1500
const container = current.current.container.current
if (container) lastScrollTop.current = container.scrollTop
if (direction) checkBoundary(direction)
}, [checkBoundary])
const onScroll = useCallback(() => {
const container = current.current.container.current
if (!container) return
const delta = container.scrollTop - lastScrollTop.current
lastScrollTop.current = container.scrollTop
if (correcting.current || performance.now() > intentUntil.current) return
if (pending.current && requestInFlight.current) pending.current = capture()
if (delta !== 0) checkBoundary(delta < 0 ? 'older' : 'newer')
}, [capture, checkBoundary])
useLayoutEffect(() => {
const before = previous.current
previous.current = { sessionId: options.sessionId, revision: options.revision }
if (before.sessionId !== options.sessionId) {
pending.current = null
intentUntil.current = 0
requestInFlight.current = false
requestGeneration.current++
lastPrefetch.current = ''
correcting.current = false
if (correctionFrame.current !== null) cancelAnimationFrame(correctionFrame.current)
return
}
if (before.revision === options.revision) return
const snapshot = pending.current
pending.current = null
const container = options.container.current
if (!snapshot || snapshot.sessionId !== options.sessionId || !container) return
const anchor = snapshot.anchors.map((item) => {
if (options.keys.includes(item.key)) return item
const key = options.keys.find((candidate) => options.identities?.get(candidate)?.some((id) => item.identities?.includes(id)))
return key ? { ...item, key } : undefined
}).find((item) => item !== undefined)
if (!anchor) return
options.preserveReading()
correcting.current = true
const findAnchor = () => Array.from(container.querySelectorAll<HTMLElement>('[data-chat-render-item-key]'))
.find((node) => node.dataset.chatRenderItemKey === anchor.key)
const node = findAnchor()
const index = options.keys.indexOf(anchor.key)
container.scrollTop = Math.max(0, node
? container.scrollTop + node.getBoundingClientRect().top - container.getBoundingClientRect().top - anchor.top
: snapshot.scrollTop + (options.offsets[index] ?? 0) - anchor.offset)
lastScrollTop.current = container.scrollTop
options.syncViewport(container)
// The offset correction mounts the anchor if virtualization had removed it.
// One measured correction then includes padding, notices and real row height.
correctionFrame.current = requestAnimationFrame(() => {
correctionFrame.current = null
const mounted = findAnchor()
if (mounted) {
const delta = mounted.getBoundingClientRect().top - container.getBoundingClientRect().top - anchor.top
if (Math.abs(delta) > 0.5) container.scrollTop += delta
}
lastScrollTop.current = container.scrollTop
current.current.syncViewport(container)
correcting.current = false
})
}, [options.revision, options.sessionId, options.keys, options.offsets, options.identities, options.container, options.syncViewport, options.preserveReading])
const cancelAnchor = useCallback(() => {
pending.current = null
intentUntil.current = 0
requestGeneration.current++
requestInFlight.current = false
correcting.current = false
if (correctionFrame.current !== null) cancelAnimationFrame(correctionFrame.current)
correctionFrame.current = null
}, [])
useLayoutEffect(() => () => {
if (correctionFrame.current !== null) cancelAnimationFrame(correctionFrame.current)
}, [])
return { onUserIntent, onScroll, cancelAnchor, retry: () => { void request(failedDirection.current) } }
}
+4 -6
View File
@@ -1,10 +1,8 @@
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.",
"chat.history.loading": "Loading messages…",
"chat.history.loadFailed": "Could not load messages.",
"chat.history.retry": "Try again",
"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",
+4 -6
View File
@@ -1,12 +1,10 @@
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": "一部のセッション状態を復元できませんでした。",
"chat.history.loading": "メッセージを読み込み中…",
"chat.history.loadFailed": "メッセージを読み込めませんでした。",
"chat.history.retry": "再試行",
"trace.windowNotice": "会話とトレースは制限付きページで読み込まれます。",
"trace.windowLimited": "表示上限を超えた記録があります。元のファイルは変更されません。",
"trace.windowFirst": "先頭 / ライブ",
+4 -6
View File
@@ -1,12 +1,10 @@
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": "일부 세션 상태를 복원하지 못했습니다.",
"chat.history.loading": "메시지 불러오는 중…",
"chat.history.loadFailed": "메시지를 불러오지 못했습니다.",
"chat.history.retry": "다시 시도",
"trace.windowNotice": "대화와 추적은 제한된 페이지로 불러옵니다.",
"trace.windowLimited": "일부 기록이 표시 한도를 초과했습니다. 원본 파일은 유지됩니다.",
"trace.windowFirst": "처음 / 실시간",
+4 -6
View File
@@ -1,12 +1,10 @@
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": "部分工作階段狀態尚未還原。",
"chat.history.loading": "正在載入訊息…",
"chat.history.loadFailed": "訊息載入失敗。",
"chat.history.retry": "重試",
"trace.windowNotice": "聊天記錄與追蹤以有限視窗載入。",
"trace.windowLimited": "部分記錄超過檢視上限,原始檔案保持完整。",
"trace.windowFirst": "首頁 / 即時",
+4 -6
View File
@@ -1,12 +1,10 @@
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": "部分会话状态尚未恢复。",
"chat.history.loading": "正在加载消息…",
"chat.history.loadFailed": "消息加载失败。",
"chat.history.retry": "重试",
"trace.windowNotice": "聊天记录与追踪按有限窗口加载。",
"trace.windowLimited": "部分记录超出查看上限,原始文件保持完整。",
"trace.windowFirst": "首页 / 实时",
+11 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { boundActivityText, boundChatHistory, CHAT_HISTORY_MAX_ROWS } from './chatHistoryBudget'
import { boundActivityText, boundChatHistory, previewHistoryPage, CHAT_HISTORY_MAX_ROWS } from './chatHistoryBudget'
import type { UIMessage } from '../types/chat'
function text(id: number, content = 'message'): UIMessage {
@@ -7,6 +7,16 @@ function text(id: number, content = 'message'): UIMessage {
}
describe('chat history retention', () => {
it('uses spare page space for ordinary replies while bounding an oversized tool body', () => {
const normal = Array.from({ length: 100 }, (_, index) => text(index, 'A complete readable response. '.repeat(20)))
const huge = text(100, 'x'.repeat(2_000_000))
const result = previewHistoryPage([...normal, huge], 256 * 1024)
expect(result).toHaveLength(101)
for (let index = 0; index < normal.length; index++) expect(result[index]).toBe(normal[index])
expect(result.at(-1)).toMatchObject({ id: '100' })
expect(JSON.stringify(result).length * 2).toBeLessThan(256 * 1024)
})
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)
+34 -8
View File
@@ -14,14 +14,14 @@ export function copyChatPreview(value: string, maxChars: number, tail = false):
return new TextDecoder().decode(new TextEncoder().encode(slice))
}
function previewMessage(message: UIMessage) {
const cached = cache.get(message)
function previewMessage(message: UIMessage, maxChars = MESSAGE_MAX_CHARS, maxNodes = 2048) {
const cached = maxChars === MESSAGE_MAX_CHARS && maxNodes === 2048 ? cache.get(message) : undefined
if (cached) return cached
let remaining = MESSAGE_MAX_CHARS
let remaining = maxChars
let nodes = 0
let clipped = false
function visit(value: unknown, depth: number): unknown {
if (++nodes > 2048 || depth > 16) {
if (++nodes > maxNodes || depth > 16) {
clipped = true
return null
}
@@ -39,7 +39,7 @@ function previewMessage(message: UIMessage) {
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 }
if (nodes > maxNodes || 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>
@@ -54,9 +54,11 @@ function previewMessage(message: UIMessage) {
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)
const entry = { message: clipped ? result : message, bytes: 256 + (maxChars - remaining) * 2 + nodes * 32, clipped }
if (maxChars === MESSAGE_MAX_CHARS && maxNodes === 2048) {
cache.set(message, entry)
cache.set(entry.message, entry)
}
return entry
}
@@ -77,6 +79,30 @@ export function boundChatHistory(messages: UIMessage[], budget = CHAT_HISTORY_MA
return { messages: start === 0 && retained.every((message, index) => message === messages[index]) ? messages : retained, bytes, dropped: start, clipped: clipped || start > 0 }
}
// A cursor describes a whole page. Preserve every row identity when tightening
// a page's display budget, otherwise trimmed rows become unreachable by scrolling.
export function previewHistoryPage(messages: UIMessage[], budget: number): UIMessage[] {
if (!messages.length) return messages
const entries = messages.map(message => previewMessage(message))
const result = entries.map(entry => entry.message)
if (entries.reduce((sum, entry) => sum + entry.bytes, 0) > budget) {
// Small messages keep their full content. Share the remaining allowance
// among larger payloads, rather than truncating every row to an equal size.
const order = entries.map((entry, index) => ({ entry, index })).sort((a, b) => a.entry.bytes - b.entry.bytes)
let remaining = budget
for (let index = 0; index < order.length; index++) {
const item = order[index]!
const allowance = Math.max(0, Math.floor(remaining / (order.length - index)))
if (item.entry.bytes <= allowance) { remaining -= item.entry.bytes; continue }
const payload = Math.max(0, allowance - 288)
const entry = previewMessage(item.entry.message, Math.min(MESSAGE_MAX_CHARS, Math.floor(payload / 4)), Math.max(1, Math.min(2048, Math.floor(payload / 64))))
result[item.index] = entry.message
remaining -= entry.bytes
}
}
return result.every((message, index) => message === messages[index]) ? messages : result
}
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 }>()
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { boundHistoryWindow, historyWindowBoundary, historyWindowMessages, type HistoryWindowPage } from './chatHistoryWindow'
function page(index: number, content = 'short message'): HistoryWindowPage {
return {
cursor: `page-${index}`,
page: { nextCursor: `older-${index}`, previousCursor: `newer-${index}`, hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 100, omittedOversizedEntries: 0 },
messages: [{ id: String(index), type: 'assistant_text', timestamp: index, content }],
}
}
describe('continuous history window', () => {
it('keeps adjacent pages and a return cursor when the opposite edge is evicted', () => {
const pages = [0, 1, 2, 3].map(index => page(index))
const older = boundHistoryWindow(pages, 1024 * 1024, 'older')
expect(historyWindowMessages(older).map(message => message.id)).toEqual(['0', '1', '2'])
expect(historyWindowBoundary(older)).toMatchObject({ nextCursor: 'older-0', previousCursor: 'newer-2' })
const newer = boundHistoryWindow(pages, 1024 * 1024, 'newer')
expect(historyWindowMessages(newer).map(message => message.id)).toEqual(['1', '2', '3'])
expect(historyWindowBoundary(newer)).toMatchObject({ nextCursor: 'older-1', previousCursor: 'newer-3' })
expect(boundHistoryWindow(newer, 1024 * 1024, 'newer')).toBe(newer)
})
it('preserves every row identity when large bodies exhaust the display budget', () => {
const pages = [page(0), page(1), page(2)]
for (const [index, entry] of pages.entries()) entry.messages = Array.from({ length: 200 }, (_, row) => ({
id: `${index}-${row}`, type: 'assistant_text', timestamp: row, content: 'x'.repeat(32_000),
}))
const bounded = boundHistoryWindow(pages, 256 * 1024, 'older')
const messages = historyWindowMessages(bounded)
expect(messages).toHaveLength(600)
expect(messages.map(message => message.id)).toEqual(pages.flatMap(entry => entry.messages.map(message => message.id)))
expect(JSON.stringify(messages).length * 2).toBeLessThan(256 * 1024)
expect(pages[0]!.messages[0]).toMatchObject({ content: 'x'.repeat(32_000) })
expect(historyWindowMessages(bounded)).toBe(messages)
})
it('deduplicates overlapping page boundaries without reordering history', () => {
const first = page(0)
const second = page(1)
second.messages.unshift(first.messages[0]!)
expect(historyWindowMessages([first, second]).map(message => message.id)).toEqual(['0', '1'])
expect(historyWindowBoundary([first, second]).historyComplete).toBe(false)
})
})
+56
View File
@@ -0,0 +1,56 @@
import type { SessionHistoryPage } from '../api/sessions'
import type { UIMessage } from '../types/chat'
import { previewHistoryPage } from './chatHistoryBudget'
export type HistoryDirection = 'older' | 'newer'
export type HistoryWindowPage = {
cursor: string | null
page: NonNullable<SessionHistoryPage['page']>
messages: UIMessage[]
}
const MAX_WINDOW_PAGES = 3
const cache = new WeakMap<HistoryWindowPage[], { budget: number; direction: HistoryDirection; pages: HistoryWindowPage[] }>()
export function boundHistoryWindow(pages: HistoryWindowPage[], budget: number, direction: HistoryDirection): HistoryWindowPage[] {
const cached = cache.get(pages)
if (cached?.budget === budget && cached.direction === direction) return cached.pages
const selected = pages.length <= MAX_WINDOW_PAGES ? pages
: direction === 'older' ? pages.slice(0, MAX_WINDOW_PAGES) : pages.slice(-MAX_WINDOW_PAGES)
const perPage = Math.floor(budget / Math.max(1, selected.length))
const result = selected.map(page => {
const messages = previewHistoryPage(page.messages, perPage)
return messages === page.messages ? page : { ...page, messages }
})
const bounded = selected === pages && result.every((page, index) => page === pages[index]) ? pages : result
const memo = { budget, direction, pages: bounded }
cache.set(pages, memo)
cache.set(bounded, memo)
return bounded
}
const flattened = new WeakMap<HistoryWindowPage[], UIMessage[]>()
export function historyWindowMessages(pages: HistoryWindowPage[]): UIMessage[] {
const cached = flattened.get(pages)
if (cached) return cached
const seen = new Set<string>()
const messages: UIMessage[] = []
for (const page of pages) for (const message of page.messages) {
if (seen.has(message.id)) continue
seen.add(message.id)
messages.push(message)
}
flattened.set(pages, messages)
return messages
}
export function historyWindowBoundary(pages: HistoryWindowPage[]): NonNullable<SessionHistoryPage['page']> {
const oldest = pages[0]!.page
const newest = pages[pages.length - 1]!.page
return {
...oldest,
previousCursor: newest.previousCursor ?? null,
// A collection of display pages must never become authoritative recovery.
historyComplete: pages.length === 1 && oldest.historyComplete,
}
}
+140 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentTaskNotification } from '../types/chat'
import type { AgentTaskNotification, UIMessage } from '../types/chat'
import type { MessageEntry } from '../types/session'
import type { SavedProvider } from '../types/provider'
import {
@@ -15038,6 +15038,8 @@ describe('chatStore activity state survival across reload paths', () => {
markCompletedAndDismissedMock.mockReset()
updateTabStatusMock.mockReset()
connectionStateHandlers.clear()
vi.mocked(sessionsApi.getHistoryPage).mockReset()
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValue({ messages: [] })
vi.mocked(sessionsApi.getMessages).mockReset()
vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: [] })
vi.mocked(sessionsApi.getSlashCommands).mockReset()
@@ -15197,6 +15199,143 @@ describe('chatStore activity state survival across reload paths', () => {
expect(setTasksFromTodosMock).not.toHaveBeenCalled()
})
it('restores every initial cursor-page row when live display dropped multi-block rows', async () => {
const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 100, omittedOversizedEntries: 0 }
const content = Array.from({ length: 600 }, (_, index) => ({ type: 'tool_use', id: `tool-${index}`, name: 'Read', input: {} }))
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages: [{ id: 'many', type: 'assistant', content, timestamp: '2020-01-01T00:00:00Z' }], page })
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession() } })
await useChatStore.getState().loadHistory(TEST_SESSION_ID)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]!.messages).toHaveLength(600)
// Simulate a prior live-window eviction; the canonical seed remains complete.
useChatStore.setState(state => ({ sessions: { ...state.sessions, [TEST_SESSION_ID]: { ...state.sessions[TEST_SESSION_ID]!, messages: state.sessions[TEST_SESSION_ID]!.messages.slice(-500) } } }))
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValueOnce({ messages: [], page: { ...page, nextCursor: null, hasMore: false } })
await useChatStore.getState().loadOlderHistory(TEST_SESSION_ID)
const current = useChatStore.getState().sessions[TEST_SESSION_ID]!
expect(current.historyBrowseMessages?.map(message => message.id)).toEqual(Array.from({ length: 600 }, (_, index) => `many-block-${index}`))
expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(1)
})
it('preserves every complete initial-page identity even when there is no older cursor', async () => {
const messages = Array.from({ length: 600 }, (_, index) => ({ id: `row-${index}`, type: 'assistant' as const, content: 'small', timestamp: new Date(index * 1000).toISOString() }))
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages, page: { nextCursor: null, hasMore: false, historyComplete: true, sourceVersion: 'v1', scannedBytes: 100, omittedOversizedEntries: 0 } })
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession() } })
await useChatStore.getState().loadHistory(TEST_SESSION_ID)
const current = useChatStore.getState().sessions[TEST_SESSION_ID]!
expect(current.messages.map(message => message.id)).toEqual(messages.map(message => message.id))
expect(current.historyPage?.nextCursor).toBeNull()
})
it('can repair an evicted live prefix even when the original complete page had no older cursor', async () => {
const page = { nextCursor: null, hasMore: false, historyComplete: true, sourceVersion: 'v1', scannedBytes: 100, omittedOversizedEntries: 0 }
const original: UIMessage = { id: 'original', type: 'assistant_text', content: 'original', timestamp: 0 }
useChatStore.getState().applyBoundedUpdate(() => ({ sessions: { [TEST_SESSION_ID]: makeSession({
messages: [original, ...Array.from({ length: 501 }, (_, index): UIMessage => ({ id: `live-${index}`, type: 'assistant_text', content: `live ${index}`, timestamp: index + 1 }))],
historyPage: page, historyInitialPage: { cursor: null, page, messages: [original] },
}) } }))
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyLiveGap).toBe(true)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyPage?.nextCursor).toBeNull()
vi.mocked(sessionsApi.getHistoryPage)
.mockResolvedValueOnce({ messages: [{ id: 'live-500', type: 'assistant', content: 'live 500', timestamp: new Date(501).toISOString() }], page: { ...page, nextCursor: 'fresh-before', hasMore: true, historyComplete: false } })
.mockResolvedValueOnce({ messages: [{ id: 'live-0', type: 'assistant', content: 'live 0', timestamp: new Date(1).toISOString() }], page })
await useChatStore.getState().loadOlderHistory(TEST_SESSION_ID)
expect(sessionsApi.getHistoryPage).toHaveBeenNthCalledWith(1, TEST_SESSION_ID, undefined, expect.anything())
expect(sessionsApi.getHistoryPage).toHaveBeenNthCalledWith(2, TEST_SESSION_ID, { cursor: 'fresh-before' }, expect.anything())
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyBrowseMessages?.some(message => message.id === 'live-0')).toBe(true)
})
it('keeps the live top anchor as a cursorless overlay until fresh canonical pages reach it', async () => {
const page = (cursor: string | null) => ({ nextCursor: cursor, hasMore: Boolean(cursor), historyComplete: false, sourceVersion: 'fresh', scannedBytes: 100, omittedOversizedEntries: 0 })
const ui = (index: number): UIMessage => ({ id: `m${index}`, type: 'assistant_text', content: `row ${index}`, timestamp: index * 1000 })
const raw = (index: number) => ({ id: `m${index}`, type: 'assistant' as const, content: `row ${index}`, timestamp: new Date(index * 1000).toISOString() })
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({
messages: [2, 3, 4, 5].map(ui), historyPage: page('stale-older'),
historyInitialPage: { cursor: null, page: page('stale-older'), messages: [ui(1)] },
}) } })
vi.mocked(sessionsApi.getHistoryPage)
.mockResolvedValueOnce({ messages: [raw(5)], page: page('before-5') })
.mockResolvedValueOnce({ messages: [raw(4)], page: { ...page('before-4'), previousCursor: 'after-4' } })
await useChatStore.getState().loadOlderHistory(TEST_SESSION_ID)
let current = useChatStore.getState().sessions[TEST_SESSION_ID]!
expect(current.historyBrowseMessages?.map(message => message.id)).toEqual(['m2', 'm3', 'm4', 'm5'])
expect(current.historyPage?.nextCursor).toBe('before-4')
expect(current.historyWindowPages?.flatMap(entry => entry.messages.map(message => message.id))).toEqual(['m4', 'm5'])
expect(current.historyWindowOverlay).toHaveLength(4)
expect(sessionsApi.getHistoryPage).toHaveBeenNthCalledWith(1, TEST_SESSION_ID, undefined, expect.anything())
expect(sessionsApi.getHistoryPage).toHaveBeenNthCalledWith(2, TEST_SESSION_ID, { cursor: 'before-5' }, expect.anything())
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValueOnce({ messages: [raw(2), raw(3)], page: { ...page('before-2'), previousCursor: 'after-3' } })
await useChatStore.getState().loadOlderHistory(TEST_SESSION_ID)
current = useChatStore.getState().sessions[TEST_SESSION_ID]!
expect(current.historyBrowseMessages?.map(message => message.id)).toEqual(['m2', 'm3', 'm4', 'm5'])
expect(current.historyWindowOverlay).toBeUndefined()
})
it('consumes a prefetched page once, keeps adjacent history, and restores evicted newer pages', async () => {
const page = (index: number) => ({ nextCursor: index ? `older-${index}` : null, previousCursor: index < 4 ? `newer-${index}` : null, hasMore: index > 0, historyComplete: false, sourceVersion: 'v1', scannedBytes: 10, omittedOversizedEntries: 0 })
const response = (index: number) => ({ messages: [{ id: `m${index}`, type: 'assistant' as const, content: `page ${index}`, timestamp: '2020-01-01T00:00:00Z' }], page: page(index) })
const live = [{ id: 'm4', type: 'assistant_text' as const, content: 'page 4', timestamp: 1 }]
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ messages: live, historyPage: page(4) }) } })
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValueOnce(response(3))
await useChatStore.getState().prefetchHistory(TEST_SESSION_ID, 'older')
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyBrowseMessages).toBeUndefined()
await useChatStore.getState().loadOlderHistory(TEST_SESSION_ID)
expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(1)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyBrowseMessages?.map(message => 'content' in message ? message.content : undefined)).toEqual(['page 3', 'page 4'])
for (const index of [2, 1, 0]) {
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValueOnce(response(index))
await useChatStore.getState().loadOlderHistory(TEST_SESSION_ID)
}
let current = useChatStore.getState().sessions[TEST_SESSION_ID]!
expect(current.historyWindowPages).toHaveLength(3)
expect(current.historyBrowseMessages?.map(message => 'content' in message ? message.content : undefined)).toEqual(['page 0', 'page 1', 'page 2'])
expect(current.historyPage).toMatchObject({ nextCursor: null, previousCursor: 'newer-2' })
expect(current.messages).toBe(live)
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValueOnce(response(3))
await useChatStore.getState().loadNewerHistory(TEST_SESSION_ID)
current = useChatStore.getState().sessions[TEST_SESSION_ID]!
expect(sessionsApi.getHistoryPage).toHaveBeenLastCalledWith(TEST_SESSION_ID, { cursor: 'newer-2' }, expect.anything())
expect(current.historyBrowseMessages?.map(message => 'content' in message ? message.content : undefined)).toEqual(['page 1', 'page 2', 'page 3'])
expect(current.historyWindowRevision).toBe(5)
})
it('lets return-to-latest cancel an in-flight older page without a late overwrite', async () => {
const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 10, omittedOversizedEntries: 0 }
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ historyPage: page }) } })
let resolveOlder!: (value: Awaited<ReturnType<typeof sessionsApi.getHistoryPage>>) => void
let signal: AbortSignal | undefined
vi.mocked(sessionsApi.getHistoryPage).mockImplementationOnce((_id, _cursor, options) => {
signal = options?.signal
return new Promise(resolve => { resolveOlder = resolve })
})
const older = useChatStore.getState().loadOlderHistory(TEST_SESSION_ID)
vi.mocked(sessionsApi.getHistoryPage).mockResolvedValueOnce({ messages: [{ id: 'latest', type: 'assistant', content: 'latest response', timestamp: '2020-01-01T00:00:00Z' }], page: { ...page, nextCursor: null, hasMore: false, historyComplete: true } })
await useChatStore.getState().loadOlderHistory(TEST_SESSION_ID, true)
expect(signal?.aborted).toBe(true)
resolveOlder({ messages: [{ id: 'stale', type: 'assistant', content: 'stale', timestamp: '2020-01-01T00:00:00Z' }], page })
await older
const current = useChatStore.getState().sessions[TEST_SESSION_ID]!
expect(current.historyBrowseMessages).toBeUndefined()
expect(current.messages.map(message => 'content' in message ? message.content : undefined)).toEqual(['latest response'])
expect(current.historyPageLoading).toBe(false)
expect(current.historyWindowed).toBe(false)
})
it('cancels speculative history on authoritative reload without inserting it', async () => {
const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 10, omittedOversizedEntries: 0 }
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ historyPage: page }) } })
let resolveOlder!: (value: Awaited<ReturnType<typeof sessionsApi.getHistoryPage>>) => void
let signal: AbortSignal | undefined
vi.mocked(sessionsApi.getHistoryPage).mockImplementationOnce((_id, _cursor, options) => {
signal = options?.signal
return new Promise(resolve => { resolveOlder = resolve })
})
const prefetch = useChatStore.getState().prefetchHistory(TEST_SESSION_ID, 'older')
await useChatStore.getState().reloadHistory(TEST_SESSION_ID)
expect(signal?.aborted).toBe(true)
resolveOlder({ messages: [], page })
await prefetch
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.historyBrowseMessages).toBeUndefined()
})
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 }) } })
+225 -32
View File
@@ -1,5 +1,6 @@
import { boundHistoryWindow, historyWindowBoundary, historyWindowMessages, type HistoryDirection, type HistoryWindowPage } from '../lib/chatHistoryWindow'
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 { boundActivityText, boundChatHistory, previewHistoryPage, 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, type SessionHistoryPage } from '../api/sessions'
import { ApiResponseParseError } from '../api/client'
@@ -135,7 +136,14 @@ export type PerSessionState = {
historyError?: string | null
historyPage?: SessionHistoryPage['page']
historyWindowed?: boolean
historyLiveGap?: boolean
historyPageLoading?: boolean
historyPageDirection?: HistoryDirection | 'latest'
historyWindowRevision?: number
historyWindowPages?: HistoryWindowPage[]
historyLivePage?: SessionHistoryPage['page']
historyInitialPage?: HistoryWindowPage
historyWindowOverlay?: UIMessage[]
historyViewingOlder?: boolean
historyBrowseMessages?: UIMessage[]
historyRecoveryStatus?: 'loading' | 'ready' | 'incomplete' | 'error'
@@ -420,6 +428,8 @@ type ChatStore = {
options?: { mode?: 'terminal-reconnect' },
) => Promise<void>
loadOlderHistory: (sessionId: string, latest?: boolean) => Promise<void>
loadNewerHistory: (sessionId: string) => Promise<void>
prefetchHistory: (sessionId: string, direction: HistoryDirection) => Promise<void>
reloadHistory: (
sessionId: string,
guard?: {
@@ -2336,6 +2346,36 @@ type TerminalReconnectHistoryBoundary = {
const historyLoadsInFlight = new Map<string, HistoryLoadInFlight>()
const historyRecoveryControllers = new Map<string, AbortController>()
const historyPageControllers = new Map<string, AbortController>()
type PreparedHistoryPage = { messages: UIMessage[]; page: SessionHistoryPage['page'] }
const historyPrefetches = new Map<string, { cursor: string | null; controller: AbortController; promise: Promise<PreparedHistoryPage> }>()
function invalidateHistoryPrefetch(sessionId: string) {
historyPrefetches.get(sessionId)?.controller.abort()
historyPrefetches.delete(sessionId)
}
async function prepareHistoryPage(sessionId: string, cursor: string | null, signal: AbortSignal): Promise<PreparedHistoryPage> {
const response = await sessionsApi.getHistoryPage(sessionId, cursor ? { cursor } : undefined, { signal })
const messages = mapHistoryMessagesToUiMessages(response.messages)
// Keep speculative work small; retain all row identities within a cursor page.
return { messages: previewHistoryPage(messages, 256 * 1024), page: response.page }
}
async function requestHistoryPage(sessionId: string, cursor: string | null, signal: AbortSignal): Promise<PreparedHistoryPage> {
const prefetched = historyPrefetches.get(sessionId)
if (prefetched?.cursor === cursor && !prefetched.controller.signal.aborted) {
const abort = () => prefetched.controller.abort()
signal.addEventListener('abort', abort, { once: true })
try { return await prefetched.promise }
finally {
signal.removeEventListener('abort', abort)
if (historyPrefetches.get(sessionId) === prefetched) historyPrefetches.delete(sessionId)
}
}
invalidateHistoryPrefetch(sessionId)
return prepareHistoryPage(sessionId, cursor, signal)
}
const historyReloadControllers = new Map<string, AbortController>()
const historyReloadGenerations = new Map<string, number>()
const historyReloadCompletionGenerations = new Map<string, number>()
@@ -2352,6 +2392,7 @@ function currentHistoryLifecycle(sessionId: string): number {
function advanceHistoryLifecycle(sessionId: string): number {
const nextGeneration = currentHistoryLifecycle(sessionId) + 1
historyLifecycleGenerations.set(sessionId, nextGeneration)
invalidateHistoryPrefetch(sessionId)
historyLoadsInFlight.get(sessionId)?.controller.abort()
historyLoadsInFlight.delete(sessionId)
historyReloadControllers.get(sessionId)?.abort()
@@ -2701,6 +2742,107 @@ function activeGoalAfterHistoryLoad(
return session.activeGoal ?? null
}
const overlayWindowCache = new WeakMap<HistoryWindowPage[], WeakMap<UIMessage[], UIMessage[]>>()
function messagesWithHistoryOverlay(pages: HistoryWindowPage[], overlay?: UIMessage[]): UIMessage[] {
const canonical = historyWindowMessages(pages)
if (!overlay?.length) return canonical
const cache = overlayWindowCache.get(pages) ?? new WeakMap<UIMessage[], UIMessage[]>()
overlayWindowCache.set(pages, cache)
const existing = cache.get(overlay)
if (existing) return existing
const indexes = new Map<string, number>()
canonical.forEach((message, index) => strongHistoryMessageIdentities(message).forEach(identity => indexes.set(identity, index)))
const buckets = new Map<number, UIMessage[]>()
const matched = new Map<number, UIMessage>()
let before = canonical.length
for (let index = overlay.length - 1; index >= 0; index--) {
const message = overlay[index]!
const target = strongHistoryMessageIdentities(message).map(identity => indexes.get(identity)).find(value => value !== undefined)
if (target !== undefined) { before = target; matched.set(target, message); continue }
// A disconnected live prefix has no shared row yet. Keep it visible until
// canonical paging reaches it; it never supplies a backend cursor.
if (before === canonical.length && message.timestamp < (canonical[0]?.timestamp ?? -Infinity)) before = 0
const bucket = buckets.get(before) ?? []
bucket.unshift(message)
buckets.set(before, bucket)
}
const result: UIMessage[] = []
canonical.forEach((message, index) => { result.push(...(buckets.get(index) ?? []), matched.get(index) ?? message) })
result.push(...(buckets.get(canonical.length) ?? []))
cache.set(overlay, result)
return result
}
async function changeHistoryWindow(
sessionId: string,
direction: HistoryDirection | 'latest',
get: () => ChatStore,
set: (update: (state: ChatStore) => Partial<ChatStore>) => void,
) {
const session = get().sessions[sessionId]
let cursor = direction === 'latest' ? null : direction === 'older' ? session?.historyPage?.nextCursor : session?.historyPage?.previousCursor
if (!session || (direction !== 'latest' && ((!cursor && !(direction === 'older' && session.historyLiveGap)) || session.historyPageLoading))) return
if (direction === 'latest') invalidateHistoryPrefetch(sessionId)
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, historyPageDirection: direction, historyError: null })) }))
try {
let seed = session.historyInitialPage
let overlay: UIMessage[] | undefined
if (direction === 'older' && !session.historyWindowPages && (seed || session.historyLiveGap)) {
const identities = new Set(seed?.messages.flatMap(strongHistoryMessageIdentities) ?? [])
const liveChanged = session.historyLiveGap || session.messages.some(message => !strongHistoryMessageIdentities(message).some(identity => identities.has(identity)))
if (liveChanged) {
invalidateHistoryPrefetch(sessionId)
const fresh = await requestHistoryPage(sessionId, null, controller.signal)
if (controller.signal.aborted || !isCurrentHistoryLifecycle(sessionId, lifecycle)) return
seed = fresh.page ? { cursor: null, page: fresh.page, messages: fresh.messages } : undefined
overlay = session.messages
cursor = fresh.page?.nextCursor ?? null
}
}
const result = direction === 'older' && seed && !cursor
? { messages: [] as UIMessage[], page: undefined }
: await requestHistoryPage(sessionId, cursor ?? null, controller.signal)
if (controller.signal.aborted || !isCurrentHistoryLifecycle(sessionId, lifecycle)) return
set(state => ({ sessions: updateSessionIn(state.sessions, sessionId, current => {
if (direction === 'latest' || (!result.page && !seed)) return {
messages: mergeColdRestoredHistoryIntoLiveMessages(result.messages, current.messages),
historyBrowseMessages: undefined, historyWindowPages: undefined, historyLivePage: undefined, historyWindowOverlay: undefined,
historyInitialPage: result.page ? { cursor: null, page: result.page, messages: result.messages } : undefined,
historyPage: result.page, historyViewingOlder: false, historyLiveGap: false,
historyWindowed: Boolean(result.page && !result.page.historyComplete),
historyWindowRevision: (current.historyWindowRevision ?? 0) + 1,
}
const previous = current.historyWindowPages ?? (seed ? [seed] : current.historyPage ? [{
cursor: null, page: current.historyPage, messages: current.historyBrowseMessages ?? current.messages,
}] : [])
const incoming = result.page ? { cursor: cursor ?? null, page: result.page, messages: result.messages } : undefined
const pages = !incoming ? previous : direction === 'older' ? [incoming, ...previous] : [...previous, incoming]
return {
historyWindowPages: pages,
historyLiveGap: false,
historyWindowOverlay: overlay ?? current.historyWindowOverlay,
historyLivePage: current.historyLivePage ?? current.historyPage,
historyBrowseMessages: messagesWithHistoryOverlay(pages, overlay ?? current.historyWindowOverlay),
historyPage: historyWindowBoundary(pages), historyViewingOlder: true, historyWindowed: true,
historyWindowRevision: (current.historyWindowRevision ?? 0) + 1,
}
}) }))
if (direction === '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 })) }))
}
}
}
function shouldPrewarmSession(sessionId: string): boolean {
const knownSession = useSessionStore.getState().sessions.find((session) => session.id === sessionId)
return knownSession?.messageCount === 0
@@ -2722,16 +2864,40 @@ export const useChatStore = create<ChatStore>((setState, get) => {
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 liveBudget = session.historyBrowseMessages ? Math.floor(displayBudget / 4) : session.historyInitialPage ? Math.floor(displayBudget * 3 / 4) : displayBudget
const initialIdentities = session.historyInitialPage
? new Set(session.historyInitialPage.messages.flatMap(strongHistoryMessageIdentities)) : undefined
const isCompleteInitialPage = initialIdentities && session.messages.every(message => strongHistoryMessageIdentities(message).some(identity => initialIdentities.has(identity)))
const bounded = isCompleteInitialPage
? { messages: previewHistoryPage(session.messages, liveBudget), dropped: 0 }
: boundChatHistory(session.messages, liveBudget)
const pages = session.historyWindowPages
? boundHistoryWindow(session.historyWindowPages, Math.floor(displayBudget / 2), session.historyPageDirection === 'newer' ? 'newer' : 'older')
: undefined
let overlay = session.historyWindowOverlay
if (overlay?.length && pages?.length) {
const canonical = historyWindowMessages(pages)
const identities = new Set(canonical.map(message => message.id))
if (overlay.every(message => identities.has(message.id)) ||
(session.historyPageDirection === 'older' && (canonical[canonical.length - 1]?.timestamp ?? Infinity) < overlay[0]!.timestamp)) overlay = undefined
else overlay = previewHistoryPage(overlay, Math.floor(displayBudget / 8))
}
const initialPage = session.historyInitialPage
? { ...session.historyInitialPage, messages: previewHistoryPage(session.historyInitialPage.messages, Math.floor(displayBudget / (session.historyBrowseMessages ? 8 : 4))) }
: undefined
const browse = pages ? messagesWithHistoryOverlay(pages, overlay)
: 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 (initialPage?.messages === session.historyInitialPage?.messages && overlay === session.historyWindowOverlay && bounded.messages === session.messages && browse === session.historyBrowseMessages && pages === session.historyWindowPages && 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,
...session, messages: bounded.messages, historyBrowseMessages: browse, historyWindowPages: pages, historyWindowOverlay: overlay, historyInitialPage: initialPage,
...(pages ? { historyPage: historyWindowBoundary(pages) } : {}),
streamingText: text, streamingToolInput: input,
backgroundAgentTasks: tasks, agentTaskNotifications: notifications,
historyWindowed: true,
historyLiveGap: session.historyLiveGap || bounded.dropped > 0,
...(bounded.dropped && session.streamAttemptStartIndex !== undefined
? { streamAttemptStartIndex: Math.max(0, session.streamAttemptStartIndex - bounded.dropped) } : {}),
}
@@ -3085,6 +3251,10 @@ export const useChatStore = create<ChatStore>((setState, get) => {
updateOptimisticSessionTitle(sessionId, userFacingContent || content.trim())
}
// An explicit send returns to the live conversation; background events do not.
invalidateHistoryPrefetch(sessionId)
historyPageControllers.get(sessionId)?.abort()
historyPageControllers.delete(sessionId)
set((s) => {
const session = s.sessions[sessionId] ?? createDefaultSessionState()
const bufferedDelta = consumePendingDelta(sessionId)
@@ -3126,6 +3296,13 @@ export const useChatStore = create<ChatStore>((setState, get) => {
[sessionId]: {
...session,
messages: newMessages,
historyBrowseMessages: undefined,
historyWindowPages: undefined,
historyWindowOverlay: undefined,
historyPage: session.historyLivePage ?? session.historyPage,
historyLivePage: undefined,
historyViewingOlder: false,
historyPageLoading: false,
chatState: 'thinking',
isPreparingTurn: false,
historyMutationEpoch: (session.historyMutationEpoch ?? 0) + 1,
@@ -3364,6 +3541,7 @@ export const useChatStore = create<ChatStore>((setState, get) => {
},
loadHistory: async (sessionId, options) => {
invalidateHistoryPrefetch(sessionId)
if (historyPageControllers.has(sessionId)) {
historyPageControllers.get(sessionId)?.abort()
historyPageControllers.delete(sessionId)
@@ -3621,8 +3799,13 @@ export const useChatStore = create<ChatStore>((setState, get) => {
return {
historyStatus: 'ready',
historyPage: page,
historyInitialPage: page ? { cursor: null, page, messages: previewHistoryPage(uiMessages, 256 * 1024) } : undefined,
historyLiveGap: false,
historyViewingOlder: false,
historyBrowseMessages: undefined,
historyWindowPages: undefined,
historyWindowOverlay: undefined,
historyLivePage: undefined,
historyWindowed: !historyComplete,
historyRecoveryStatus: historyComplete ? 'ready' : 'loading',
historyHydrated: true,
@@ -3711,8 +3894,13 @@ export const useChatStore = create<ChatStore>((setState, get) => {
return {
historyStatus: 'ready',
historyPage: page,
historyInitialPage: page ? { cursor: null, page, messages: previewHistoryPage(uiMessages, 256 * 1024) } : undefined,
historyLiveGap: false,
historyViewingOlder: false,
historyBrowseMessages: undefined,
historyWindowPages: undefined,
historyWindowOverlay: undefined,
historyLivePage: undefined,
historyWindowed: !historyComplete,
historyRecoveryStatus: historyComplete ? 'ready' : 'loading',
historyHydrated: true,
@@ -3834,39 +4022,34 @@ export const useChatStore = create<ChatStore>((setState, get) => {
return load
},
loadOlderHistory: async (sessionId, latest = false) => {
prefetchHistory: async (sessionId, direction) => {
const session = get().sessions[sessionId]
const cursor = session?.historyPage?.nextCursor
if (!session || (!latest && !cursor) || session.historyPageLoading) return
const lifecycle = currentHistoryLifecycle(sessionId)
const cursor = direction === 'older' ? session?.historyPage?.nextCursor : session?.historyPage?.previousCursor
if (!cursor || session?.historyPageLoading || historyPrefetches.get(sessionId)?.cursor === cursor) return
invalidateHistoryPrefetch(sessionId)
while (historyPrefetches.size >= 4) invalidateHistoryPrefetch(historyPrefetches.keys().next().value!)
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 })) }))
}
const pending = { cursor, controller, promise: prepareHistoryPage(sessionId, cursor, controller.signal) }
historyPrefetches.set(sessionId, pending)
try { await pending.promise }
catch {
// Speculative failure is retried only when this page is actually requested.
if (historyPrefetches.get(sessionId) === pending) historyPrefetches.delete(sessionId)
}
},
loadOlderHistory: async (sessionId, latest = false) => {
await changeHistoryWindow(sessionId, latest ? 'latest' : 'older', get, set)
},
loadNewerHistory: async (sessionId) => {
const current = get().sessions[sessionId]
if (!current?.historyBrowseMessages) return
await changeHistoryWindow(sessionId, current.historyPage?.previousCursor ? 'newer' : 'latest', get, set)
},
reloadHistory: async (sessionId, guard) => {
invalidateHistoryPrefetch(sessionId)
if (historyPageControllers.has(sessionId)) {
historyPageControllers.get(sessionId)?.abort()
historyPageControllers.delete(sessionId)
@@ -3989,8 +4172,13 @@ export const useChatStore = create<ChatStore>((setState, get) => {
sessions: updateSessionIn(state.sessions, sessionId, () => ({
historyStatus: 'ready',
historyPage: page,
historyInitialPage: page ? { cursor: null, page, messages: previewHistoryPage(uiMessages, 256 * 1024) } : undefined,
historyLiveGap: false,
historyViewingOlder: false,
historyBrowseMessages: undefined,
historyWindowPages: undefined,
historyWindowOverlay: undefined,
historyLivePage: undefined,
historyWindowed: !historyComplete,
historyRecoveryStatus: historyComplete ? 'ready' : 'loading',
historyHydrated: true,
@@ -5524,8 +5712,13 @@ export const useChatStore = create<ChatStore>((setState, get) => {
historyMutationEpoch: (session?.historyMutationEpoch ?? 0) + 1,
historyStatus: 'ready',
historyPage: undefined,
historyInitialPage: undefined,
historyLiveGap: false,
historyViewingOlder: false,
historyBrowseMessages: undefined,
historyWindowPages: undefined,
historyWindowOverlay: undefined,
historyLivePage: undefined,
historyWindowed: false,
historyHydrated: true,
historyError: null,
@@ -2,7 +2,7 @@ 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'
import { readBoundedHistoryPage, streamBoundedHistory, withHistoryReadBudget, HISTORY_SCAN_BYTES, HISTORY_RECORD_BYTES, HISTORY_PAGE_BYTES, HISTORY_PAGE_ROWS } from './boundedSessionHistory.js'
let directory: string
let file: string
@@ -32,7 +32,7 @@ describe('bounded history pages', () => {
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))
for (let index = 0; index < 36; index++) await handle.write('x'.repeat(1024 * 1024))
await handle.write('"}\n' + row('after'))
await handle.close()
let cursor: string | undefined
@@ -52,6 +52,102 @@ describe('bounded history pages', () => {
expect(omissions).toBe(1)
})
test('moves to the immediately newer window without reading appended records outside the snapshot', async () => {
await writeFile(file, Array.from({ length: 10 }, (_, index) => row(String(index))).join(''))
const latest = await readBoundedHistoryPage(file, { limit: 3 })
expect(latest.entries.map(item => item.entry.uuid)).toEqual(['7', '8', '9'])
expect(latest.page.previousCursor).toBeNull()
const older = await readBoundedHistoryPage(file, { limit: 3, cursor: latest.page.nextCursor! })
expect(older.entries.map(item => item.entry.uuid)).toEqual(['4', '5', '6'])
await appendFile(file, row('10'))
const newer = await readBoundedHistoryPage(file, { limit: 3, cursor: older.page.previousCursor! })
expect(newer.entries.map(item => item.entry.uuid)).toEqual(['7', '8', '9'])
expect(newer.page.previousCursor).toBeNull()
expect(newer.page.sourceVersion).toBe(latest.page.sourceVersion)
const earlier = await readBoundedHistoryPage(file, { cursor: older.page.nextCursor! })
expect(earlier.entries.map(item => item.entry.uuid)).toEqual(['0', '1', '2', '3'])
expect(earlier.page.historyComplete).toBe(false)
})
test('reads small pages on demand and preserves a large tool result as a bounded identified preview', async () => {
const huge = JSON.stringify({ type: 'user', uuid: 'large-result', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'bash-1', is_error: false, content: 'x'.repeat(4 * 1024 * 1024) }] } }) + '\n'
await writeFile(file, huge + Array.from({ length: 158 }, (_, index) => row(String(index))).join(''))
const tiny = await readBoundedHistoryPage(file, { limit: 3 })
expect(tiny.page.scannedBytes).toBeLessThanOrEqual(96 * 1024)
const all = await readBoundedHistoryPage(file)
expect(all.entries).toHaveLength(159)
expect(all.page.nextCursor).toBeNull()
expect(all.page.omittedOversizedEntries).toBe(0)
expect(all.page.contentTruncated).toBe(true)
expect(all.page.historyComplete).toBe(false)
expect(all.entries[0]!.entry).toMatchObject({ uuid: 'large-result', bodyTruncated: true, message: { content: [{ type: 'tool_result', tool_use_id: 'bash-1', is_error: false }] } })
expect(Buffer.byteLength(JSON.stringify(all.entries.map(item => item.entry)))).toBeLessThan(HISTORY_PAGE_BYTES)
})
test('forward continuation crosses giant records and 64KiB boundaries without losing adjacent messages', async () => {
await writeFile(file, row('before'))
const handle = await open(file, 'a')
await handle.write('{"message":"')
for (let index = 0; index < 36; index++) await handle.write('x'.repeat(1024 * 1024))
await handle.write('"}\n' + row('after', 'z'.repeat(70 * 1024)) + row('last'))
await handle.close()
let backward = await readBoundedHistoryPage(file, { limit: 1 })
while (backward.page.nextCursor) backward = await readBoundedHistoryPage(file, { cursor: backward.page.nextCursor!, limit: 1 })
expect(backward.entries.map(item => item.entry.uuid)).toEqual(['before'])
const ids: unknown[] = []
let cursor = backward.page.previousCursor
let count = 0
while (cursor) {
const page = await readBoundedHistoryPage(file, { cursor, limit: 1 })
ids.push(...page.entries.map(item => item.entry.uuid))
expect(page.page.scannedBytes).toBeLessThanOrEqual(HISTORY_SCAN_BYTES)
cursor = page.page.previousCursor
expect(++count).toBeLessThan(8)
}
expect(ids).toEqual(['after', 'last'])
})
test('preserves tool identities after earlier content exhausts the display text budget', async () => {
await writeFile(file, JSON.stringify({ type: 'assistant', uuid: 'many-tools', message: { role: 'assistant', content: [
...Array.from({ length: 4 }, () => ({ type: 'text', text: 'x'.repeat(32 * 1024) })),
{ type: 'tool_use', id: 'last-tool', name: 'Bash', input: { command: 'echo okay' } },
] } }) + '\n')
const result = await readBoundedHistoryPage(file)
const content = (result.entries[0]!.entry.message as { content: unknown[] }).content
expect(content.at(-1)).toMatchObject({ type: 'tool_use', id: 'last-tool', name: 'Bash' })
expect(result.page.contentTruncated).toBe(true)
})
test('rejects an in-place rewrite that grows instead of mixing replacement records into the old snapshot', async () => {
await writeFile(file, Array.from({ length: 10 }, (_, index) => row(`old-${index}`)).join(''))
const first = await readBoundedHistoryPage(file, { limit: 3 })
await writeFile(file, Array.from({ length: 20 }, (_, index) => row(`replacement-${index}`)).join(''))
await expect(readBoundedHistoryPage(file, { cursor: first.page.nextCursor!, limit: 3 })).rejects.toMatchObject({ statusCode: 409, code: 'HISTORY_CHANGED' })
})
test('checks the continuation boundary even when a growing rewrite preserves source prefix and old EOF', async () => {
await writeFile(file, Array.from({ length: 500 }, (_, index) => row(String(index), 'x'.repeat(100))).join(''))
const first = await readBoundedHistoryPage(file)
const decoded = JSON.parse(Buffer.from(first.page.nextCursor!, 'base64url').toString('utf8'))
const handle = await open(file, 'r+')
await handle.write(Buffer.from('Y'), 0, 1, decoded.offset - 10)
await handle.close()
await appendFile(file, row('new'))
await expect(readBoundedHistoryPage(file, { cursor: first.page.nextCursor! })).rejects.toMatchObject({ statusCode: 409 })
})
test('keeps multi-block records intact while bounding every page to 500 renderable rows', async () => {
await writeFile(file, Array.from({ length: 200 }, (_, index) => JSON.stringify({ type: 'assistant', uuid: String(index), message: { role: 'assistant', content: Array.from({ length: 3 }, (_, block) => ({ type: 'tool_use', id: `${index}-${block}`, name: 'Bash', input: {} })) } }) + '\n').join(''))
const first = await readBoundedHistoryPage(file)
expect(first.entries).toHaveLength(166)
expect(first.entries.reduce((sum, item) => sum + (item.entry.message as { content: unknown[] }).content.length, 0)).toBeLessThanOrEqual(HISTORY_PAGE_ROWS)
const older = await readBoundedHistoryPage(file, { cursor: first.page.nextCursor! })
expect(older.entries).toHaveLength(34)
expect([...older.entries, ...first.entries].map(item => item.entry.uuid)).toEqual(Array.from({ length: 200 }, (_, index) => String(index)))
const newer = await readBoundedHistoryPage(file, { cursor: older.page.previousCursor! })
expect(newer.entries.map(item => item.entry.uuid)).toEqual(first.entries.map(item => item.entry.uuid))
})
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 })
+175 -62
View File
@@ -1,15 +1,20 @@
import { createHash } from 'node:crypto'
import { open } from 'node:fs/promises'
import { ApiError } from '../middleware/errorHandler.js'
export const HISTORY_SCAN_BYTES = 4 * 1024 * 1024
export const HISTORY_SCAN_BYTES = 16 * 1024 * 1024
export const HISTORY_SEMANTIC_RECORD_BYTES = 8 * 1024 * 1024
export const HISTORY_RECORD_BYTES = 1024 * 1024
export const HISTORY_PAGE_BYTES = 1536 * 1024
export const HISTORY_PAGE_RECORDS = 100
export const HISTORY_PAGE_BYTES = 256 * 1024
export const HISTORY_PAGE_RECORDS = 200
export const HISTORY_PAGE_ROWS = 500
type Cursor = { version: 1; dev: string; ino: string; size: number; mtime: string; offset: number; skipping: boolean }
type Cursor = { version: 1; dev: string; ino: string; size: number; mtime: string; offset: number; skipping: boolean; direction?: 'older' | 'newer'; fingerprints?: { prefix: string; tail: string; boundary: string } }
export type HistoryPageInfo = {
previousCursor?: string | null
nextCursor: string | null
hasMore: boolean
contentTruncated?: boolean
historyComplete: boolean
sourceVersion: string
scannedBytes: number
@@ -66,11 +71,45 @@ function decodeCursor(value: string): Cursor {
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')
!Number.isSafeInteger(cursor.size) || cursor.size < 0 || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0 || cursor.offset > cursor.size || typeof cursor.skipping !== 'boolean' || (cursor.direction !== undefined && cursor.direction !== 'older' && cursor.direction !== 'newer')) throw new Error('invalid cursor')
if (cursor.fingerprints && !['prefix', 'tail', 'boundary'].every(key => /^[a-f0-9]{64}$/.test((cursor.fingerprints as Record<string, string>)[key] ?? ''))) throw new Error('invalid cursor fingerprints')
return cursor
} catch { throw ApiError.badRequest('Invalid history cursor') }
}
/** Produce a display preview without dropping a message's identity. Durable
* replay and semantic state reducers always receive the original record. */
function displayPreview(entry: Record<string, unknown>): Record<string, unknown> {
let truncated = false
let remaining = 48 * 1024
let nodes = 2048
const preview = (value: unknown, depth: number): unknown => {
if (typeof value === 'string') {
const limit = Math.max(0, Math.min(16 * 1024, remaining))
remaining -= Math.min(value.length, limit)
if (value.length > limit) { truncated = true; return value.slice(0, limit) + '\n… [truncated preview]' }
return value
}
if (!value || typeof value !== 'object') return value
if (--nodes < 0) { truncated = true; return '[truncated preview]' }
if (depth > 12) { truncated = true; return '[truncated preview]' }
if (Array.isArray(value)) {
if (value.length > 256) truncated = true
return value.slice(0, 256).map(item => preview(item, depth + 1))
}
const pairs = Object.entries(value)
if (pairs.length > 256) truncated = true
return Object.fromEntries(pairs.slice(0, 256).map(([key, child]) => [key,
['id', 'type', 'role', 'name', 'tool_use_id', 'agentId', 'backgroundTaskId', 'background_task_id'].includes(key) && typeof child === 'string' && child.length <= 4096
? child : preview(child, depth + 1)]))
}
// Structural ids and usage are independent of the potentially huge body.
const message = entry.message as Record<string, unknown> | undefined
const result = { ...entry, ...(entry.content !== undefined ? { content: preview(entry.content, 0) } : {}), ...(message ? { message: { ...message, content: preview(message.content, 0) } } : {}),
...(entry.toolUseResult !== undefined ? { toolUseResult: preview(entry.toolUseResult, 0) } : {}) }
return truncated ? { ...result, bodyTruncated: true } : result
}
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 () => {
@@ -80,79 +119,153 @@ export async function readBoundedHistoryPage(filePath: string, options: { cursor
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')
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')
let scannedBytes = 0
// Hash fixed-size anchors, never the whole transcript. Anchors include the
// original EOF and the requested boundary so truncate/regrow cannot be
// silently accepted merely because inode is unchanged and size increased.
const fingerprint = async (start: number): Promise<string> => {
const bytes = Buffer.alloc(Math.min(4096, cursor.size - start))
let read = 0
while (read < bytes.length) {
aborted(options.signal)
const part = await handle.read(bytes, read, bytes.length - read, start + read)
if (!part.bytesRead) throw new ApiError(409, 'Session history changed during validation', 'HISTORY_CHANGED')
read += part.bytesRead
}
scannedBytes += read
return createHash('sha256').update(bytes).digest('hex')
}
const sourceFingerprints = async () => ({ prefix: await fingerprint(0), tail: await fingerprint(Math.max(0, cursor.size - 4096)) })
const boundaryFingerprint = (offset: number) => fingerprint(Math.max(0, Math.min(offset - 2048, cursor.size - 4096)))
if (!cursor.fingerprints && options.cursor && current.size > cursor.size) throw new ApiError(409, 'Legacy history cursor cannot validate append; reload the newest page', 'HISTORY_CHANGED')
const sourceAnchors = await sourceFingerprints()
if (cursor.fingerprints && (cursor.fingerprints.prefix !== sourceAnchors.prefix || cursor.fingerprints.tail !== sourceAnchors.tail || cursor.fingerprints.boundary !== await boundaryFingerprint(cursor.offset))) throw new ApiError(409, 'Session history was rewritten; reload the newest page', 'HISTORY_CHANGED')
const newer = cursor.direction === 'newer'
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 position = cursor.offset
let buffer = Buffer.alloc(0)
let bufferStart = -1
let bufferEnd = -1
let skipping = cursor.skipping
let omitted = 0
while (position > 0 && entries.length < limit) {
let outputBytes = 0
let renderedRows = 0
const entries: BoundedHistoryEntry[] = []
const limit = Math.max(1, Math.min(HISTORY_PAGE_RECORDS, Math.floor(options.limit ?? HISTORY_PAGE_RECORDS)))
const load = async (): Promise<boolean> => {
// Reserve enough I/O for post-read source anchors and both outgoing
// boundary hashes; validation is part of the same request byte budget.
const capacity = Math.min(64 * 1024, HISTORY_SCAN_BYTES - 64 * 1024 - scannedBytes)
if (capacity <= 0) return false
const start = newer ? position : Math.max(0, position - capacity)
const end = newer ? Math.min(cursor.size, position + capacity) : position
if (end <= start) return false
buffer = Buffer.allocUnsafe(end - start)
let read = 0
while (read < buffer.length) {
aborted(options.signal)
const part = await handle.read(buffer, read, buffer.length - read, start + read)
if (!part.bytesRead) throw new ApiError(409, 'History changed during read', 'HISTORY_CHANGED')
read += part.bytesRead
}
scannedBytes += read
bufferStart = start; bufferEnd = end
return true
}
while ((newer ? position < cursor.size : 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
const boundary = position
let parts: Buffer[] = []
let bytes = 0
let complete = false
let oversized = skipping
let end = position
let start = position
let first = true
while (!complete) {
if ((!buffer.length || (newer ? position >= bufferEnd : position <= bufferStart)) && !await load()) break
if (newer) {
const local = position - bufferStart
const newline = buffer.indexOf(10, local)
const stop = newline < 0 ? buffer.length : newline
const part = buffer.subarray(local, stop)
bytes += part.length
if (!oversized && bytes <= HISTORY_SEMANTIC_RECORD_BYTES) parts.push(part)
else if (!oversized) { oversized = true; parts = [] }
position = bufferStart + stop + (newline < 0 ? 0 : 1)
end = bufferStart + stop
complete = newline >= 0 || position === cursor.size
} else {
let local = position - bufferStart
if (first && local > 0 && buffer[local - 1] === 10) { local--; position--; end-- }
const newline = local > 0 ? buffer.lastIndexOf(10, local - 1) : -1
const stop = newline + 1
const part = buffer.subarray(stop, local)
bytes += part.length
if (!oversized && bytes <= HISTORY_SEMANTIC_RECORD_BYTES) parts.push(part)
else if (!oversized) { oversized = true; parts = [] }
position = bufferStart + stop
start = position
complete = newline >= 0 || position === 0
}
first = false
}
if (!complete) {
if (oversized) { if (!skipping) omitted++; skipping = true }
else position = boundary
break
}
if (skipping) {
skipping = false
position = lineStart
continue
if (oversized) { if (!skipping) omitted++; skipping = false; continue }
if (!bytes) continue
const raw = parts.length === 1 ? parts[0]! : Buffer.concat(newer ? parts : parts.reverse(), bytes)
let entry: Record<string, unknown>
try { entry = JSON.parse(raw.toString('utf8')) } catch { omitted++; continue }
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
entry = displayPreview(entry)
let previewBytes = Buffer.byteLength(JSON.stringify(entry))
if (previewBytes > HISTORY_PAGE_BYTES) {
// Even an unusually broad structured body keeps its transcript row.
// The scalar envelope is enough to display an honest preview marker.
const message = entry.message as Record<string, unknown> | undefined
entry = { type: entry.type, uuid: entry.uuid, timestamp: entry.timestamp, parentUuid: entry.parentUuid,
parent_tool_use_id: entry.parent_tool_use_id, isSidechain: entry.isSidechain, bodyTruncated: true,
...(message ? { message: { role: message.role, content: '[Message body exceeds preview budget]' } } : { content: '[Message body exceeds preview budget]' }) }
previewBytes = Buffer.byteLength(JSON.stringify(entry))
if (previewBytes > HISTORY_PAGE_BYTES) { omitted++; 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.
const content = (entry.message as { content?: unknown } | undefined)?.content
// Each assistant block/tool result may become a separate UI row. Stop
// before the complete record instead of clipping rows behind a cursor.
const rowCost = Array.isArray(content) ? Math.max(1, content.length) : 1
if ((outputBytes + previewBytes > HISTORY_PAGE_BYTES || renderedRows + rowCost > HISTORY_PAGE_ROWS) && entries.length) { position = boundary; break }
renderedRows += rowCost
entries.push({ entry, byteStart: newer ? boundary : start, byteEnd: end })
outputBytes += previewBytes
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 },
}
const lower = newer ? cursor.offset : position
const upper = newer ? position : cursor.offset
const afterAnchors = await sourceFingerprints()
if (sourceAnchors.prefix !== afterAnchors.prefix || sourceAnchors.tail !== afterAnchors.tail) throw new ApiError(409, 'Session history was rewritten during read', 'HISTORY_CHANGED')
const encode = async (offset: number, direction: 'older' | 'newer', continuation = false) => Buffer.from(JSON.stringify({ ...cursor, offset, direction, skipping: continuation, fingerprints: { ...sourceAnchors, boundary: await boundaryFingerprint(offset) } })).toString('base64url')
const nextCursor = lower > 0 ? await encode(lower, 'older', !newer && skipping) : null
const previousCursor = upper < cursor.size ? await encode(upper, 'newer', newer && skipping) : null
const contentTruncated = entries.some(item => item.entry.bodyTruncated === true)
return { entries: newer ? entries : entries.reverse(), page: {
nextCursor, previousCursor,
hasMore: lower > 0, historyComplete: lower === 0 && upper === cursor.size && omitted === 0 && !contentTruncated,
...(contentTruncated ? { contentTruncated: true } : {}),
sourceVersion, scannedBytes, 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 }> {
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; maxRecordBytes?: number } = {}): 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 })
@@ -191,7 +304,7 @@ export async function streamBoundedHistory(filePath: string, onEntry: (entry: Re
const end = found >= 0 && found < bytesRead ? found : bytesRead
if (!skipping) {
length += end - start
if (length > HISTORY_RECORD_BYTES) { skipping = true; parts = [] }
if (length > (options.maxRecordBytes ?? HISTORY_RECORD_BYTES)) { skipping = true; parts = [] }
else parts.push(Buffer.from(chunk.subarray(start, end)))
}
start = end + 1
@@ -0,0 +1,67 @@
import { afterEach, expect, test } from 'bun:test'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { withSearchProjectionBudget } from './searchContentCommitWorker.js'
const directories: string[] = []
afterEach(async () => { await Promise.all(directories.splice(0).map(path => rm(path, { recursive: true, force: true }))) })
test('bounds active projections and pending work and removes cancelled waiters', async () => {
let release!: () => void
const active = withSearchProjectionBudget(undefined, () => new Promise<void>(resolve => { release = resolve }))
const controller = new AbortController()
const cancelled = withSearchProjectionBudget(controller.signal, async () => 'unexpected').catch(error => error)
const queued = Array.from({ length: 7 }, () => withSearchProjectionBudget(undefined, async () => 'ok'))
await expect(withSearchProjectionBudget(undefined, async () => 'overflow')).rejects.toThrow('SEARCH_CONTENT_BUSY')
controller.abort()
expect((await cancelled).name).toBe('AbortError')
const replacement = withSearchProjectionBudget(undefined, async () => 'replacement')
release()
await active
expect(await Promise.all(queued)).toEqual(Array(7).fill('ok'))
expect(await replacement).toBe('replacement')
})
test('inline SQLite commit worker survives bun --compile without external worker source assets', async () => {
const directory = await mkdtemp(join(tmpdir(), 'search-worker-compiled-'))
directories.push(directory)
const script = join(directory, 'entry.ts')
const binary = join(directory, 'compiled-search')
const databaseModule = new URL('./searchContentDatabase.ts', import.meta.url).pathname
const indexModule = new URL('./searchContentIndex.ts', import.meta.url).pathname
const projectorModule = new URL('./searchContentProjector.ts', import.meta.url).pathname
await writeFile(script, `
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { openSearchContentDatabase } from ${JSON.stringify(databaseModule)}
import { createSearchContentIndex } from ${JSON.stringify(indexModule)}
import { createSearchContentProjector } from ${JSON.stringify(projectorModule)}
const root = process.argv[2]
const source = join(root, 'session.jsonl')
await writeFile(source, JSON.stringify({type:'user',message:{role:'user',content:'compiled worker searchable text'}})+'\\n')
const database = openSearchContentDatabase({path:join(root,'search.sqlite')})
const index = createSearchContentIndex(database,{scope:root})
const result = await createSearchContentProjector({database,index}).projectSource({path:source,projectPath:root,ownerSessionId:'compiled',ownerTranscriptPath:source,modifiedAtMs:1})
const row = database.read(reader=>reader.get('SELECT body FROM search_documents'))
database.close()
if(result.kind!=='indexed'||row.body!=='compiled worker searchable text') throw new Error(JSON.stringify({result,row}))
console.log('compiled worker passed')
`)
const build = Bun.spawn([process.execPath, 'build', '--compile', '--minify', script, '--outfile', binary], { stdout: 'pipe', stderr: 'pipe' })
const [buildCode, buildError] = await Promise.all([build.exited, new Response(build.stderr).text()])
expect({ code: buildCode, error: buildCode ? buildError : '' }).toEqual({ code: 0, error: '' })
if (process.platform === 'darwin') {
// Match the sidecar packaging smoke: Bun's compiled Mach-O needs a fresh ad-hoc signature.
for (const args of [['--remove-signature', binary], ['--sign', '-', '--force', binary]]) {
const sign = Bun.spawn(['codesign', ...args], { stdout: 'pipe', stderr: 'pipe' })
const [code, error] = await Promise.all([sign.exited, new Response(sign.stderr).text()])
expect({ code, error: code ? error : '' }).toEqual({ code: 0, error: '' })
}
}
await rm(script)
const run = Bun.spawn([binary, directory], { stdout: 'pipe', stderr: 'pipe' })
const [code, stdout, stderr] = await Promise.all([run.exited, new Response(run.stdout).text(), new Response(run.stderr).text()])
expect({ code, stderr }).toEqual({ code: 0, stderr: '' })
expect(stdout).toContain('compiled worker passed')
}, 30_000)
@@ -0,0 +1,101 @@
import { Worker } from 'node:worker_threads'
import { searchContentCommitFunctions, type SearchContentSourceWrite } from './searchContentIndex.js'
type Waiter = { resolve: () => void; reject: (error: unknown) => void; signal?: AbortSignal; abort: () => void }
let active = false
const waiting: Waiter[] = []
/** One cold whole-file projection/commit at a time, including direct callers
* outside the already-serial coordinator. Pending work is also bounded. */
export async function withSearchProjectionBudget<T>(signal: AbortSignal | undefined, operation: () => Promise<T>): Promise<T> {
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError')
if (active) {
if (waiting.length >= 8) throw new Error('SEARCH_CONTENT_BUSY')
await new Promise<void>((resolve, reject) => {
const waiter: Waiter = { resolve, reject, signal, abort: () => {} }
waiter.abort = () => {
const index = waiting.indexOf(waiter)
if (index >= 0) waiting.splice(index, 1)
reject(signal?.reason ?? new DOMException('Aborted', 'AbortError'))
}
waiting.push(waiter)
signal?.addEventListener('abort', waiter.abort, { once: true })
})
} else active = true
try {
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError')
return await operation()
} finally {
const next = waiting.shift()
if (next) { next.signal?.removeEventListener('abort', next.abort); next.resolve() }
else active = false
}
}
/** Inline eval avoids an external .ts worker asset that disappears from bun
* --compile sidecars. Only scalar metadata crosses the thread boundary. */
export function commitSearchContentSpool(options: {
databasePath: string
spoolPath: string
source: SearchContentSourceWrite
append: boolean
signal?: AbortSignal
onStarted?: () => void
}): Promise<void> {
if (options.signal?.aborted) return Promise.reject(options.signal.reason ?? new DOMException('Aborted', 'AbortError'))
const script = `
const { parentPort, workerData } = require('node:worker_threads');
const { Database } = require('bun:sqlite');
${searchContentCommitFunctions()}
let database, spool;
try {
database = new Database(workerData.databasePath);
database.exec('PRAGMA busy_timeout=100; PRAGMA foreign_keys=ON; PRAGMA cache_size=-2048; PRAGMA synchronous=NORMAL');
spool = new Database(workerData.spoolPath, { readonly: true });
spool.exec('PRAGMA cache_size=-512');
const statements = new Map();
const statement = sql => { if (!statements.has(sql)) statements.set(sql, database.query(sql)); return statements.get(sql); };
const writer = { run: (sql, ...args) => statement(sql).run(...args), get: (sql, ...args) => statement(sql).get(...args) };
database.exec('BEGIN IMMEDIATE');
parentPort.postMessage({ type: 'started' });
const gate = new Int32Array(workerData.gate);
Atomics.wait(gate, 0, 0);
if (Atomics.load(gate, 0) !== 1) throw new Error('Search commit cancelled');
applySource(writer, workerData.source, spool.query('SELECT jsonlLine, byteStart, byteLength, segmentIndex, role, messageId, timestamp, body, normalizedBody FROM documents ORDER BY seq').iterate(), workerData.append, upsert, insert);
database.exec('COMMIT');
database.close(); database = undefined;
spool.close(); spool = undefined;
parentPort.postMessage({ type: 'complete' });
} catch (error) {
try { database?.exec('ROLLBACK'); } catch {}
parentPort.postMessage({ type: 'failed', message: String(error?.message ?? error) });
} finally {
database?.close(); spool?.close(); parentPort.close();
}
`
return new Promise<void>((resolve, reject) => {
const gate = new Int32Array(new SharedArrayBuffer(4))
const worker = new Worker(script, { eval: true, workerData: {
databasePath: options.databasePath, spoolPath: options.spoolPath, source: options.source, append: options.append, gate: gate.buffer,
} })
let completed = false
let failure: unknown
const abort = () => { failure = options.signal?.reason ?? new DOMException('Aborted', 'AbortError'); Atomics.store(gate, 0, 2); Atomics.notify(gate, 0); void worker.terminate() }
options.signal?.addEventListener('abort', abort, { once: true })
worker.on('message', message => {
if (message.type === 'complete') completed = true
else if (message.type === 'failed') failure = new Error(message.message)
else if (message.type === 'started') {
try { options.onStarted?.() } catch (error) { failure = error; Atomics.store(gate, 0, 2); Atomics.notify(gate, 0); void worker.terminate() }
if (!failure) { Atomics.store(gate, 0, 1); Atomics.notify(gate, 0) }
}
})
worker.on('error', error => { failure = error })
worker.on('exit', code => {
options.signal?.removeEventListener('abort', abort)
if (failure || !completed || code !== 0) reject(failure ?? new Error(`Search commit worker exited before commit (${code})`))
else resolve()
})
if (options.signal?.aborted) abort()
})
}
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises'
import { Worker } from 'node:worker_threads'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
@@ -631,3 +632,74 @@ describe('search content coordinator', () => {
await coordinator.stop()
})
})
describe('worker write-lock coordination', () => {
test.each(['finish', 'shutdown'] as const)('coalesces dirty notifications outside the worker lock during %s', async mode => {
const scope = await createTempScope()
const source = join(scope, 'projects', '-repo', 'session.jsonl')
await mkdir(join(source, '..'), { recursive: true })
await writeFile(source, userLine('lock fixture'))
const databasePath = join(scope, 'cc-haha', 'db', 'search-index-v1.sqlite')
let watcherOptions: ReconciliationWatcherOptions | undefined
let worker: Worker | undefined
let locked!: () => void
const lockHeld = new Promise<void>(resolve => { locked = resolve })
let writes = 0
let activeIndex: ReturnType<typeof createSearchContentIndex> | undefined
const coordinator = createSearchContentCoordinator({
resolveScope: () => scope,
resolveDatabasePath: () => databasePath,
createIndex: (database, owner) => {
const index = createSearchContentIndex(database, { scope: owner })
activeIndex = index
return { ...index, setReadiness(value) { writes++; index.setReadiness(value) } }
},
createWatcher: options => { watcherOptions = options; return noOpWatcher() },
createProjector: ({ signal }) => ({
async projectSource() {
worker = new Worker(`
const { parentPort, workerData } = require('node:worker_threads');
const { Database } = require('bun:sqlite');
const database = new Database(workerData);
database.exec('BEGIN IMMEDIATE');
parentPort.postMessage('locked');
parentPort.once('message', () => { database.exec('ROLLBACK'); database.close(); parentPort.close() });
`, { eval: true, workerData: databasePath })
const current = worker
const abort = () => { void current.terminate() }
signal.addEventListener('abort', abort, { once: true })
current.once('message', locked)
try { await new Promise<void>((resolve, reject) => { current.once('exit', () => resolve()); current.once('error', reject) }) }
finally { signal.removeEventListener('abort', abort) }
return { kind: 'indexed', action: 'full', state: 'ready', indexedBytes: 0, indexedLines: 0, documentCount: 0 }
},
deleteSource: () => ({ kind: 'deleted' }),
}),
})
try {
await coordinator.start()
await lockHeld
const before = writes
const started = performance.now()
for (let event = 0; event < 20; event++) watcherOptions!.onDirty!()
expect(writes).toBe(before)
expect(performance.now() - started).toBeLessThan(100)
expect(coordinator.getStatus()).toMatchObject({ state: 'building', lastErrorCode: null })
expect(coordinator.search('lock')).toBeNull()
if (mode === 'shutdown') {
await coordinator.stop()
expect(writes).toBe(before)
} else {
worker!.postMessage('release')
await waitFor(() => writes === before + 2) // old sweep status + one coalesced dirty persistence
expect(activeIndex!.getReadiness()).toMatchObject({ state: 'building', generation: 21 })
await watcherOptions!.onBatch({ paths: [], fullSweep: false })
expect(coordinator.getStatus()).toMatchObject({ state: 'ready', lastErrorCode: null })
expect(activeIndex!.getReadiness()?.state).toBe('ready')
}
} finally {
await coordinator.stop()
}
})
})
@@ -310,6 +310,7 @@ export function createSearchContentCoordinator(
let watcher: ReconciliationWatcher | undefined
let controller: AbortController | undefined
let writerQueue: Promise<void> = Promise.resolve()
let dirtyReadinessWrite: symbol | undefined
let startPromise: Promise<void> | undefined
let stopPromise: Promise<void> | undefined
let corruptionRecoveryToken: symbol | undefined
@@ -345,21 +346,25 @@ export function createSearchContentCoordinator(
if (!started) return
dirtyRevision += 1
status = { ...status, state: 'building', lastErrorCode: null }
try {
index?.setReadiness({
state: 'building',
// Watcher notifications run outside the projection queue. A synchronous
// write here would wait on the worker's SQLite lock on the UI/API thread.
if (dirtyReadinessWrite) return
const token = Symbol('dirty-readiness')
dirtyReadinessWrite = token
const expectedLifecycle = lifecycle
void enqueue(expectedLifecycle, async () => {
if (dirtyReadinessWrite !== token) return
dirtyReadinessWrite = undefined
if (!started || expectedLifecycle !== lifecycle || !index) return
index.setReadiness({
state: status.state,
generation: dirtyRevision,
discovered: status.discovered,
indexed: status.indexed,
degraded: status.degradedSources,
lastErrorCode: status.lastErrorCode,
})
} catch (error) {
status = {
...status,
state: 'degraded',
lastErrorCode: errorCode(error, 'SEARCH_CONTENT_DIRTY_FAILED'),
}
}
})
}
const refreshStorage = (): boolean => {
@@ -410,9 +415,10 @@ export function createSearchContentCoordinator(
const lastErrorCode = !storageHealthy
? status.lastErrorCode ?? 'SEARCH_CONTENT_STORAGE_LIMIT'
: projectionFailures.at(-1) ?? (!watcherHealthy ? 'SEARCH_CONTENT_WATCH_FAILED' : null)
status = { ...status, state: ready ? 'ready' : 'degraded', lastErrorCode }
const state = ready ? 'ready' : processedRevision !== dirtyRevision && !lastErrorCode ? 'building' : 'degraded'
status = { ...status, state, lastErrorCode }
index.setReadiness({
state: ready ? 'ready' : 'degraded',
state,
generation: dirtyRevision,
discovered: status.discovered,
indexed: status.indexed,
@@ -461,7 +467,7 @@ export function createSearchContentCoordinator(
discoveryFailureCode = SEARCH_CONTENT_PROJECTS_ROOT_MISSING
} else if (discovery.complete) {
for (const stalePath of existing) {
if (!seen.has(stalePath)) activeProjector.deleteSource(stalePath)
if (!seen.has(stalePath)) await activeProjector.deleteSource(stalePath)
}
hasCompleteSweep = true
failedPaths = sweepFailures
@@ -519,9 +525,9 @@ export function createSearchContentCoordinator(
resolve(source.ownerTranscriptPath) === normalizedPath &&
resolve(source.path) !== normalizedPath)
: []
result = activeProjector.deleteSource(normalizedPath)
result = await activeProjector.deleteSource(normalizedPath)
for (const dependent of dependentSources) {
activeProjector.deleteSource(resolve(dependent.path))
await activeProjector.deleteSource(resolve(dependent.path))
failedPaths.set(resolve(dependent.path), SEARCH_CONTENT_OWNER_MISSING)
}
}
@@ -534,7 +540,7 @@ export function createSearchContentCoordinator(
}
} catch (error) {
if (errorCode(error, '') === SEARCH_CONTENT_OWNER_MISSING) {
activeProjector.deleteSource(normalizedPath)
await activeProjector.deleteSource(normalizedPath)
}
failedPaths.set(
normalizedPath,
@@ -590,6 +596,7 @@ export function createSearchContentCoordinator(
const activeWriterQueue = writerQueue
started = false
dirtyReadinessWrite = undefined
hasCompleteSweep = false
activeController?.abort()
if (controller === activeController) controller = undefined
@@ -47,6 +47,7 @@ export type SearchContentStorageStats = {
}
export type SearchContentDatabase = {
readonly path?: string
read<T>(operation: (database: SearchContentReadOperation) => T): T
write<T>(operation: (database: SearchContentWriteOperation) => T): T
transaction<T>(operation: (database: SearchContentWriteOperation) => T): T
@@ -163,6 +164,7 @@ export function openSearchContentDatabase(options?: {
})
return {
path: databasePath === ':memory:' ? undefined : databasePath,
read(operation) {
assertOpen()
return operation(createReadOperation())
@@ -112,11 +112,11 @@ export interface SearchContentIndex {
countSources(): number
replaceSource(
source: SearchContentSourceWrite,
documents: SearchContentDocumentWrite[],
documents: Iterable<SearchContentDocumentWrite>,
): void
appendSource(
source: SearchContentSourceWrite,
documents: SearchContentDocumentWrite[],
documents: Iterable<SearchContentDocumentWrite>,
): void
deleteSource(path: string): void
getReadiness(): SearchContentReadiness | null
@@ -278,7 +278,7 @@ function upsertSource(
function insertDocuments(
writer: SearchContentWriteOperation,
sourcePath: string,
documents: SearchContentDocumentWrite[],
documents: Iterable<SearchContentDocumentWrite>,
): void {
for (const document of documents) {
writer.run(`
@@ -300,6 +300,26 @@ function insertDocuments(
}
}
// Keep these functions closed over their explicit arguments so the exact same
// SQL implementation can run in the inline compiled-safe commit worker.
function applySearchSource(
writer: SearchContentWriteOperation,
source: SearchContentSourceWrite,
documents: Iterable<SearchContentDocumentWrite>,
append: boolean,
upsert: typeof upsertSource,
insert: typeof insertDocuments,
): void {
if (append && !writer.get('SELECT path FROM search_sources WHERE path = ?', source.path)) throw new Error('Cannot append an unindexed search source')
upsert(writer, source)
if (!append) writer.run('DELETE FROM search_documents WHERE source_path = ?', source.path)
insert(writer, source.path, documents)
}
export function searchContentCommitFunctions(): string {
return `const upsert = (${upsertSource.toString()}); const insert = (${insertDocuments.toString()}); const applySource = (${applySearchSource.toString()});`
}
function ftsPhrase(value: string): string {
return `"${value.replace(/"/g, '""')}"`
}
@@ -329,22 +349,10 @@ export function createSearchContentIndex(
))?.total ?? 0
},
replaceSource(source, documents) {
database.transaction(writer => {
upsertSource(writer, source)
writer.run('DELETE FROM search_documents WHERE source_path = ?', source.path)
insertDocuments(writer, source.path, documents)
})
database.transaction(writer => applySearchSource(writer, source, documents, false, upsertSource, insertDocuments))
},
appendSource(source, documents) {
database.transaction(writer => {
const existing = writer.get<{ path: string }>(
'SELECT path FROM search_sources WHERE path = ?',
source.path,
)
if (!existing) throw new Error('Cannot append an unindexed search source')
upsertSource(writer, source)
insertDocuments(writer, source.path, documents)
})
database.transaction(writer => applySearchSource(writer, source, documents, true, upsertSource, insertDocuments))
},
deleteSource(path) {
database.transaction(writer => {
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { afterEach, describe, expect, it, test } from 'bun:test'
import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
@@ -252,3 +252,93 @@ describe('search content projector', () => {
}
})
})
describe('bounded search projection', () => {
it('flushes bounded batches without exposing a partially replaced source', async () => {
const { database, index, projector, candidate, sourcePath } = await setup()
try {
await writeFile(sourcePath, line({ type: 'user', message: { role: 'user', content: 'old stable body' } }))
await projector.projectSource(candidate)
const batches: Array<{ bytes: number; documents: number }> = []
const bounded = createSearchContentProjector({ database, index, onBatch: batch => {
batches.push(batch)
expect(database.read(reader => reader.get<{ count: number }>('SELECT COUNT(*) AS count FROM search_documents'))?.count).toBe(1)
} })
await writeFile(sourcePath, Array.from({ length: 24 }, (_, index) => line({ type: 'assistant', uuid: String(index), message: { role: 'assistant', content: 'new searchable ' + 'x'.repeat(256 * 1024) } })).join(''))
const result = await bounded.projectSource(candidate)
expect(result).toMatchObject({ kind: 'indexed', documentCount: 24 })
expect(batches.length).toBeGreaterThan(1)
expect(Math.max(...batches.map(batch => batch.bytes))).toBeLessThanOrEqual(4 * 1024 * 1024)
expect(database.read(reader => reader.get<{ count: number }>('SELECT COUNT(*) AS count FROM search_documents'))?.count).toBe(24)
} finally { database.close() }
})
it('cancels a partially spooled replacement without changing its old source or documents', async () => {
const { database, index, projector, candidate, sourcePath } = await setup()
try {
await writeFile(sourcePath, line({ type: 'user', message: { role: 'user', content: 'old stable body' } }))
await projector.projectSource(candidate)
const before = index.getSource(sourcePath)
const controller = new AbortController()
const bounded = createSearchContentProjector({ database, index, signal: controller.signal, onBatch: () => controller.abort() })
await writeFile(sourcePath, Array.from({ length: 24 }, () => line({ type: 'assistant', message: { role: 'assistant', content: 'x'.repeat(256 * 1024) } })).join(''))
expect(await bounded.projectSource(candidate)).toMatchObject({ kind: 'retry' })
expect(index.getSource(sourcePath)).toEqual(before)
expect(database.read(reader => reader.get<{ body: string }>('SELECT body FROM search_documents'))?.body).toBe('old stable body')
} finally { database.close() }
})
})
test.each(['append', 'rewrite'] as const)('worker insertion failure rolls back %s metadata and all new rows', async mode => {
const { database, index, projector, candidate, sourcePath } = await setup()
try {
await writeFile(sourcePath, line({ type: 'user', message: { role: 'user', content: 'old stable body' } }))
await projector.projectSource(candidate)
const before = index.getSource(sourcePath)
database.write(writer => writer.exec("CREATE TRIGGER reject_bad_search_doc BEFORE INSERT ON search_documents WHEN new.body = 'reject this row' BEGIN SELECT RAISE(ABORT, 'fixture insertion failure'); END"))
await (mode === 'append' ? appendFile : writeFile)(sourcePath, line({ type: 'user', message: { role: 'user', content: 'new successful first row' } }) + line({ type: 'user', message: { role: 'user', content: 'reject this row' } }))
expect(await projector.projectSource(candidate)).toMatchObject({ kind: 'retry' })
expect(index.getSource(sourcePath)).toEqual(before)
expect(database.read(reader => reader.all<{ body: string }>('SELECT body FROM search_documents'))).toEqual([{ body: 'old stable body' }])
} finally { database.close() }
})
test('abort after worker BEGIN preserves the complete old search snapshot', async () => {
const { database, index, projector, candidate, sourcePath } = await setup()
try {
await writeFile(sourcePath, line({ type: 'user', message: { role: 'user', content: 'old stable body' } }))
await projector.projectSource(candidate)
const before = index.getSource(sourcePath)
const controller = new AbortController()
const aborting = createSearchContentProjector({ database, index, signal: controller.signal, onCommitStarted() {
expect(index.getSource(sourcePath)).toEqual(before)
controller.abort()
} })
await writeFile(sourcePath, line({ type: 'user', message: { role: 'user', content: 'new replacement row' } }))
expect(await aborting.projectSource(candidate)).toMatchObject({ kind: 'retry' })
expect(index.getSource(sourcePath)).toEqual(before)
expect(database.read(reader => reader.all<{ body: string }>('SELECT body FROM search_documents'))).toEqual([{ body: 'old stable body' }])
} finally { database.close() }
})
test.each(['worker', 'custom'] as const)('preserves a single document exceeding the batch target through %s commit', async mode => {
const { database, index, candidate, sourcePath } = await setup()
try {
const body = 'large searchable ' + 'x'.repeat(5 * 1024 * 1024) + ' end marker'
await writeFile(sourcePath, line({ type: 'user', message: { role: 'user', content: body } }))
const batches: Array<{ bytes: number; documents: number }> = []
const projector = createSearchContentProjector({
database: mode === 'custom' ? { ...database, path: undefined } : database,
index,
onBatch: batch => batches.push(batch),
})
expect(await projector.projectSource(candidate)).toMatchObject({ kind: 'indexed', state: 'ready', documentCount: 1 })
expect(batches).toHaveLength(1)
expect(batches[0]?.documents).toBe(1)
expect(batches[0]?.bytes).toBeGreaterThan(4 * 1024 * 1024)
expect(database.read(reader => reader.get<{ body: string }>('SELECT body FROM search_documents'))?.body).toBe(body)
} finally { database.close() }
})
@@ -1,4 +1,8 @@
import { open, type FileHandle } from 'node:fs/promises'
import { mkdtemp, open, rm, type FileHandle } from 'node:fs/promises'
import { Database } from 'bun:sqlite'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { commitSearchContentSpool, withSearchProjectionBudget } from './searchContentCommitWorker.js'
import {
getCommandMetadataDisplayText,
shouldHideCommandMetadataContent,
@@ -64,7 +68,7 @@ export interface SearchContentProjector {
projectSource(
candidate: SearchContentSourceCandidate,
): Promise<SearchContentProjectResult>
deleteSource(path: string): { kind: 'deleted' }
deleteSource(path: string): { kind: 'deleted' } | Promise<{ kind: 'deleted' }>
}
export type SearchContentProjectorOptions = {
@@ -75,9 +79,13 @@ export type SearchContentProjectorOptions = {
signal?: AbortSignal
verifyFingerprint?: typeof verifySourceFingerprint
maxJsonlLineBytes?: number
onBatch?: (batch: { bytes: number; documents: number }) => void
onCommitStarted?: () => void
}
const READ_BUFFER_BYTES = 256 * 1024
export const SEARCH_CONTENT_BATCH_BYTES = 4 * 1024 * 1024
const SEARCH_CONTENT_BATCH_DOCUMENTS = 256
function extractPlainTextBlocks(content: unknown): string[] {
if (typeof content === 'string') {
@@ -214,13 +222,26 @@ async function readCompleteLines(options: {
startingLine: number
signal?: AbortSignal
maxJsonlLineBytes: number
writeBatch: (documents: SearchContentDocumentWrite[], bytes: number) => void
}): Promise<{
documents: SearchContentDocumentWrite[]
documentCount: number
indexedBytes: number
indexedLines: number
lineTooLarge: boolean
}> {
const documents: SearchContentDocumentWrite[] = []
let documents: SearchContentDocumentWrite[] = []
let batchBytes = 0
let documentCount = 0
const flush = async () => {
if (!documents.length) return
throwIfAborted(options.signal)
options.writeBatch(documents, batchBytes)
documentCount += documents.length
documents = []
batchBytes = 0
await new Promise<void>(resolve => setImmediate(resolve))
throwIfAborted(options.signal)
}
const pendingParts: Buffer[] = []
let pendingBytes = 0
let position = options.start
@@ -241,8 +262,9 @@ async function readCompleteLines(options: {
if (newline < 0) {
const remainder = chunk.subarray(cursor)
if (pendingBytes + remainder.length > options.maxJsonlLineBytes) {
await flush()
return {
documents,
documentCount,
indexedBytes: lineStart,
indexedLines: jsonlLine,
lineTooLarge: true,
@@ -255,8 +277,9 @@ async function readCompleteLines(options: {
const tail = chunk.subarray(cursor, newline + 1)
if (pendingBytes + tail.length > options.maxJsonlLineBytes) {
await flush()
return {
documents,
documentCount,
indexedBytes: lineStart,
indexedLines: jsonlLine,
lineTooLarge: true,
@@ -266,11 +289,15 @@ async function readCompleteLines(options: {
? Buffer.concat([...pendingParts, tail], pendingBytes + tail.length)
: tail
jsonlLine += 1
documents.push(...parseCompleteLine({
bytes: lineBytes,
byteStart: lineStart,
jsonlLine,
}))
for (const document of parseCompleteLine({ bytes: lineBytes, byteStart: lineStart, jsonlLine })) {
const bytes = Buffer.byteLength(document.body) + Buffer.byteLength(document.normalizedBody)
if (documents.length && (batchBytes + bytes > SEARCH_CONTENT_BATCH_BYTES || documents.length >= SEARCH_CONTENT_BATCH_DOCUMENTS)) await flush()
documents.push(document)
batchBytes += bytes
// A single document may exceed the batch target, but its source line
// remains bounded by maxJsonlLineBytes; never truncate searchable text.
if (batchBytes >= SEARCH_CONTENT_BATCH_BYTES) await flush()
}
lineStart += lineBytes.length
pendingParts.length = 0
pendingBytes = 0
@@ -279,8 +306,9 @@ async function readCompleteLines(options: {
position += bytesRead
}
await flush()
return {
documents,
documentCount,
indexedBytes: lineStart,
indexedLines: jsonlLine,
lineTooLarge: false,
@@ -306,144 +334,169 @@ export function createSearchContentProjector(
return {
async projectSource(candidate) {
throwIfAborted(options.signal)
const existing = options.index.getSource(candidate.path)
let action: 'full' | 'append' | 'rebuild' = existing ? 'rebuild' : 'full'
let start = 0
let startingLine = 0
return withSearchProjectionBudget(options.signal, async () => {
throwIfAborted(options.signal)
const existing = options.index.getSource(candidate.path)
let action: 'full' | 'append' | 'rebuild' = existing ? 'rebuild' : 'full'
let start = 0
let startingLine = 0
if (existing) {
const previous = deserializeSourceFingerprint(existing.fingerprint)
const change = previous
? await detectSourceChange({
if (existing) {
const previous = deserializeSourceFingerprint(existing.fingerprint)
const change = previous
? await detectSourceChange({
path: candidate.path,
previous,
parserVersion,
})
: { kind: 'rebuild', reason: 'rewrite' } as const
if (change.kind === 'retry') return change
if (change.kind === 'deleted') return remove(candidate.path)
if (change.kind === 'unchanged') {
if (
existing.projectPath !== candidate.projectPath ||
existing.ownerSessionId !== candidate.ownerSessionId ||
existing.ownerTranscriptPath !== candidate.ownerTranscriptPath ||
existing.modifiedAtMs !== candidate.modifiedAtMs
) {
options.index.appendSource({
...existing,
projectPath: candidate.projectPath,
ownerSessionId: candidate.ownerSessionId,
ownerTranscriptPath: candidate.ownerTranscriptPath,
modifiedAtMs: candidate.modifiedAtMs,
updatedAtMs: now(),
}, [])
}
return {
kind: 'indexed',
action: 'unchanged',
state: existing.state,
indexedBytes: existing.indexedBytes,
indexedLines: existing.indexedLines,
documentCount: 0,
}
}
if (change.kind === 'append') {
action = 'append'
start = change.readFrom
startingLine = existing.indexedLines
}
}
let handle: FileHandle | undefined
let spool: Database | undefined
let spoolDirectory: string | undefined
try {
const readSnapshot = await captureSourceFingerprint({
path: candidate.path,
previous,
indexedBytes: start,
parserVersion,
})
: { kind: 'rebuild', reason: 'rewrite' } as const
if (change.kind === 'retry') return change
if (change.kind === 'deleted') return remove(candidate.path)
if (change.kind === 'unchanged') {
if (
existing.projectPath !== candidate.projectPath ||
existing.ownerSessionId !== candidate.ownerSessionId ||
existing.ownerTranscriptPath !== candidate.ownerTranscriptPath ||
existing.modifiedAtMs !== candidate.modifiedAtMs
) {
options.index.appendSource({
...existing,
projectPath: candidate.projectPath,
ownerSessionId: candidate.ownerSessionId,
ownerTranscriptPath: candidate.ownerTranscriptPath,
modifiedAtMs: candidate.modifiedAtMs,
updatedAtMs: now(),
}, [])
throwIfAborted(options.signal)
handle = await open(candidate.path, 'r')
const before = await handle.stat()
if (!snapshotMatchesFingerprint(before, readSnapshot)) {
return { kind: 'retry', reason: 'changed-during-read' }
}
spoolDirectory = await mkdtemp(join(tmpdir(), 'claude-search-spool-'))
const spoolPath = join(spoolDirectory, 'documents.sqlite')
spool = new Database(spoolPath)
spool.exec('PRAGMA journal_mode=OFF; PRAGMA cache_size=-512; PRAGMA temp_store=FILE; CREATE TABLE documents (seq INTEGER PRIMARY KEY, jsonlLine INTEGER, byteStart INTEGER, byteLength INTEGER, segmentIndex INTEGER, role TEXT, messageId TEXT, timestamp TEXT, body TEXT, normalizedBody TEXT)')
const insert = spool.query('INSERT INTO documents (jsonlLine, byteStart, byteLength, segmentIndex, role, messageId, timestamp, body, normalizedBody) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
const activeSpool = spool
const reduced = await readCompleteLines({
handle,
start,
end: readSnapshot.size,
startingLine,
signal: options.signal,
maxJsonlLineBytes,
writeBatch(documents, bytes) {
options.onBatch?.({ documents: documents.length, bytes })
throwIfAborted(options.signal)
activeSpool.transaction(() => {
for (const document of documents) insert.run(document.jsonlLine, document.byteStart, document.byteLength, document.segmentIndex, document.role, document.messageId, document.timestamp, document.body, document.normalizedBody)
})()
},
})
const after = await handle.stat()
if (!snapshotMatchesFingerprint(after, readSnapshot)) {
return { kind: 'retry', reason: 'changed-during-read' }
}
await handle.close()
handle = undefined
const commitSnapshot = await captureSourceFingerprint({
path: candidate.path,
indexedBytes: reduced.indexedBytes,
parserVersion,
})
if (!sameReadSnapshot(readSnapshot, commitSnapshot)) {
return { kind: 'retry', reason: 'changed-during-read' }
}
const verified = await verifyFingerprint({
path: candidate.path,
expected: commitSnapshot,
})
if (verified.kind !== 'unchanged') {
return verified.kind === 'retry'
? verified
: { kind: 'retry', reason: 'changed-during-read' }
}
throwIfAborted(options.signal)
const state: SearchContentSourceState = reduced.lineTooLarge
? 'degraded'
: reduced.indexedBytes < commitSnapshot.size ? 'pending' : 'ready'
const source: SearchContentSourceWrite = {
path: candidate.path,
projectPath: candidate.projectPath,
ownerSessionId: candidate.ownerSessionId,
ownerTranscriptPath: candidate.ownerTranscriptPath,
modifiedAtMs: candidate.modifiedAtMs,
sizeBytes: commitSnapshot.size,
mtimeMs: commitSnapshot.mtimeMs,
fileIdentity: commitSnapshot.fileIdentity,
fingerprint: serializeSourceFingerprint(commitSnapshot),
indexedBytes: reduced.indexedBytes,
indexedLines: reduced.indexedLines,
parserVersion,
state,
lastErrorCode: reduced.lineTooLarge
? SEARCH_CONTENT_LINE_TOO_LARGE
: null,
updatedAtMs: now(),
}
if (options.database.path) {
spool.close()
spool = undefined
await commitSearchContentSpool({ databasePath: options.database.path, spoolPath, source, append: action === 'append', signal: options.signal, onStarted: options.onCommitStarted })
} else {
// Custom/in-memory databases cannot be reopened in another thread.
// They retain the same bounded iterator and atomic transaction API.
const documents = spool.query('SELECT jsonlLine, byteStart, byteLength, segmentIndex, role, messageId, timestamp, body, normalizedBody FROM documents ORDER BY seq').iterate() as Iterable<SearchContentDocumentWrite>
if (action === 'append') options.index.appendSource(source, documents)
else options.index.replaceSource(source, documents)
}
return {
kind: 'indexed',
action: 'unchanged',
state: existing.state,
indexedBytes: existing.indexedBytes,
indexedLines: existing.indexedLines,
documentCount: 0,
action,
state,
indexedBytes: reduced.indexedBytes,
indexedLines: reduced.indexedLines,
documentCount: reduced.documentCount,
}
} catch (error) {
if (isMissing(error)) return remove(candidate.path)
return retry()
} finally {
await handle?.close().catch(() => {})
spool?.close()
if (spoolDirectory) await rm(spoolDirectory, { recursive: true, force: true })
}
if (change.kind === 'append') {
action = 'append'
start = change.readFrom
startingLine = existing.indexedLines
}
}
let handle: FileHandle | undefined
try {
const readSnapshot = await captureSourceFingerprint({
path: candidate.path,
indexedBytes: start,
parserVersion,
})
throwIfAborted(options.signal)
handle = await open(candidate.path, 'r')
const before = await handle.stat()
if (!snapshotMatchesFingerprint(before, readSnapshot)) {
return { kind: 'retry', reason: 'changed-during-read' }
}
const reduced = await readCompleteLines({
handle,
start,
end: readSnapshot.size,
startingLine,
signal: options.signal,
maxJsonlLineBytes,
})
const after = await handle.stat()
if (!snapshotMatchesFingerprint(after, readSnapshot)) {
return { kind: 'retry', reason: 'changed-during-read' }
}
await handle.close()
handle = undefined
const commitSnapshot = await captureSourceFingerprint({
path: candidate.path,
indexedBytes: reduced.indexedBytes,
parserVersion,
})
if (!sameReadSnapshot(readSnapshot, commitSnapshot)) {
return { kind: 'retry', reason: 'changed-during-read' }
}
const verified = await verifyFingerprint({
path: candidate.path,
expected: commitSnapshot,
})
if (verified.kind !== 'unchanged') {
return verified.kind === 'retry'
? verified
: { kind: 'retry', reason: 'changed-during-read' }
}
throwIfAborted(options.signal)
const state: SearchContentSourceState = reduced.lineTooLarge
? 'degraded'
: reduced.indexedBytes < commitSnapshot.size ? 'pending' : 'ready'
const source: SearchContentSourceWrite = {
path: candidate.path,
projectPath: candidate.projectPath,
ownerSessionId: candidate.ownerSessionId,
ownerTranscriptPath: candidate.ownerTranscriptPath,
modifiedAtMs: candidate.modifiedAtMs,
sizeBytes: commitSnapshot.size,
mtimeMs: commitSnapshot.mtimeMs,
fileIdentity: commitSnapshot.fileIdentity,
fingerprint: serializeSourceFingerprint(commitSnapshot),
indexedBytes: reduced.indexedBytes,
indexedLines: reduced.indexedLines,
parserVersion,
state,
lastErrorCode: reduced.lineTooLarge
? SEARCH_CONTENT_LINE_TOO_LARGE
: null,
updatedAtMs: now(),
}
if (action === 'append') {
options.index.appendSource(source, reduced.documents)
} else {
options.index.replaceSource(source, reduced.documents)
}
return {
kind: 'indexed',
action,
state,
indexedBytes: reduced.indexedBytes,
indexedLines: reduced.indexedLines,
documentCount: reduced.documents.length,
}
} catch (error) {
if (isMissing(error)) return remove(candidate.path)
return retry()
} finally {
await handle?.close().catch(() => {})
}
})
},
deleteSource: remove,
deleteSource: path => withSearchProjectionBudget(options.signal, async () => remove(path)),
}
}
@@ -62,3 +62,13 @@ test('bounds queued file builds and cleans a fully cancelled scan for subsequent
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)
})
test('rebuilds scalar context after an in-place rewrite grows beyond the cached snapshot', async () => {
await writeFile(file, row('notice', { notification: true }) + '\n')
await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0], classify })
await writeFile(file, row('replacement', { reset: true }) + '\n' + row('more-records') + '\n')
const rebuilt = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0], classify })
expect(rebuilt.contexts.get(0)?.suppressed).toBe(false)
expect(rebuilt.scannedBytes).toBe(Number((await stat(file)).size))
})
+31 -6
View File
@@ -1,18 +1,38 @@
import { createHash } from 'node:crypto'
import { Database } from 'bun:sqlite'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { mkdtemp, open, 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 { HISTORY_SEMANTIC_RECORD_BYTES, 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 Cache = { database: Database; directory: string; identity: string; size: number; mtime: string; offset: number; suppressed: boolean | null; fingerprint?: string }
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 }) } })
async function sourceAnchors(filePath: string, size: number, signal: AbortSignal): Promise<string> {
const handle = await open(filePath, 'r')
try {
const hash = createHash('sha256')
for (const offset of [0, Math.max(0, size - 4096)]) {
const bytes = Buffer.alloc(Math.min(4096, size - offset))
let read = 0
while (read < bytes.length) {
if (signal.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError')
const result = await handle.read(bytes, read, bytes.length - read, offset + read)
if (!result.bytesRead) throw new ApiError(409, 'History changed during context validation', 'HISTORY_CHANGED')
read += result.bytesRead
}
hash.update(bytes)
}
return hash.digest('hex')
} finally { await handle.close() }
}
/** 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: {
@@ -33,7 +53,9 @@ export async function readHistoryContexts(options: {
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))) {
const rewrittenGrowth = state?.fingerprint && Number(current.size) > state.size
? state.fingerprint !== await sourceAnchors(options.filePath, state.size, signal) : false
if (state && (rewrittenGrowth || 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 })
@@ -63,6 +85,7 @@ export async function readHistoryContexts(options: {
let suppressed = state.suppressed
let completeSuppression = suppressed
try {
const fingerprint = await sourceAnchors(options.filePath, targetSize, signal)
state.database.exec('BEGIN')
const result = await streamBoundedHistory(options.filePath, (entry, completeLine, offset) => {
const classification = options.classify(entry)
@@ -76,15 +99,17 @@ export async function readHistoryContexts(options: {
// 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 } })
}, signal, { startOffset: originalOffset, endOffset: targetSize, maxRecordBytes: HISTORY_SEMANTIC_RECORD_BYTES, onSkipped: () => { suppressed = null; completeSuppression = null } })
if (fingerprint !== await sourceAnchors(options.filePath, targetSize, signal)) throw new ApiError(409, 'History was rewritten during context scan', 'HISTORY_CHANGED')
state.database.exec('COMMIT')
state.fingerprint = fingerprint
state.size = targetSize
state.mtime = mtime!
state.offset = result.nextOffset
state.suppressed = completeSuppression
scannedBytes += result.scannedBytes
} catch (error) {
state.database.exec('ROLLBACK')
try { state.database.exec('ROLLBACK') } catch { /* Validation may fail before BEGIN. */ }
// journal_mode=OFF cannot guarantee rollback restoration after a failed
// build; discard this regenerable index entirely.
cache.delete(options.filePath)
@@ -62,7 +62,7 @@ test('recovery retains tool lifecycle ownership and does not revive child tools
})
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')
await writeFile(file, JSON.stringify(entry('assistant', 'huge', [{ type: 'text', text: 'x'.repeat(9 * 1024 * 1024) }])) + '\n')
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.status).toBe('incomplete')
expect(recovery.omittedRecords).toBe(1)
@@ -134,3 +134,21 @@ test('recovery resolves sidechain ancestry through Agent calls without attaching
const recovered = await service.getSessionHistoryRecovery(id)
expect(recovered.messages.map(message => message.id)).toEqual(['owner', 'root'])
})
test('a multi-megabyte foreground tool output preserves all small-session messages and does not degrade recovery', async () => {
await writeFile(file, [
entry('assistant', 'call', [{ type: 'tool_use', id: 'bash', name: 'Bash', input: { command: 'cat large-log' } }]),
entry('user', 'result', [{ type: 'tool_result', tool_use_id: 'bash', content: 'x'.repeat(4 * 1024 * 1024) }]),
...Array.from({ length: 158 }, (_, index) => entry('assistant', `reply-${index}`, 'ok')),
].map(value => JSON.stringify(value)).join('\n') + '\n')
const page = await service.getSessionHistoryPage(id)
expect(page.messages).toHaveLength(160)
expect(page.messages.find(message => message.id === 'result')?.bodyTruncated).toBe(true)
expect(page.page.nextCursor).toBeNull()
expect(page.page.omittedOversizedEntries).toBe(0)
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.status).toBe('ready')
expect(recovery.omittedRecords).toBe(0)
expect((await service.getSessionLaunchInfo(id))?.transcriptMessageCount).toBe(160)
})
@@ -2,7 +2,7 @@ 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 { HISTORY_SEMANTIC_RECORD_BYTES, streamBoundedHistory, withHistoryReadBudget } from './boundedSessionHistory.js'
import type { MessageEntry, SessionTaskNotification } from './sessionService.js'
const RECOVERY_BYTES = 3 * 1024 * 1024
@@ -173,7 +173,7 @@ export async function recoverBoundedSessionHistory(options: {
}
if (workspaceResults.length) saveActivity({ ordinal, message: { ...base, content: workspaceResults, toolUseResult: message.toolUseResult } }, 'workspace')
if (agentBlocks.length) saveActivity({ ordinal, message: { ...base, content: agentBlocks } })
}, options.signal)
}, options.signal, { maxRecordBytes: HISTORY_SEMANTIC_RECORD_BYTES })
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]))
+4 -2
View File
@@ -7,7 +7,7 @@ import { recoverBoundedSessionHistory, type SessionHistoryRecovery } from './ses
* 确保 Desktop App 与 CLI 的数据完全互通。
*/
import { readBoundedHistoryPage, streamBoundedHistory, withHistoryReadBudget, type HistoryPageInfo } from './boundedSessionHistory.js'
import { HISTORY_SEMANTIC_RECORD_BYTES, readBoundedHistoryPage, streamBoundedHistory, withHistoryReadBudget, type HistoryPageInfo } from './boundedSessionHistory.js'
import { constants, createReadStream, type Stats } from 'node:fs'
import { createHash } from 'node:crypto'
import * as fs from 'node:fs/promises'
@@ -216,6 +216,7 @@ export type MessageEntry = {
id: string
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result'
content: unknown
bodyTruncated?: boolean
toolUseResult?: unknown
timestamp: string
model?: string
@@ -1288,7 +1289,7 @@ export class SessionService {
const scan = await streamBoundedHistory(filePath, (entry, completeLine) => {
apply(launch, entry as RawEntry)
if (completeLine) apply(summary, entry as RawEntry)
})
}, undefined, { maxRecordBytes: HISTORY_SEMANTIC_RECORD_BYTES })
const shared = (state: typeof summary) => ({
...(state.permissionMode ? { permissionMode: state.permissionMode } : {}),
...(state.runtimeProviderId !== undefined ? { runtimeProviderId: state.runtimeProviderId } : {}),
@@ -1846,6 +1847,7 @@ export class SessionService {
id: entry.uuid || crypto.randomUUID(),
type,
content: msg.content,
...(entry.bodyTruncated === true ? { bodyTruncated: true } : {}),
...(entry.toolUseResult !== undefined ? { toolUseResult: entry.toolUseResult } : {}),
timestamp: entry.timestamp || new Date().toISOString(),
model: msg.model,