diff --git a/apps/desktop/e2e/large-session-resume.spec.ts b/apps/desktop/e2e/large-session-resume.spec.ts index 02c5f16937d1f..587f541275ddd 100644 --- a/apps/desktop/e2e/large-session-resume.spec.ts +++ b/apps/desktop/e2e/large-session-resume.spec.ts @@ -42,9 +42,15 @@ interface SeededFixture { interface PaintState { bursts: number + pending: boolean timeline: Array<{ mutations: number; time: number }> } +interface PaintBudget { + bursts: number + kind: 'exact' | 'maximum' +} + async function setupSeededDesktop(mockServer?: MockServerOptions): Promise { const mock = await startMockServer(mockServer) const sandbox = createSandbox('large-session') @@ -116,28 +122,86 @@ async function submitPrompt(page: Page, prompt: string): Promise { async function startPaintObserver(page: Page): Promise { await page.evaluate(() => { const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') - const state = { bursts: 0, timeline: [] as Array<{ mutations: number; time: number }> } + const state = { bursts: 0, pending: false, timeline: [] as Array<{ mutations: number; time: number }> } ;(window as Window & { __largeSessionPaints?: typeof state }).__largeSessionPaints = state if (!viewport) return let additions = 0 + let addedUserRows = 0 let flushTimer: ReturnType | undefined - new MutationObserver(records => { - additions += records.reduce( - (count, record) => count + (record.type === 'childList' && record.addedNodes.length > 0 ? 1 : 0), - 0, + let previousUserIds: string[] = [] + + const userIds = () => + Array.from(viewport.querySelectorAll('[data-role="user"][data-message-id]')).map( + row => row.dataset.messageId ?? '', ) + + const flush = () => { + const nextUserIds = userIds() + + if (nextUserIds.length > 0) { + const onlyPrependedBackfill = + nextUserIds.length > previousUserIds.length && + previousUserIds.every((id, index) => id === nextUserIds[nextUserIds.length - previousUserIds.length + index]) + const rebuiltSameRows = + previousUserIds.length > 0 && + previousUserIds.length === nextUserIds.length && + previousUserIds.every((id, index) => id === nextUserIds[index]) && + addedUserRows > 0 + const transcriptChanged = + previousUserIds.length === 0 || + rebuiltSameRows || + (!onlyPrependedBackfill && + (previousUserIds.length !== nextUserIds.length || + previousUserIds.some((id, index) => id !== nextUserIds[index]))) + + if (transcriptChanged) { + state.bursts += 1 + state.timeline.push({ mutations: additions, time: Date.now() }) + } + + previousUserIds = nextUserIds + } + + additions = 0 + addedUserRows = 0 + state.pending = false + } + + new MutationObserver(records => { + for (const record of records) { + if (record.type !== 'childList' || record.addedNodes.length === 0) continue + + additions += 1 + addedUserRows += Array.from(record.addedNodes).filter( + node => + node instanceof Element && + (node.matches('[data-role="user"][data-message-id]') || + node.querySelector('[data-role="user"][data-message-id]')), + ).length + } + if (additions === 0) return + state.pending = true if (flushTimer) clearTimeout(flushTimer) - flushTimer = setTimeout(() => { - state.bursts += 1 - state.timeline.push({ mutations: additions, time: Date.now() }) - additions = 0 - }, 30) + flushTimer = setTimeout(flush, 30) }).observe(viewport, { childList: true, subtree: true }) }) } +async function waitForSettledTranscriptPaint(page: Page): Promise { + await page.waitForFunction( + expected => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + const state = (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints + + return Boolean((viewport?.textContent ?? '').includes(expected) && state && state.bursts >= 1 && !state.pending) + }, + OLDEST_SEEDED_TEXT, + { timeout: 30_000 }, + ) +} + async function paintState(page: Page): Promise { const state = await page.evaluate(() => (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints) expect(state, 'paint observer should attach to the thread viewport').toBeDefined() @@ -166,17 +230,24 @@ async function reloadIntoColdRenderer(fixture: SeededFixture): Promise { await openNewSession(fixture.page) } -async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise { +async function assertUnchangedResume(page: Page, testInfo: TestInfo, budget: PaintBudget): Promise { await openSeededSession(page) - await page.waitForTimeout(1_000) + await waitForSettledTranscriptPaint(page) await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false }) const paints = await paintState(page) expect(await textNodeOccurrences(page, EXPECTED_TEXT), 'the resumed user message should appear once').toBe(1) - // A warm session first restores its retained view, then reconciles it with the - // authoritative transcript. That is bounded at two builds; a third paint was - // the old eager-prefetch + runtime-rebuild regression. A cold restore has one. - expect(paints.bursts, `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}`).toBeLessThanOrEqual(2) + const diagnostic = `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}` + + if (budget.kind === 'exact') { + expect(paints.bursts, diagnostic).toBe(budget.bursts) + } else { + // A warm session first restores its retained view, then reconciles it with + // the authoritative transcript. A third paint is the old eager-prefetch + + // runtime-rebuild regression. + expect(paints.bursts, diagnostic).toBeGreaterThanOrEqual(1) + expect(paints.bursts, diagnostic).toBeLessThanOrEqual(budget.bursts) + } } test.describe('large session resume', () => { @@ -192,7 +263,7 @@ test.describe('large session resume', () => { await waitForAppReady(fixture, 120_000) await startPaintObserver(fixture.page) - await assertUnchangedResume(fixture.page, testInfo) + await assertUnchangedResume(fixture.page, testInfo, { bursts: 1, kind: 'exact' }) }) test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => { @@ -207,7 +278,7 @@ test.describe('large session resume', () => { await openSeededSession(fixture.page) await openNewSession(fixture.page) await startPaintObserver(fixture.page) - await assertUnchangedResume(fixture.page, testInfo) + await assertUnchangedResume(fixture.page, testInfo, { bursts: 2, kind: 'maximum' }) }) for (const resumeKind of ['fast', 'cold'] as const) { @@ -233,9 +304,24 @@ test.describe('large session resume', () => { if (resumeKind === 'cold') { await reloadIntoColdRenderer(fixture) + await startPaintObserver(fixture.page) } await openSeededSession(fixture.page) + + if (resumeKind === 'cold') { + await fixture.page.waitForTimeout(300) + const paints = await paintState(fixture.page) + expect( + paints.bursts, + `unexpected cold live-resume paint count: ${JSON.stringify(paints.timeline)}`, + ).toBeGreaterThanOrEqual(1) + expect( + paints.bursts, + `unexpected cold live-resume paint count: ${JSON.stringify(paints.timeline)}`, + ).toBeLessThanOrEqual(2) + } + fixture.mock.releaseHeldStream() await fixture.page.waitForFunction( expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index c0b0ec63591e3..fb9159376f574 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -16,7 +16,7 @@ import { } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer' -import { requestGatewayForAgent } from '@/store/gateway' +import { requestGatewayForAgent, requestGatewayForProfile } from '@/store/gateway' import { $activeGatewayProfile, $newChatProfile, $newChatRoute, ensureGatewayProfile } from '@/store/profile' import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' import { @@ -50,6 +50,8 @@ import { } from '@/store/session' import type { SessionProfileRoute } from '@/store/session-request-router' import { $sessionTiles } from '@/store/session-states' +import { isWatchWindow } from '@/store/windows' +import type { SessionMessage } from '@/types/hermes' import sessionResumeActiveTurn from '../../../../../../tests/fixtures/session-resume-active-turn.json' import { deferred } from '../../../test/deferred' @@ -78,7 +80,8 @@ vi.mock('@/store/profile', async importOriginal => ({ vi.mock('@/store/gateway', async importOriginal => ({ ...(await importOriginal>()), - requestGatewayForAgent: vi.fn() + requestGatewayForAgent: vi.fn(), + requestGatewayForProfile: vi.fn() })) vi.mock('@/components/pane-shell/tree/store', async importOriginal => ({ @@ -87,11 +90,16 @@ vi.mock('@/components/pane-shell/tree/store', async importOriginal => ({ revealTreePane: vi.fn() })) +vi.mock('@/store/windows', async importOriginal => ({ + ...(await importOriginal>()), + isWatchWindow: vi.fn(() => false) +})) + const RUNTIME_SESSION_ID = 'rt-new-001' type HarnessHandle = Pick< ReturnType, - 'createBackendSessionForSend' | 'removeSession' | 'selectSidebarItem' | 'startFreshSessionDraft' + 'createBackendSessionForSend' | 'removeSession' | 'resumeSession' | 'selectSidebarItem' | 'startFreshSessionDraft' > function storedSession(overrides: Partial = {}): SessionInfo { @@ -806,6 +814,8 @@ describe('resumeSession failure recovery', () => { setResumeFailedSessionId(null) setMessages([]) setSessions([]) + vi.mocked(isWatchWindow).mockReset().mockReturnValue(false) + vi.mocked(requestGatewayForProfile).mockReset() vi.restoreAllMocks() }) @@ -822,6 +832,383 @@ describe('resumeSession failure recovery', () => { await resume!('stored-1', true) } + it('paints REST history while session.resume is still pending', async () => { + const resumeDeferred = deferred() + const persistedMessages = [{ content: 'persisted question', role: 'user', timestamp: 1 }] + + vi.mocked(getLatestSessionMessages).mockResolvedValue({ + messages: persistedMessages, + session_id: 'stored-1' + } as never) + + const requestGateway = vi.fn((method: string) => { + if (method === 'session.resume') { + return resumeDeferred.promise as Promise + } + + return Promise.resolve({} as never) + }) + + let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null + render( (resume = ready)} requestGateway={requestGateway} />) + await waitFor(() => expect(resume).not.toBeNull()) + + const resumeResult = resume!('stored-1', true) + let earlyPaintedMessages = $messages.get() + + try { + await waitFor(() => expect(JSON.stringify($messages.get())).toContain('persisted question')) + earlyPaintedMessages = $messages.get() + expect($activeSessionId.get()).toBeNull() + } finally { + resumeDeferred.resolve({ + info: {}, + message_count: persistedMessages.length, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + session_id: 'runtime-1', + session_key: 'stored-1' + }) + await resumeResult + } + + expect($activeSessionId.get()).toBe('runtime-1') + expect($messages.get()).toBe(earlyPaintedMessages) + }) + + it('does not let a stale REST completion overwrite a newer selected session', async () => { + const stalePrefetch = deferred<{ messages: SessionMessage[]; session_id: string }>() + + vi.mocked(getLatestSessionMessages).mockImplementation(async storedSessionId => { + if (storedSessionId === 'stored-1') { + return stalePrefetch.promise + } + + return { + messages: [{ content: 'newer session history', role: 'user', timestamp: 2 }], + session_id: 'stored-2' + } as never + }) + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.resume') { + return { + info: {}, + message_count: 1, + messages: [], + messages_omitted: true, + resumed: params?.session_id, + session_id: `runtime-${params?.session_id}`, + session_key: params?.session_id + } as never + } + + return {} as never + }) + + let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null + render( (resume = ready)} requestGateway={requestGateway} />) + await waitFor(() => expect(resume).not.toBeNull()) + + const staleResume = resume!('stored-1', true) + await resume!('stored-2', true) + expect(JSON.stringify($messages.get())).toContain('newer session history') + + stalePrefetch.resolve({ + messages: [{ content: 'stale session history', role: 'user', timestamp: 1 }], + session_id: 'stored-1' + }) + await staleResume + + expect(JSON.stringify($messages.get())).toContain('newer session history') + expect(JSON.stringify($messages.get())).not.toContain('stale session history') + expect($activeSessionId.get()).toBe('runtime-stored-2') + }) + + it('keeps a pending row that arrives after the early REST paint', async () => { + const resumeDeferred = deferred() + + const persistedMessages = [ + { content: 'persisted question', role: 'user', timestamp: 1 }, + { content: 'persisted answer', role: 'assistant', timestamp: 2 } + ] + + vi.mocked(getLatestSessionMessages).mockResolvedValue({ + messages: persistedMessages, + session_id: 'stored-1' + } as never) + + const requestGateway = vi.fn((method: string) => { + if (method === 'session.resume') { + return resumeDeferred.promise as Promise + } + + return Promise.resolve({} as never) + }) + + let resumedState: ClientSessionState | undefined + let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null + render( + (resume = ready)} + onStateUpdate={(_sessionId, state) => (resumedState = state)} + requestGateway={requestGateway} + /> + ) + await waitFor(() => expect(resume).not.toBeNull()) + + const resumeResult = resume!('stored-1', true) + await waitFor(() => expect(JSON.stringify($messages.get())).toContain('persisted answer')) + + const messagesAfterEarlyPaint = [ + ...$messages.get(), + { + id: 'pending-user', + role: 'user' as const, + parts: [{ type: 'text' as const, text: 'arrived while resume was pending' }] + } + ] + + act(() => setMessages(messagesAfterEarlyPaint)) + + resumeDeferred.resolve({ + info: {}, + message_count: persistedMessages.length, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + session_id: 'runtime-1', + session_key: 'stored-1' + }) + await resumeResult + + expect(JSON.stringify(resumedState?.messages)).toContain('arrived while resume was pending') + expect(resumedState?.messages).toBe(messagesAfterEarlyPaint) + }) + + it('falls back to REST when resume omits messages and the initial prefetch fails', async () => { + setSessions([storedSession({ profile: 'work' })]) + vi.mocked(getLatestSessionMessages) + .mockReset() + .mockRejectedValueOnce(new Error('initial prefetch failed')) + .mockResolvedValueOnce({ + messages: [{ content: 'history recovered by bound key', role: 'user', timestamp: 1 }], + session_id: 'stored-1' + } as never) + + const requestGateway = vi.fn(async () => ({}) as never) + vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ + info: {}, + message_count: 1, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + session_id: 'runtime-1', + session_key: 'stored-1' + } as never) + + await runResume(requestGateway) + + expect(ensureGatewayProfile).toHaveBeenCalledWith('work') + expect(getLatestSessionMessages).toHaveBeenCalledTimes(2) + expect(getLatestSessionMessages).toHaveBeenNthCalledWith(1, 'stored-1', 'work') + expect(getLatestSessionMessages).toHaveBeenNthCalledWith(2, 'stored-1', 'work') + expect(requestGatewayForProfile).toHaveBeenCalledWith( + 'work', + 'session.resume', + expect.objectContaining({ profile: 'work', session_id: 'stored-1' }), + undefined, + undefined + ) + expect(requestGateway).not.toHaveBeenCalled() + expect(JSON.stringify($messages.get())).toContain('history recovered by bound key') + expect($activeSessionId.get()).toBe('runtime-1') + }) + + it('replaces an identity-mismatched early prefetch using the resume-bound key', async () => { + const resumeDeferred = deferred() + vi.mocked(getLatestSessionMessages) + .mockReset() + .mockResolvedValueOnce({ + messages: [{ content: 'parent session history', role: 'user', timestamp: 1 }], + session_id: 'stored-1' + } as never) + .mockResolvedValueOnce({ + messages: [{ content: 'continuation session history', role: 'user', timestamp: 2 }], + session_id: 'stored-continuation' + } as never) + + const requestGateway = vi.fn((method: string) => { + if (method === 'session.resume') { + return resumeDeferred.promise as Promise + } + + return Promise.resolve({} as never) + }) + + let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null + render( (resume = ready)} requestGateway={requestGateway} />) + await waitFor(() => expect(resume).not.toBeNull()) + + const resumeResult = resume!('stored-1', true) + await waitFor(() => expect(JSON.stringify($messages.get())).toContain('parent session history')) + + resumeDeferred.resolve({ + info: {}, + message_count: 1, + messages: [], + messages_omitted: true, + resumed: 'stored-continuation', + session_id: 'runtime-continuation', + session_key: 'stored-continuation' + }) + await resumeResult + + expect(getLatestSessionMessages).toHaveBeenCalledTimes(2) + expect(getLatestSessionMessages).toHaveBeenNthCalledWith(1, 'stored-1', undefined) + expect(getLatestSessionMessages).toHaveBeenNthCalledWith(2, 'stored-continuation', undefined) + expect(JSON.stringify($messages.get())).toContain('continuation session history') + expect(JSON.stringify($messages.get())).not.toContain('parent session history') + expect($activeSessionId.get()).toBe('runtime-continuation') + }) + + it('arms the failure latch from resume message_count when the sidebar row is absent', async () => { + vi.mocked(getLatestSessionMessages).mockReset().mockRejectedValue(new Error('REST unavailable')) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.resume') { + return { + info: {}, + message_count: 3, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + session_id: 'runtime-1', + session_key: 'stored-1' + } as never + } + + return {} as never + }) + + await runResume(requestGateway) + + expect(getLatestSessionMessages).toHaveBeenCalledTimes(2) + expect($resumeFailedSessionId.get()).toBe('stored-1') + expect($activeSessionId.get()).toBeNull() + }) + + it('binds an empty session when REST fails and resume message_count is zero', async () => { + vi.mocked(getLatestSessionMessages).mockReset().mockRejectedValue(new Error('REST unavailable')) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.resume') { + return { + info: {}, + message_count: 0, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + session_id: 'runtime-1', + session_key: 'stored-1' + } as never + } + + return {} as never + }) + + await runResume(requestGateway) + + expect(getLatestSessionMessages).toHaveBeenCalledTimes(2) + expect($resumeFailedSessionId.get()).toBeNull() + expect($activeSessionId.get()).toBe('runtime-1') + }) + + it.each([true, false])( + 'does not latch a newly created session from its live count when running=%s', + async running => { + const storedSessionId = `created-session-${String(running)}` + + vi.mocked(getLatestSessionMessages) + .mockReset() + .mockResolvedValue({ + messages: [], + session_id: storedSessionId + } as never) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.create') { + return { + info: {}, + message_count: 0, + messages: [], + session_id: `created-runtime-${String(running)}`, + stored_session_id: storedSessionId + } as never + } + + return {} as never + }) + + vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ + info: {}, + message_count: 1, + messages: [], + messages_omitted: true, + resumed: storedSessionId, + running, + session_id: `resumed-runtime-${String(running)}`, + session_key: storedSessionId + } as never) + + let handle: HarnessHandle | null = null + render( (handle = ready)} requestGateway={requestGateway} />) + await waitFor(() => expect(handle).not.toBeNull()) + + await act(async () => { + await handle!.createBackendSessionForSend() + }) + await act(async () => { + await handle!.resumeSession(storedSessionId, true) + }) + + expect($resumeFailedSessionId.get()).toBeNull() + expect($activeSessionId.get()).toBe(`resumed-runtime-${String(running)}`) + } + ) + + it('keeps watch-window lazy resume off the REST transcript path', async () => { + vi.mocked(isWatchWindow).mockReturnValue(true) + vi.mocked(getLatestSessionMessages).mockReset() + let resumeParams: Record | undefined + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.resume') { + resumeParams = params + + return { + info: {}, + message_count: 1, + messages: [{ content: 'watch transcript', role: 'user', timestamp: 1 }], + resumed: 'stored-1', + session_id: 'runtime-watch', + session_key: 'stored-1' + } as never + } + + return {} as never + }) + + await runResume(requestGateway) + + expect(getLatestSessionMessages).not.toHaveBeenCalled() + expect(resumeParams).toMatchObject({ lazy: true, source: 'desktop' }) + expect(resumeParams).not.toHaveProperty('omit_messages') + expect(JSON.stringify($messages.get())).toContain('watch transcript') + expect($activeSessionId.get()).toBe('runtime-watch') + }) + it('arms $resumeFailedSessionId when resume RPC and REST fallback both fail', async () => { // session.resume rejects (e.g. timeout against a wedged backend)... const requestGateway = vi.fn(async (method: string) => { @@ -897,19 +1284,14 @@ describe('resumeSession failure recovery', () => { vi.mocked(getLatestSessionMessages).mockResolvedValue({ messages: storedMessages, session_id: 'stored-1' } as never) - const requestGateway = vi.fn(async (method: string) => { + const resumeDeferred = deferred() + + const requestGateway = vi.fn((method: string) => { if (method === 'session.resume') { - return { - session_id: 'runtime-1', - session_key: 'stored-1', - resumed: 'stored-1', - message_count: 2, - messages: storedMessages, - info: {} - } as never + return resumeDeferred.promise as Promise } - return {} as never + return Promise.resolve({} as never) }) let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null @@ -917,7 +1299,21 @@ describe('resumeSession failure recovery', () => { (resume = r)} requestGateway={requestGateway} selectedStoredSessionId="stored-1" /> ) await waitFor(() => expect(resume).not.toBeNull()) - await resume!('stored-1', true) + const resumeResult = resume!('stored-1', true) + + await waitFor(() => expect(JSON.stringify($messages.get())).toContain('earlier answer')) + expect($messages.get().map(message => message.id)).toContain('user-optimistic') + + resumeDeferred.resolve({ + info: {}, + message_count: storedMessages.length, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + session_id: 'runtime-1', + session_key: 'stored-1' + }) + await resumeResult expect($messages.get().map(message => message.id)).toContain('user-optimistic') }) @@ -1946,7 +2342,7 @@ describe('resumeSession warm-cache mapping integrity', () => { expect(sessionStateByRuntimeIdRef.current.has('rt-recycled')).toBe(false) }) - it('paints the bounded latest transcript after the deferred resume acknowledgement', async () => { + it('paints the bounded latest transcript before the deferred resume acknowledgement', async () => { const latestPage = Array.from({ length: 500 }, (_, index) => ({ content: `message-${index}`, role: index % 2 === 0 ? ('user' as const) : ('assistant' as const), @@ -1981,7 +2377,8 @@ describe('resumeSession warm-cache mapping integrity', () => { await waitFor(() => expect(getLatestSessionMessages).toHaveBeenCalledTimes(1)) expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-A', undefined) - expect($messages.get()).toHaveLength(0) + await waitFor(() => expect($messages.get()).toHaveLength(500)) + const earlyPaintedMessages = $messages.get() expect(requestGatewayMock).toHaveBeenCalledWith( 'session.resume', expect.objectContaining({ @@ -1999,7 +2396,7 @@ describe('resumeSession warm-cache mapping integrity', () => { info: {} }) await resumePromise - expect($messages.get()).toHaveLength(500) + expect($messages.get()).toBe(earlyPaintedMessages) }) it('honours a warm cache entry whose stored id matches and refreshes its persisted transcript', async () => { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 540058dc53773..680360370a4a1 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -1188,6 +1188,42 @@ export function useSessionActions({ let prefetchedStoredSessionId: string | null = null let prefetchedTranscriptMessages: ChatMessage[] | null = null + const publishPersistedMessages = ( + persisted: { messages: SessionMessage[]; session_id?: string }, + discardSettledCandidate = false + ): ChatMessage[] | null => { + if (!isCurrentResume()) { + return null + } + + const currentMessages = $messages.get() + const reconcileMessages = viewMessagesForReconcile() + + const previousMessages = discardSettledCandidate + ? preserveLocalPendingTurnMessages([], currentMessages) + : resumedSameSelectedSession + ? preserveLocalPendingTurnMessages(reconcileMessages, resumeStartMessages) + : reconcileMessages + + // REST hydration is a newest-tail page. Preserve any older pages that + // were already backfilled before reconciling local pending state. + const graftedMessages = graftRefreshedTailOntoBackfill(toChatMessages(persisted.messages), previousMessages) + const reconciledMessages = reconcileAuthoritativeChatMessages(graftedMessages, previousMessages) + + const messagesForView = chatMessageArraysEquivalent(currentMessages, reconciledMessages) + ? currentMessages + : reconciledMessages + + if (messagesForView !== currentMessages) { + setMessages(messagesForView) + } + + prefetchedTranscriptMessages = graftedMessages + localSnapshot = messagesForView + + return messagesForView + } + // REST transcript prefetch and the gateway resume RPC are independent // — run them concurrently so a big session's wall time is // max(prefetch, resume) instead of their sum. The prefetch paints the @@ -1224,9 +1260,9 @@ export function useSessionActions({ // keeps it from surfacing as unhandled while the prefetch settles. resumePromise.catch(() => undefined) - // Keep both requests concurrent, but do not paint the REST result until - // the runtime resume has also settled. An eager prefetch paint followed - // by the runtime projection rebuilds large transcripts during resume. + // Keep both requests concurrent. A successful REST result can paint the + // persisted baseline before the runtime binding settles; final resume + // reconciliation below reuses that view unless it adds real projection. let prefetchedResult: { messages: SessionMessage[]; session_id?: string } | null = null try { @@ -1237,45 +1273,85 @@ export function useSessionActions({ // Non-fatal: gateway resume below can still hydrate the session. } - const resumed = await resumePromise - if (!isCurrentResume()) { return } if (prefetchedResult) { - const previousMessages = resumedSameSelectedSession - ? preserveLocalPendingTurnMessages(viewMessagesForReconcile(), resumeStartMessages) - : viewMessagesForReconcile() - - // Tail page + previously backfilled prefix (same-session re-resume). - const graftedPrefetch = graftRefreshedTailOntoBackfill( - toChatMessages(prefetchedResult.messages), - previousMessages - ) - - prefetchedTranscriptMessages = graftedPrefetch - localSnapshot = reconcileAuthoritativeChatMessages(graftedPrefetch, previousMessages) + publishPersistedMessages(prefetchedResult) prefetchApplied = true prefetchedStoredSessionId = prefetchedResult.session_id || storedSessionId } - const currentMessages = viewMessagesForReconcile() + const resumed = await resumePromise + + if (!isCurrentResume()) { + return + } - // Keep the local snapshot when resume would only reshuffle runtime + // Keep the current view when resume would only reshuffle runtime // projection. When the REST prefetch already hydrated the transcript, // skip converting/reconciling the resume payload entirely — on a // 1000+-message session that second conversion plus the deep // equivalence compare costs over a second of main-thread time. const resumedStoredSessionId = resumed.session_key || resumed.resumed - const prefetchMatchesResumedSession = + let prefetchMatchesResumedSession = !prefetchedStoredSessionId || !resumedStoredSessionId || prefetchedStoredSessionId === resumedStoredSessionId + let usablePrefetch = prefetchApplied && prefetchMatchesResumedSession + + if (resumed.messages_omitted && !usablePrefetch) { + try { + const fallback = await getLatestSessionMessages(resumedStoredSessionId || storedSessionId, sessionRestScope) + + if (!isCurrentResume()) { + return + } + + // A mismatched candidate belongs to another stored identity. Drop + // its settled rows before publishing the resume-bound transcript; + // only local optimistic/pending rows may survive the correction. + const publishedFallback = publishPersistedMessages( + fallback, + prefetchApplied && !prefetchMatchesResumedSession + ) + + if (publishedFallback) { + prefetchApplied = true + prefetchedStoredSessionId = fallback.session_id || resumedStoredSessionId || storedSessionId + prefetchMatchesResumedSession = + !prefetchedStoredSessionId || + !resumedStoredSessionId || + prefetchedStoredSessionId === resumedStoredSessionId + usablePrefetch = prefetchApplied && prefetchMatchesResumedSession + } + } catch { + if (!isCurrentResume()) { + return + } + + if (prefetchApplied && !prefetchMatchesResumedSession) { + const currentMessages = $messages.get() + const pendingMessages = preserveLocalPendingTurnMessages([], currentMessages) + + if (!chatMessageArraysEquivalent(currentMessages, pendingMessages)) { + setMessages(pendingMessages) + } + } + } + } + + const currentMessages = viewMessagesForReconcile() + const hasLiveProjection = Boolean(resumed.inflight || resumed.queued) + // Older/partial gateway responses may omit the echo flag even though + // this client requested omit_messages. A matching successful prefetch + // is sufficient proof that REST is the transcript authority. + const persistedTranscriptIsAuthority = resumed.messages_omitted || usablePrefetch const preferredMessages = (() => { - if (prefetchApplied && prefetchMatchesResumedSession) { + if (usablePrefetch) { if (hasLiveProjection && prefetchedTranscriptMessages) { const runtimeMessages = toChatMessages(resumed.messages) const previousMessages = removeRepresentedLocalLiveProjection(currentMessages, resumed) @@ -1310,7 +1386,9 @@ export function useSessionActions({ } if (!hasLiveProjection) { - return localSnapshot + // The prefetch already painted. Re-read the live view so events + // received while resume settled cannot be rolled back. + return currentMessages } } @@ -1318,7 +1396,11 @@ export function useSessionActions({ ? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages) : currentMessages - const resumedMessages = reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed) + // Omitted, not empty: without a usable REST transcript the response + // may contribute only its live projection, never an empty authority. + const resumedMessages = persistedTranscriptIsAuthority + ? appendLiveSessionProjection(currentMessages, resumed) + : reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed) return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages })() @@ -1364,7 +1446,11 @@ export function useSessionActions({ // must not mask a lost transcript (a retry that reloads real history // is safer than surfacing the in-flight turn alone). Recovery only // ever appends, so this matches the final transcript's emptiness. - if (sessionShouldHaveTranscript(stored) && preferredMessages.length === 0) { + const responseClaimsHistory = !createdThisRun.has(storedSessionId) && (resumed.message_count ?? 0) > 0 + + const shouldHaveTranscript = sessionShouldHaveTranscript(stored) || responseClaimsHistory + + if (shouldHaveTranscript && preferredMessages.length === 0) { // Roll back a provisional cached-tail paint and drop its entry: the // authoritative sources say this session has no transcript, so the // cache no longer reflects backend truth and must not survive to @@ -1426,9 +1512,8 @@ export function useSessionActions({ ) // updateSessionState stages its view sync through requestAnimationFrame. - // Commit the final, already-reconciled transcript now so resume has one - // additive DOM build instead of an eager prefetch build plus a later - // runtime projection build. + // Publish only when runtime projection or journal recovery materially + // changed the already-visible persisted baseline. if (!chatMessageArraysEquivalent($messages.get(), messagesForView)) { setMessages(messagesForView) } diff --git a/docs/design/issue-83729-desktop-stored-session-resume.md b/docs/design/issue-83729-desktop-stored-session-resume.md new file mode 100644 index 0000000000000..c536f4b9b5451 --- /dev/null +++ b/docs/design/issue-83729-desktop-stored-session-resume.md @@ -0,0 +1,279 @@ +# Issue 83729: Desktop Stored Session Resume + +**Issue:** [#83729](https://github.com/NousResearch/hermes-agent/issues/83729) + +## Problem + +Opening a persisted Desktop session can leave the thread blank even though its +REST transcript is available. Desktop starts `getLatestSessionMessages()` and +`session.resume` concurrently, but the cold path does not publish the REST +result until the RPC settles. A delayed RPC therefore holds usable persisted +history behind the loading state. + +The failure is amplified by two independent mistakes: + +- When `messages_omitted` is true and the REST prefetch is unavailable, the + client still reconciles `resumed.messages`. The gateway deliberately returns + `messages: []` in that case, so the client turns "not carried in this payload" + into an authoritative empty transcript. +- The empty-transcript latch consults only the renderer's cached sidebar row. + When that row is missing or stale, it ignores the resume response's + `message_count` and accepts the fabricated empty transcript. + +The stored session in #83729 is valid. Its durable ID and database history are +separate from the process-local runtime ID that `session.resume` binds. + +## Existing Constraints + +Commit `73c7f68456` fixed #69649 by removing eager REST publication before the +runtime projection rebuild. That change prevents duplicate inflight user rows +and keeps large transcripts from being built three times. This fix deliberately +reintroduces an early publication point, so it must preserve the constraints of +that change rather than simply revert it. + +`apps/desktop/e2e/large-session-resume.spec.ts` is the performance contract: + +- an unchanged cold resume builds the transcript once; +- a cold resume with a live projection, an identity correction, or a recovered + local journal tail may build the persisted base and then one corrected result, + for at most two paint bursts; +- a third paint is the old eager-prefetch regression. + +All existing request-generation and selected-session guards remain mandatory. +An older async result must never overwrite a newer foreground selection. + +## Goals + +- Publish a valid REST transcript as soon as it arrives, without waiting for + `session.resume`. +- Never treat `messages_omitted: true` plus `messages: []` as an empty + authoritative transcript. +- Preserve same-session optimistic user rows and pending assistant rows. +- Merge rows that arrive after early REST publication instead of reverting to + the prefetch-time snapshot. +- Reuse the published message array when the RPC adds no live projection. +- Enter the existing bounded retry and explicit error state when available + metadata says history exists but no transcript can be painted. +- Preserve the #69649 large-session paint budget. + +## Chosen Design + +### Independent REST Publication + +The REST prefetch and `session.resume` remain concurrent. The prefetch gets an +independent completion handler which: + +1. verifies that the request generation and selected stored session are still + current; +2. reconciles the REST rows against `$messages.get()` at completion time; +3. preserves same-session optimistic/pending rows and local assistant errors; +4. calls `setMessages` only when the result is not content-equivalent; and +5. retains the successful response as a candidate for final runtime + reconciliation. + +A stale completion is discarded. It does not paint, bind a runtime, or arm a +failure latch. + +The prefetch cannot be declared usable until the RPC supplies the bound stored +identity. A **usable prefetch** is therefore exactly one which resolved +successfully and whose `session_id` matches `resumed.session_key` / +`resumed.resumed` (with the existing missing-identity compatibility allowance). +Before the RPC settles, a guarded early publication is only a candidate view. + +### Omitted Messages Are Not Empty Messages + +After the RPC settles, `messages_omitted` controls the source of the transcript: + +- With a usable prefetch, the already-published REST transcript is the base. +- A usable prefetch remains the transcript authority when an older gateway + honors the client's `omit_messages` request but does not echo + `messages_omitted`. Any `inflight` or `queued` projection from that response + is grafted onto the REST baseline instead of replacing it. +- Without a usable prefetch, the hook makes one authoritative REST fallback + request using the stored key bound by the resume response (falling back to the + requested key only when the response omits its identity). It never passes + `resumed.messages` to + `reconcileAuthoritativeMessages` when `messages_omitted` is true. +- If the fallback succeeds, it is reconciled and published through the same + guarded path. This is a transcript recovery, not an RPC failure, so it does + not emit a synthetic "Resume failed" notification. +- If the fallback fails, the hook evaluates the empty-transcript failure rule + below. + +An identity mismatch is the important third unusable-prefetch case, alongside a +failed request and an empty result. The hook discards the candidate REST baseline +before rebuilding from the fallback response. It may preserve only local +optimistic/pending rows through the existing pending-turn reconciler; settled +rows from the mismatched candidate cannot remain in the foreground. Publishing +the corrected identity is a legitimate second paint and remains inside the +#69649 budget. + +For watch windows, the request remains `lazy: true` without `omit_messages` and +the current no-prefetch behavior is unchanged. + +### Settle Against the Latest View + +Final reconciliation reads `$messages.get()` again after the RPC and any REST +fallback settle. It does not reuse the prefetch-time array as the previous +state. This protects optimistic rows and stream events that arrived between the +early paint and runtime binding. + +If the RPC carries `inflight` or `queued`, `appendLiveSessionProjection` grafts +that tail onto the latest view. Otherwise the exact current array is written +into the per-runtime state. The subsequent `syncSessionStateToView` therefore +sees the same reference/content and does not cause another transcript paint. + +`recoverInFlightTurnJournal` remains after runtime reconciliation. A journaled +tail recovered after a renderer/app crash is another legitimate second paint; +it must not be suppressed to make the unchanged-session budget pass. + +This yields one paint for an unchanged cold resume and at most two for a cold +resume that adds a real live tail, corrects a mismatched identity, or restores a +journal tail. + +## Empty-Transcript Failure Rule + +The hook treats history as expected when either source says it exists: + +```text +sidebar row message_count > 0 +OR +( + resume response message_count > 0 + AND session was not created by this renderer run +) +``` + +The second clause absolutely excludes `createdThisRun` from the runtime-count +source, whether the new session is running or idle. This avoids treating an +unpersisted first turn that failed or was interrupted as proof of a missing REST +display transcript. A real persisted count on its sidebar row remains the other +side of the OR condition. + +The response count is used only as a `> 0` signal; its numeric value is never +compared with the REST row count because the gateway branches expose different +projections: + +- deferred cold resume counts alternation-repaired raw history; +- lazy/watch resume counts display history; +- live reuse counts in-memory history plus any ancestor prefix. + +A branch can have zero raw rows while its visible transcript comes from an +ancestor prefix. Its sidebar row remains the other side of the OR condition. + +When history is expected and the pre-recovery transcript is empty, the hook +clears the foreground runtime binding and arms `$resumeFailedSessionId`. The +existing four-attempt exponential backoff then retries and ultimately displays +the existing inline ErrorState with manual Retry. + +## Runtime Lifecycle + +The latch path must not call `session.close` on `resumed.session_id`. A successful +resume registers the runtime under the stored session key; subsequent retries +hit `_find_live_session_by_key` and reuse that runtime rather than minting four +records. More importantly, a reused runtime may be the valid auto-continue run +whose transcript the renderer is trying to recover. Closing it would destroy the +feature being repaired. + +A client-side RPC timeout can still orphan a server record if the server +registers it but its response never arrives. Existing gateway session-cap +enforcement owns that cleanup; adding cooperative RPC cancellation is outside +this renderer fix. + +## Residual Failure Window + +If the initial REST prefetch fails while `session.resume` is still pending, +there is no transcript to paint and no resume-bound identity for the second REST +attempt yet. Although the shared client library defaults to 120 seconds, +`HermesGateway` overrides Desktop's effective request timeout to 30 seconds. +After that timeout, the current RPC-failure catch path performs its REST fallback +and arms retry/error recovery if that also fails while cached metadata says +history exists. + +This change removes the unbounded blank state when REST succeeds, which is the +reported #83729 case, but it does not shorten the dual-failure window. Starting +automatic retries before the pending RPC ends would create concurrent resumes. +A follow-up should add cancellable resume requests before adopting a shorter +deadline. + +When `messages_omitted` is true, both REST attempts fail, and the RPC still +provides `inflight` or `queued`, the existing degraded behavior renders only +that live projection. Because the foreground is then non-empty, the missing- +history latch does not arm. This tail-only view predates the fix and remains a +known residual case rather than broadening this change into partial-history UI. + +If an early prefetch paints successfully but `session.resume` later rejects, +the existing RPC-failure path performs another REST request instead of reusing +the painted result. Reusing it would remove a redundant request, but requires +hoisting successful-prefetch state across the catch boundary and is left as a +separate optimization. + +## Non-Goals + +- **Genuine 404 navigation:** the current code sends a prior-run stale route to + a fresh draft when both RPC and REST report the session gone. Distinguishing a + boot-time stale route from a user click requires carrying an explicit resume + reason through `useRouteResume`; changing that UX is a separate follow-up and + is not required to fix the valid session in #83729. +- **Warm-cache policy:** the warm path's stale sidebar check is unchanged. A + known non-empty row with an empty cached view is evicted and falls through to + this corrected cold path. Broadening warm-cache behavior would mix activation + recovery into this fix. +- New UI, new timeout settings, or a resume state-machine rewrite. +- Changes to watch-window lazy resume. + +No follow-up issue is created as part of this change; issue creation is an +external tracking action and should be requested separately. + +## Tests + +Hook tests in `use-session-actions.test.tsx` must cover: + +1. REST history paints while `session.resume` is still pending. +2. A stale REST completion cannot overwrite a newer selected session. +3. REST prefetch failure plus `messages_omitted` triggers one REST fallback and + never reconciles the omitted empty array. +4. REST failure plus `message_count > 0` arms the latch when the sidebar row is + absent. +5. REST failure plus `message_count === 0` binds a legitimate empty session and + does not arm the latch. +6. A newly created session, both running and idle cases, is not falsely latched + from its live count. +7. A stream/pending row arriving after early paint survives final settle. +8. Same-session reconnect early paint preserves an optimistic user message. +9. A watch window performs no REST prefetch and keeps its current lazy behavior. +10. An unchanged resume retains the already-published array through final + per-runtime state synchronization. +11. An identity-mismatched early prefetch is replaced from the resume-bound + stored key and cannot leave the candidate session's settled rows in the + foreground. + +The large-session Electron E2E must cover both paint budgets: + +- unchanged cold resume: exactly one transcript paint burst; +- cold resume with background inference/live projection: at most two bursts, + with no duplicate user or assistant rows. + +## Acceptance Criteria + +- With a pending `session.resume` and successful REST response, persisted + messages become visible before the RPC resolves. +- `messages_omitted: true` is never interpreted as authoritative empty history. +- A response reporting expected history cannot settle into + `messagesEmpty && !activeSessionId` without arming recovery. +- New empty sessions and watch windows retain their current behavior. +- An async result from a superseded resume attempt cannot affect the foreground. +- The focused hook suite, Desktop typecheck and lint pass. +- Both cold large-session E2E paint budgets pass, including row de-duplication. + +## Risks And Rollback + +The main risk is reintroducing #69649 by publishing the same large transcript +more than once or by appending an inflight user row already present in REST. The +reference/content-equivalence checks, current-view settle rule and Electron +paint-budget tests are the release guards. + +The change is renderer-local. If it regresses transcript ordering or paint +counts, it can be rolled back without gateway or database migration. The prior +behavior is restored by removing independent REST publication while retaining +the `messages_omitted` source guard and response-count latch as separable fixes. diff --git a/docs/superpowers/plans/2026-08-11-issue-83729-desktop-stored-session-resume.md b/docs/superpowers/plans/2026-08-11-issue-83729-desktop-stored-session-resume.md new file mode 100644 index 0000000000000..83b00a6b9b4e0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-issue-83729-desktop-stored-session-resume.md @@ -0,0 +1,363 @@ +# Issue 83729 Desktop Stored Session Resume Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan. + +**Goal:** Make a valid persisted Desktop transcript visible as soon as REST returns, while preserving runtime projection correctness, retry semantics, and the #69649 paint budget. + +**Architecture:** Keep the existing concurrent REST prefetch and `session.resume` request. Publish the guarded REST result as a candidate view immediately, then validate its stored-session identity when the RPC settles. If messages were omitted and that candidate is unavailable or mismatched, fetch once by the resume-bound stored key; finally reconcile live projection and journal recovery against the latest foreground array. + +**Tech Stack:** React 19, TypeScript, nanostores, Vitest/Testing Library, Electron Playwright. + +--- + +## Task 1: Lock Down Candidate Prefetch Publication + +**Files:** +- Modify: `apps/desktop/src/app/session/hooks/use-session-actions.test.tsx` +- Modify: `apps/desktop/src/app/session/hooks/use-session-actions/index.ts` + +### Step 1: Write the failing early-publication test + +Add a deferred `session.resume` test in `resumeSession failure recovery`: + +```ts +it('paints REST history while session.resume is still pending', async () => { + const resumeDeferred = deferred() + vi.mocked(getLatestSessionMessages).mockResolvedValue({ + messages: [{ content: 'persisted question', role: 'user', timestamp: 1 }], + session_id: 'stored-1' + } as never) + + // Start resume without awaiting it, then assert the REST row appears while + // activeSessionId remains null. Resolve the RPC and await completion. +}) +``` + +Assert that the persisted row is visible before `resumeDeferred.resolve(...)`, that the runtime is not bound early, and that the final resume succeeds. + +### Step 2: Run the focused test and confirm RED + +Run: + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx -t "paints REST history while session.resume is still pending" +``` + +Expected: FAIL because the current hook awaits `resumePromise` before publishing `prefetchedResult`. + +### Step 3: Publish a guarded candidate view from the prefetch completion + +In `resumeSession` cold-path code, add a local guarded publisher that: + +```ts +function publishPersistedMessages( + result: { messages: SessionMessage[]; session_id?: string }, + mode: 'candidate' | 'authoritative' +): ChatMessage[] | null { + if (!isCurrentResume()) return null + + const currentMessages = $messages.get() + const previousMessages = + mode === 'authoritative' + ? preserveLocalPendingTurnMessages([], currentMessages) + : resumedSameSelectedSession + ? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages) + : currentMessages + const nextMessages = reconcileAuthoritativeMessages(result.messages, previousMessages) + const messagesForView = chatMessageArraysEquivalent(currentMessages, nextMessages) ? currentMessages : nextMessages + + if (messagesForView !== currentMessages) setMessages(messagesForView) + return messagesForView +} +``` + +Adapt names/types to local style. The important contracts are: + +- call `isCurrentResume()` immediately before mutating the foreground; +- read `$messages.get()` at completion time; +- preserve same-session optimistic/pending rows and assistant errors through the existing reconciler; +- use content equivalence so an unchanged candidate reuses the current array; +- do not bind `activeSessionId` from the REST result. + +Await the prefetch while `resumePromise` runs concurrently, publish a successful result immediately, retain its `session_id`, then await the RPC. + +### Step 4: Add stale-completion and same-session tests + +Add tests that: + +- start resume A with deferred REST, complete resume B, then resolve A and prove A cannot replace B's foreground; +- reconnect the currently selected stored session, keep an optimistic user row visible through the early candidate paint, then resolve the RPC and prove the row remains. + +### Step 5: Add latest-view settle test + +After the candidate paints, append a pending/stream row with `setMessages`, resolve an omitted-message RPC without a live projection, and assert final state still contains that row. Also assert the final state uses the current message array when no correction is needed. + +### Step 6: Run focused tests and confirm GREEN + +Run: + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx -t "REST history|stale REST|same-session reconnect|arriving after early paint" +``` + +Expected: PASS. + +### Step 7: Commit the candidate-publication slice + +```bash +git add apps/desktop/src/app/session/hooks/use-session-actions/index.ts apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +git commit -m "fix(desktop): paint stored transcript before resume settles" +``` + +## Task 2: Make Omitted-Message Recovery Identity-Safe + +**Files:** +- Modify: `apps/desktop/src/app/session/hooks/use-session-actions.test.tsx` +- Modify: `apps/desktop/src/app/session/hooks/use-session-actions/index.ts` + +### Step 1: Write the failing omitted-message fallback tests + +Add one test for each unusable prefetch form: + +1. Initial REST request rejects; RPC succeeds with `messages_omitted: true`; the hook calls REST once more using the resume-bound key, paints that transcript, and never treats `resumed.messages: []` as authoritative. +2. Candidate REST response identifies `stored-parent`; RPC binds `stored-continuation`; the hook fetches `stored-continuation`, replaces the parent baseline, and leaves no parent settled rows in the foreground. + +For the mismatch case, assert the REST calls are ordered as: + +```ts +expect(getLatestSessionMessages).toHaveBeenNthCalledWith(1, 'stored-1', null) +expect(getLatestSessionMessages).toHaveBeenNthCalledWith(2, 'stored-continuation', null) +``` + +Use the actual profile argument expected by the hook if it differs. + +### Step 2: Run the new tests and confirm RED + +Run: + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx -t "omitted|identity-mismatched" +``` + +Expected: FAIL because the current fallback is only in the RPC catch path and the success path reconciles omitted `messages: []` when prefetch is unusable. + +### Step 3: Define and enforce usable prefetch + +After `resumePromise` settles, compute: + +```ts +const resumedStoredSessionId = resumed.session_key || resumed.resumed || storedSessionId +const prefetchMatchesResumedSession = + !prefetchedStoredSessionId || + !resumedStoredSessionId || + prefetchedStoredSessionId === resumedStoredSessionId +const usablePrefetch = prefetchApplied && prefetchMatchesResumedSession +``` + +When `resumed.messages_omitted` is true and `usablePrefetch` is false: + +- make one `getLatestSessionMessages(resumedStoredSessionId, sessionProfile)` request; +- guard its completion with `isCurrentResume()`; +- publish it in authoritative mode, which retains only local optimistic/pending rows from the current foreground; +- update the accepted baseline identity; +- swallow fallback failure so the existing latch rule decides recovery; +- do not send a synthetic RPC failure notification. + +If the candidate identity mismatched and the fallback fails, strip its settled rows before evaluating the latch, preserving only local pending rows. Never pass `resumed.messages` to `reconcileAuthoritativeMessages` when `messages_omitted` is true. + +### Step 4: Reconcile the runtime projection against the latest view + +Re-read `$messages.get()` after identity validation/fallback. For omitted messages, use: + +```ts +const projected = appendLiveSessionProjection(currentMessages, resumed) +const preferredMessages = chatMessageArraysEquivalent(currentMessages, projected) + ? currentMessages + : projected +``` + +For non-omitted watch/lazy responses, retain the existing authoritative resume reconciliation. Leave `recoverInFlightTurnJournal` after this step so a restored journal tail remains a legitimate second paint. + +### Step 5: Run the focused tests and confirm GREEN + +Run: + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx -t "omitted|identity-mismatched|in-flight turn" +``` + +Expected: PASS, including the existing inflight/queued projection test. + +### Step 6: Commit the identity-safe fallback slice + +```bash +git add apps/desktop/src/app/session/hooks/use-session-actions/index.ts apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +git commit -m "fix(desktop): recover omitted resume history by bound identity" +``` + +## Task 3: Strengthen the Empty-Transcript Failure Rule + +**Files:** +- Modify: `apps/desktop/src/app/session/hooks/use-session-actions.test.tsx` +- Modify: `apps/desktop/src/app/session/hooks/use-session-actions/index.ts` + +### Step 1: Add response-count failure tests + +Cover both sides of the response-count predicate while the sidebar has no matching row: + +- fallback unavailable/empty plus `resumed.message_count > 0` arms `$resumeFailedSessionId`; +- fallback empty plus `resumed.message_count === 0` binds a legitimate empty session and leaves the latch clear. + +Use `messages_omitted: true` in both responses so the tests exercise the fixed path. + +### Step 2: Add newly-created-session tests + +Exercise `createBackendSessionForSend` (or the narrowest existing create harness) so the returned stored ID enters `createdThisRun`. Resume that ID with an empty REST display transcript and a positive runtime `message_count`, parameterized for `running: true` and `running: false`. Assert neither case arms the latch. Do not seed a positive sidebar `message_count`, because that remains an independent expected-history signal. + +### Step 3: Run the tests and confirm RED + +Run: + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx -t "message_count|newly created session" +``` + +Expected: the response-count test fails because the current latch only reads the sidebar row. + +### Step 4: Implement the combined predicate + +Immediately before journal recovery can mask transcript emptiness, calculate: + +```ts +const responseClaimsHistory = + !createdThisRun.has(storedSessionId) && (resumed.message_count ?? 0) > 0 +const shouldHaveTranscript = sessionShouldHaveTranscript(stored) || responseClaimsHistory +``` + +Arm the existing latch when `shouldHaveTranscript && preferredMessages.length === 0`. Keep the current no-`session.close` lifecycle and reuse behavior. + +### Step 5: Add and verify the watch-window regression test + +Mock `isWatchWindow()` per test if necessary. Assert a watch resume sends `{ lazy: true }`, omits `omit_messages`, makes no REST request, and uses the RPC-carried transcript exactly as before. + +Run: + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx -t "message_count|newly created session|watch window" +``` + +Expected: PASS. + +### Step 6: Commit the latch slice + +```bash +git add apps/desktop/src/app/session/hooks/use-session-actions/index.ts apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +git commit -m "fix(desktop): detect missing history from resume metadata" +``` + +## Task 4: Enforce the #69649 Paint Budget in Electron E2E + +**Files:** +- Modify: `apps/desktop/e2e/large-session-resume.spec.ts` + +### Step 1: Make the unchanged cold budget exact + +Parameterize `assertUnchangedResume` with an expected maximum or exact budget. The cold test must assert: + +```ts +expect(paints.bursts, diagnostic).toBe(1) +``` + +Keep the known warm-resume `fixme` separate and capped at two if it is later re-enabled. + +### Step 2: Observe the cold live-projection resume + +For the `cold resume keeps background inference attached` case, attach the mutation observer after `reloadIntoColdRenderer` and before `openSeededSession`. After the held stream completes, assert: + +```ts +expect(paints.bursts, diagnostic).toBeLessThanOrEqual(2) +``` + +Retain the existing exact one-row assertions for the running user prompt and completed assistant reply. + +### Step 3: Run the focused Electron tests + +Run: + +```bash +cd apps/desktop +npm run build +npx playwright test e2e/large-session-resume.spec.ts --grep "cold resume" +``` + +Expected: the unchanged case records exactly one paint burst; the background/live case records no more than two and has no duplicate rows. + +### Step 4: Commit the E2E contract + +```bash +git add apps/desktop/e2e/large-session-resume.spec.ts +git commit -m "test(desktop): enforce stored resume paint budget" +``` + +## Task 5: Full Verification and Review + +**Files:** +- Review: `apps/desktop/src/app/session/hooks/use-session-actions/index.ts` +- Review: `apps/desktop/src/app/session/hooks/use-session-actions.test.tsx` +- Review: `apps/desktop/e2e/large-session-resume.spec.ts` + +### Step 1: Run the complete hook suite + +```bash +cd apps/desktop +npx vitest run src/app/session/hooks/use-session-actions.test.tsx +``` + +Expected: PASS. + +### Step 2: Run static checks + +```bash +cd apps/desktop +npm run typecheck +npm run lint +``` + +Expected: both commands exit 0. + +### Step 3: Run the scoped E2E suite again after the final build + +```bash +cd apps/desktop +npm run build +npx playwright test e2e/large-session-resume.spec.ts --grep "cold resume" +``` + +Expected: PASS with the specified paint and de-duplication budgets. + +### Step 4: Inspect the final diff for lifecycle and scope regressions + +```bash +git diff origin/main --check +git diff --stat origin/main +git diff origin/main -- apps/desktop/src/app/session/hooks/use-session-actions/index.ts apps/desktop/src/app/session/hooks/use-session-actions.test.tsx apps/desktop/e2e/large-session-resume.spec.ts +``` + +Verify explicitly: + +- no `session.close` was added to the latch path; +- no new timeout, UI state, or warm-cache policy was introduced; +- watch-window lazy resume remains intact; +- omitted messages are never reconciled as authoritative history; +- final reconciliation reads `$messages.get()` after all async work; +- unchanged cold resume stays at one paint and correction/live/journal cases stay within two. + +### Step 5: Commit any verification-only adjustments + +If verification required a code or test adjustment, commit it with a focused message. Otherwise leave the preceding implementation commits unchanged.