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 f47b6b62504..f165504ad5e 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 @@ -3,9 +3,18 @@ import type { MutableRefObject } from 'react' import { useEffect } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getSessionMessages } from '@/hermes' +import { getSessionMessages, type SessionInfo } from '@/hermes' import { $activeGatewayProfile, $newChatProfile } from '@/store/profile' -import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session' +import { + $activeSessionId, + $currentCwd, + $messages, + $resumeFailedSessionId, + setActiveSessionId, + setMessages, + setResumeFailedSessionId, + setSessions +} from '@/store/session' import type { ClientSessionState } from '../../types' @@ -22,6 +31,25 @@ vi.mock('@/hermes', async importOriginal => ({ const RUNTIME_SESSION_ID = 'rt-new-001' +function storedSession(overrides: Partial = {}): SessionInfo { + return { + ended_at: null, + id: 'stored-1', + input_tokens: 0, + is_active: false, + last_active: 1, + message_count: 0, + model: null, + output_tokens: 0, + preview: null, + source: 'desktop', + started_at: 1, + title: 'stored', + tool_call_count: 0, + ...overrides + } +} + function Harness({ onReady, requestGateway @@ -126,10 +154,14 @@ describe('createBackendSessionForSend profile routing', () => { // succeeds must NOT leave the flag armed. function ResumeHarness({ onReady, - requestGateway + requestGateway, + runtimeIdByStoredSessionIdRef, + sessionStateByRuntimeIdRef }: { onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise) => void requestGateway: (method: string, params?: Record) => Promise + runtimeIdByStoredSessionIdRef?: MutableRefObject> + sessionStateByRuntimeIdRef?: MutableRefObject> }) { const ref = (value: T): MutableRefObject => ({ current: value }) @@ -142,10 +174,10 @@ function ResumeHarness({ getRouteToken: () => 'token', navigate: vi.fn() as never, requestGateway, - runtimeIdByStoredSessionIdRef: ref(new Map()), + runtimeIdByStoredSessionIdRef: runtimeIdByStoredSessionIdRef ?? ref(new Map()), selectedStoredSessionId: null, selectedStoredSessionIdRef: ref(null), - sessionStateByRuntimeIdRef: ref(new Map()), + sessionStateByRuntimeIdRef: sessionStateByRuntimeIdRef ?? ref(new Map()), syncSessionStateToView: vi.fn(), updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState) }) @@ -160,16 +192,22 @@ function ResumeHarness({ describe('resumeSession failure recovery', () => { afterEach(() => { cleanup() + setActiveSessionId(null) setResumeFailedSessionId(null) setMessages([]) + setSessions([]) vi.restoreAllMocks() }) async function runResume( - requestGateway: (method: string, params?: Record) => Promise + requestGateway: (method: string, params?: Record) => Promise, + options: { + runtimeIdByStoredSessionIdRef?: MutableRefObject> + sessionStateByRuntimeIdRef?: MutableRefObject> + } = {} ): Promise { let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null - render( (resume = r)} requestGateway={requestGateway} />) + render( (resume = r)} requestGateway={requestGateway} {...options} />) await waitFor(() => expect(resume).not.toBeNull()) await resume!('stored-1', true) } @@ -281,4 +319,84 @@ describe('resumeSession failure recovery', () => { expect(resumeParams).not.toHaveProperty('lazy') expect(resumeParams).not.toHaveProperty('eager_build') }) + + it('arms the failure latch when resume succeeds with an empty transcript for a non-empty stored session', async () => { + setSessions([storedSession({ message_count: 4 })]) + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.resume') { + return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never + } + + return {} as never + }) + + vi.mocked(getSessionMessages).mockResolvedValue({ messages: [], session_id: 'stored-1' } as never) + + await runResume(requestGateway) + + expect($resumeFailedSessionId.get()).toBe('stored-1') + expect($activeSessionId.get()).toBeNull() + expect($messages.get()).toEqual([]) + }) + + it('does not reuse an empty cached runtime view for a stored session with history', async () => { + const runtimeIdByStoredSessionIdRef = { + current: new Map([['stored-1', 'runtime-stale']]) + } satisfies MutableRefObject> + const sessionStateByRuntimeIdRef = { + current: new Map([ + [ + 'runtime-stale', + { + awaitingResponse: false, + branch: '', + busy: false, + cwd: '', + fast: false, + interrupted: false, + messages: [], + model: '', + needsInput: false, + pendingBranchGroup: null, + personality: '', + provider: '', + reasoningEffort: '', + sawAssistantPayload: false, + serviceTier: '', + storedSessionId: 'stored-1', + streamId: null, + turnStartedAt: null, + yolo: false + } + ] + ]) + } satisfies MutableRefObject> + + setSessions([storedSession({ message_count: 4 })]) + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.resume') { + return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never + } + + return {} as never + }) + + vi.mocked(getSessionMessages).mockResolvedValue({ + messages: [{ content: 'existing text', role: 'user', timestamp: 1 }], + session_id: 'stored-1' + } as never) + + await runResume(requestGateway, { + runtimeIdByStoredSessionIdRef, + sessionStateByRuntimeIdRef + }) + + expect(requestGateway).not.toHaveBeenCalledWith('session.usage', { session_id: 'runtime-stale' }) + expect(runtimeIdByStoredSessionIdRef.current.has('stored-1')).toBe(false) + expect(sessionStateByRuntimeIdRef.current.has('runtime-stale')).toBe(false) + expect($activeSessionId.get()).toBe('runtime-1') + expect($messages.get().length).toBe(1) + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index fb06c5a6048..c8dd9371d31 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -221,6 +221,10 @@ function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): return session.id === storedSessionId || session._lineage_root_id === storedSessionId } +function sessionShouldHaveTranscript(session: SessionInfo | undefined): boolean { + return (session?.message_count ?? 0) > 0 +} + function upsertResolvedSession(session: SessionInfo, storedSessionId: string) { const lineage = session._lineage_root_id ?? session.id @@ -616,7 +620,7 @@ export function useSessionActions({ const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId) if (cachedRuntimeId && cachedState) { - const stored = $sessions.get().find(session => session.id === storedSessionId) + const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile const cachedViewState = !cachedState.model && stored?.model != null @@ -630,41 +634,46 @@ export function useSessionActions({ sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState) } - setFreshDraftReady(false) - clearNotifications() - setSelectedStoredSessionId(storedSessionId) - selectedStoredSessionIdRef.current = storedSessionId - setActiveSessionId(cachedRuntimeId) - activeSessionIdRef.current = cachedRuntimeId - syncSessionStateToView(cachedRuntimeId, cachedViewState) - setCurrentCwd(cachedViewState.cwd) - setCurrentBranch(cachedViewState.branch) - setSessionStartedAt(Date.now()) - - try { - const usage = await requestGateway('session.usage', { session_id: cachedRuntimeId }) - - if (!isCurrentResume()) { - return - } - - if (usage) { - setCurrentUsage(current => ({ ...current, ...usage })) - } - - return - } catch { - // The cached runtime id was minted by a prior backend instance. A - // pooled profile backend that gets idle-reaped (pruneSecondaryGateways) - // and respawned across a profile swap mints fresh ids, so this mapping - // now 404s ("session not found"). Drop it and fall through to a full - // resume that rebinds a live runtime id. - if (!isCurrentResume()) { - return - } - + if (sessionShouldHaveTranscript(stored) && cachedViewState.messages.length === 0) { runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId) + } else { + setFreshDraftReady(false) + clearNotifications() + setSelectedStoredSessionId(storedSessionId) + selectedStoredSessionIdRef.current = storedSessionId + setActiveSessionId(cachedRuntimeId) + activeSessionIdRef.current = cachedRuntimeId + syncSessionStateToView(cachedRuntimeId, cachedViewState) + setCurrentCwd(cachedViewState.cwd) + setCurrentBranch(cachedViewState.branch) + setSessionStartedAt(Date.now()) + + try { + const usage = await requestGateway('session.usage', { session_id: cachedRuntimeId }) + + if (!isCurrentResume()) { + return + } + + if (usage) { + setCurrentUsage(current => ({ ...current, ...usage })) + } + + return + } catch { + // The cached runtime id was minted by a prior backend instance. A + // pooled profile backend that gets idle-reaped (pruneSecondaryGateways) + // and respawned across a profile swap mints fresh ids, so this mapping + // now 404s ("session not found"). Drop it and fall through to a full + // resume that rebinds a live runtime id. + if (!isCurrentResume()) { + return + } + + runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) + sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId) + } } } @@ -678,7 +687,7 @@ export function useSessionActions({ setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId setSessionStartedAt(Date.now()) - const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) + const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile applyStoredSessionPreviewRuntimeInfo(stored) if (stored) { @@ -767,6 +776,15 @@ export function useSessionActions({ ? currentMessages : preserveLocalAssistantErrors(preferredMessages, currentMessages) + if (sessionShouldHaveTranscript(stored) && messagesForView.length === 0) { + setActiveSessionId(null) + activeSessionIdRef.current = null + setResumeFailedSessionId(storedSessionId) + resumedRunning = false + + return + } + setActiveSessionId(resumed.session_id) activeSessionIdRef.current = resumed.session_id const runtimeInfo = applyRuntimeInfo(resumed.info)