mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(perf): restore continuous history scrolling and bound search indexing
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
|
||||
@@ -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) } }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "先頭 / ライブ",
|
||||
|
||||
@@ -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": "처음 / 실시간",
|
||||
|
||||
@@ -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": "首頁 / 即時",
|
||||
|
||||
@@ -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": "首页 / 实时",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }>()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user