mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(desktop): preserve stopped chat across incomplete history reloads
This commit is contained in:
@@ -277,3 +277,110 @@ describe('sessionsApi', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('full session history assembly', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const page = (id: string, cursor: string | null, extra = {}) => ({
|
||||
messages: [{ id, type: 'assistant', content: id }],
|
||||
taskNotifications: [{ taskId: id }],
|
||||
page: {
|
||||
nextCursor: cursor,
|
||||
hasMore: cursor !== null,
|
||||
historyComplete: false,
|
||||
sourceVersion: 'fixture-version',
|
||||
scannedBytes: 10,
|
||||
omittedOversizedEntries: 0,
|
||||
...extra,
|
||||
},
|
||||
})
|
||||
const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
it('joins every raw page chronologically before returning and clears transport cursors', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(response(page('result', 'middle')))
|
||||
.mockResolvedValueOnce(response(page('call', 'oldest', { previousCursor: 'newer' })))
|
||||
.mockResolvedValueOnce(response(page('user', null, { previousCursor: 'middle' })))
|
||||
const result = await sessionsApi.getFullHistory('fixture')
|
||||
expect(result.messages.map(message => message.id)).toEqual(['user', 'call', 'result'])
|
||||
expect(result.taskNotifications?.map(notice => notice.taskId)).toEqual(['user', 'call', 'result'])
|
||||
expect(result.page).toMatchObject({ nextCursor: null, hasMore: false, historyComplete: true, scannedBytes: 30 })
|
||||
expect(fetchMock.mock.calls.map(call => call[0])).toEqual([
|
||||
'http://127.0.0.1:3456/api/sessions/fixture/messages?mode=full',
|
||||
'http://127.0.0.1:3456/api/sessions/fixture/messages?cursor=middle',
|
||||
'http://127.0.0.1:3456/api/sessions/fixture/messages?cursor=oldest',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the single-request fast path', async () => {
|
||||
const original = page('all', null, { historyComplete: true })
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(response(original))
|
||||
expect(await sessionsApi.getFullHistory('fixture')).toEqual(original)
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ omittedOversizedEntries: 2 }, { omittedOversizedEntries: 3 }, 5, false],
|
||||
[{}, { contentTruncated: true }, 0, true],
|
||||
])('retains incomplete-content evidence after exhausting cursors', async (newestExtra, oldestExtra, omitted, truncated) => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(response(page('new', 'older', newestExtra)))
|
||||
.mockResolvedValueOnce(response(page('old', null, oldestExtra)))
|
||||
expect((await sessionsApi.getFullHistory('fixture')).page).toMatchObject({
|
||||
hasMore: false, historyComplete: false, omittedOversizedEntries: omitted, contentTruncated: truncated,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a changed source instead of returning a mixed transcript', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(response(page('new', 'older')))
|
||||
.mockResolvedValueOnce(response(page('old', null, { sourceVersion: 'changed' })))
|
||||
await expect(sessionsApi.getFullHistory('fixture')).rejects.toThrow('Session history changed')
|
||||
})
|
||||
|
||||
it('rejects a cursor cycle instead of looping forever', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(response(page('new', 'older')))
|
||||
.mockResolvedValueOnce(response(page('old', 'older')))
|
||||
await expect(sessionsApi.getFullHistory('fixture')).rejects.toThrow('cursor did not advance')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects a failed continuation without exposing its successfully loaded tail', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(response(page('new', 'older')))
|
||||
.mockResolvedValueOnce(response({ error: 'HISTORY_CHANGED', message: 'Rewritten' }, 409))
|
||||
await expect(sessionsApi.getFullHistory('fixture')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('forwards cancellation into an in-flight continuation', async () => {
|
||||
const controller = new AbortController()
|
||||
let continuationStarted!: () => void
|
||||
const started = new Promise<void>(resolve => { continuationStarted = resolve })
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(response(page('new', 'older')))
|
||||
.mockImplementationOnce((_url, init) => new Promise<Response>((_resolve, reject) => {
|
||||
expect(init?.signal).toBeDefined()
|
||||
init?.signal?.addEventListener('abort', () => reject(controller.signal.reason), { once: true })
|
||||
continuationStarted()
|
||||
}))
|
||||
const request = sessionsApi.getFullHistory('fixture', { signal: controller.signal })
|
||||
await started
|
||||
controller.abort()
|
||||
await expect(request).rejects.toThrow()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('honors cancellation before starting a continuation', async () => {
|
||||
const controller = new AbortController()
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
return response(page('new', 'older'))
|
||||
})
|
||||
await expect(sessionsApi.getFullHistory('fixture', { signal: controller.signal })).rejects.toThrow()
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -441,11 +441,50 @@ export const sessionsApi = {
|
||||
return api.get<ProjectSessionHistoryResponse>(`/api/sessions/project-history?${query.toString()}`, options)
|
||||
},
|
||||
|
||||
// The timeline loads the whole transcript in one call. `mode=full` keeps the
|
||||
// server's own byte budget but returns a single newest-first slice plus a
|
||||
// `historyComplete` flag, so the UI never stitches page boundaries together.
|
||||
getFullHistory(sessionId: string, options?: ApiRequestOptions) {
|
||||
return api.get<SessionHistoryPage>(`/api/sessions/${sessionId}/messages?mode=full`, options)
|
||||
// The timeline receives one assembled transcript. Ordinary sessions fit in
|
||||
// `mode=full`; larger sessions continue within the server's request budgets.
|
||||
async getFullHistory(sessionId: string, options?: ApiRequestOptions): Promise<SessionHistoryPage> {
|
||||
const newest = await api.get<SessionHistoryPage>(`/api/sessions/${sessionId}/messages?mode=full`, options)
|
||||
if (!newest.page?.nextCursor) return newest
|
||||
|
||||
// Transport budgets are page boundaries, not timeline boundaries. Join raw
|
||||
// records before mapping so tool calls and results can pair across pages.
|
||||
const pages = [newest]
|
||||
const seen = new Set<string>()
|
||||
let cursor: string | null = newest.page.nextCursor
|
||||
let omitted = newest.page.omittedOversizedEntries
|
||||
let truncated = Boolean(newest.page.contentTruncated)
|
||||
let scannedBytes = newest.page.scannedBytes
|
||||
while (cursor) {
|
||||
if (options?.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError')
|
||||
if (seen.has(cursor)) throw new Error('Session history cursor did not advance')
|
||||
seen.add(cursor)
|
||||
const query: URLSearchParams = new URLSearchParams({ cursor })
|
||||
const older: SessionHistoryPage = await api.get<SessionHistoryPage>(`/api/sessions/${sessionId}/messages?${query}`, options)
|
||||
if (options?.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError')
|
||||
if (!older.page || older.page.sourceVersion !== newest.page.sourceVersion) {
|
||||
throw new Error('Session history changed while loading')
|
||||
}
|
||||
pages.push(older)
|
||||
omitted += older.page.omittedOversizedEntries
|
||||
truncated ||= Boolean(older.page.contentTruncated)
|
||||
scannedBytes += older.page.scannedBytes
|
||||
cursor = older.page.nextCursor
|
||||
}
|
||||
pages.reverse()
|
||||
return {
|
||||
messages: pages.flatMap(page => page.messages),
|
||||
taskNotifications: pages.flatMap(page => page.taskNotifications ?? []),
|
||||
page: {
|
||||
...newest.page,
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
historyComplete: omitted === 0 && !truncated && !newest.page.previousCursor,
|
||||
contentTruncated: truncated,
|
||||
omittedOversizedEntries: omitted,
|
||||
scannedBytes,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
getHistoryPage(sessionId: string, page?: { cursor?: string }, options?: ApiRequestOptions) {
|
||||
|
||||
@@ -15279,6 +15279,60 @@ describe('chatStore activity state survival across reload paths', () => {
|
||||
expect(session?.agentTaskNotifications).toEqual(notifications)
|
||||
})
|
||||
|
||||
it.each(['loadHistory', 'reloadHistory'] as const)('keeps stopped live output and tool context when %s omits oversized records', async (method) => {
|
||||
useChatStore.getState().disconnectSession(TEST_SESSION_ID)
|
||||
const original: UIMessage[] = [
|
||||
{ id: 'user', type: 'user_text', content: 'Read the file', timestamp: 1 },
|
||||
{ id: 'tool', type: 'tool_use', toolUseId: 'read-1', toolName: 'Read', input: { file_path: '/fixture/large.txt' }, isPending: false, timestamp: 2 },
|
||||
{ id: 'result', type: 'tool_result', toolUseId: 'read-1', content: 'file contents', isError: false, timestamp: 3 },
|
||||
]
|
||||
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({
|
||||
messages: original, historyHydrated: true, historyStatus: 'ready',
|
||||
chatState: 'thinking', streamingText: 'Partial answer before stop',
|
||||
}) } })
|
||||
useChatStore.getState().stopGeneration(TEST_SESSION_ID)
|
||||
const stopped = useChatStore.getState().sessions[TEST_SESSION_ID]!.messages
|
||||
vi.mocked(sessionsApi.getFullHistory).mockResolvedValueOnce({
|
||||
messages: [{ id: 'result', type: 'user', timestamp: new Date(3).toISOString(), content: [{ type: 'tool_result', tool_use_id: 'read-1', content: 'file contents' }] }],
|
||||
page: { nextCursor: null, hasMore: false, historyComplete: false, sourceVersion: 'oversized-fixture', scannedBytes: 10_000_000, omittedOversizedEntries: 1 },
|
||||
})
|
||||
|
||||
await useChatStore.getState()[method](TEST_SESSION_ID)
|
||||
|
||||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]!
|
||||
expect(session.chatState).toBe('idle')
|
||||
expect(session.messages.map(message => message.type)).toEqual(stopped.map(message => message.type))
|
||||
expect(session.messages).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'user_text', content: 'Read the file' }),
|
||||
expect.objectContaining({ type: 'tool_use', toolUseId: 'read-1', toolName: 'Read' }),
|
||||
expect.objectContaining({ type: 'assistant_text', content: 'Partial answer before stop' }),
|
||||
]))
|
||||
expect(session.historyPage?.nextCursor).toBeNull()
|
||||
expect(session.historyWindowed).toBe(true)
|
||||
})
|
||||
|
||||
it('does not duplicate stopped text when repeatedly reloading incomplete history', async () => {
|
||||
useChatStore.getState().disconnectSession(TEST_SESSION_ID)
|
||||
useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({
|
||||
historyHydrated: true, historyStatus: 'ready', chatState: 'thinking', streamingText: 'Stopped answer',
|
||||
messages: [{ id: 'user-live', type: 'user_text', content: 'Continue', timestamp: 1 }],
|
||||
}) } })
|
||||
useChatStore.getState().stopGeneration(TEST_SESSION_ID)
|
||||
vi.mocked(sessionsApi.getFullHistory).mockResolvedValue({
|
||||
messages: [
|
||||
{ id: 'user-disk', type: 'user', content: 'Continue', timestamp: new Date(1).toISOString() },
|
||||
{ id: 'answer-disk', type: 'assistant', content: 'Stopped answer', timestamp: new Date(2).toISOString() },
|
||||
],
|
||||
page: { nextCursor: null, hasMore: false, historyComplete: false, sourceVersion: 'omitted-earlier-tool', scannedBytes: 10, omittedOversizedEntries: 1 },
|
||||
})
|
||||
await useChatStore.getState().reloadHistory(TEST_SESSION_ID)
|
||||
await useChatStore.getState().reloadHistory(TEST_SESSION_ID)
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]!.messages).toMatchObject([
|
||||
{ type: 'user_text', content: 'Continue', transcriptMessageId: 'user-disk' },
|
||||
{ type: 'assistant_text', content: 'Stopped answer', transcriptMessageId: 'answer-disk' },
|
||||
])
|
||||
})
|
||||
|
||||
it('shows a bounded page immediately and restores state independently without treating the tail as authoritative', async () => {
|
||||
const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 1024, omittedOversizedEntries: 0 }
|
||||
vi.mocked(sessionsApi.getFullHistory).mockResolvedValueOnce({ messages: [{ id: 'tail', type: 'assistant', content: 'recent', timestamp: '2026-01-01T00:00:00Z' }], page })
|
||||
|
||||
@@ -4102,7 +4102,19 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
||||
!requestedGoalEventIds.has(message.id))
|
||||
const tokenUsageChangedWhileLoading =
|
||||
session.tokenUsage !== requestedTokenUsage
|
||||
const reloadedMessages = liveGoalEventsWhileLoading.length > 0
|
||||
// A bounded read may omit oversized records (including tool calls).
|
||||
// It cannot authoritatively delete rows already received over the live
|
||||
// connection when a stopped turn is reconciled with its transcript.
|
||||
const reloadedMessages = !historyComplete
|
||||
? mergeColdRestoredHistoryIntoLiveMessages(
|
||||
uiMessages,
|
||||
session.messages === sessionAtFetchStart?.messages
|
||||
? dropDuplicateTranscriptTextMessages(
|
||||
mergeRestoredTranscriptMessageIds(session.messages, uiMessages),
|
||||
)
|
||||
: session.messages,
|
||||
)
|
||||
: liveGoalEventsWhileLoading.length > 0
|
||||
? mergeColdRestoredHistoryIntoLiveMessages(
|
||||
uiMessages,
|
||||
liveGoalEventsWhileLoading,
|
||||
|
||||
Reference in New Issue
Block a user