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 1b725f65737e1..28a0f18c351e0 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 @@ -13,6 +13,7 @@ import { $messages, $newChatWorkspaceTarget, $resumeFailedSessionId, + $selectedStoredSessionId, setActiveSessionId, setCurrentCwd, setMessages, @@ -25,6 +26,10 @@ import type { ClientSessionState } from '../../types' import { useSessionActions } from './use-session-actions' +const { ensureGatewayProfileMock } = vi.hoisted(() => ({ + ensureGatewayProfileMock: vi.fn() +})) + vi.mock('@/hermes', async importOriginal => ({ ...(await importOriginal>()), deleteSession: vi.fn(), @@ -34,10 +39,15 @@ vi.mock('@/hermes', async importOriginal => ({ setSessionArchived: vi.fn() })) +vi.mock('@/store/profile', async importOriginal => ({ + ...(await importOriginal()), + ensureGatewayProfile: ensureGatewayProfileMock +})) + const RUNTIME_SESSION_ID = 'rt-new-001' type HarnessHandle = Pick< ReturnType, - 'createBackendSessionForSend' | 'startFreshSessionDraft' + 'createBackendSessionForSend' | 'resumeSession' | 'startFreshSessionDraft' > function storedSession(overrides: Partial = {}): SessionInfo { @@ -59,6 +69,15 @@ function storedSession(overrides: Partial = {}): SessionInfo { } } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(resolvePromise => { + resolve = resolvePromise + }) + + return { promise, resolve } +} + function Harness({ onReady, requestGateway @@ -217,7 +236,7 @@ function ResumeHarness({ runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef }: { - onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise) => void + onReady: (actions: HarnessHandle) => void requestGateway: (method: string, params?: Record) => Promise runtimeIdByStoredSessionIdRef?: MutableRefObject> sessionStateByRuntimeIdRef?: MutableRefObject> @@ -243,8 +262,8 @@ function ResumeHarness({ }) useEffect(() => { - onReady(actions.resumeSession) - }, [actions.resumeSession, onReady]) + onReady(actions) + }, [actions, onReady]) return null } @@ -256,6 +275,7 @@ describe('resumeSession failure recovery', () => { setResumeFailedSessionId(null) setMessages([]) setSessions([]) + ensureGatewayProfileMock.mockReset() vi.restoreAllMocks() }) @@ -266,10 +286,10 @@ describe('resumeSession failure recovery', () => { sessionStateByRuntimeIdRef?: MutableRefObject> } = {} ): Promise { - let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null - render( (resume = r)} requestGateway={requestGateway} {...options} />) - await waitFor(() => expect(resume).not.toBeNull()) - await resume!('stored-1', true) + let actions: HarnessHandle | null = null + render( (actions = handle)} requestGateway={requestGateway} {...options} />) + await waitFor(() => expect(actions).not.toBeNull()) + await actions!.resumeSession('stored-1', true) } it('arms $resumeFailedSessionId when resume RPC and REST fallback both fail', async () => { @@ -336,6 +356,66 @@ describe('resumeSession failure recovery', () => { await expect(runResume(requestGateway)).resolves.toBeUndefined() }) + it('does not let an in-flight resume revive a fresh chat draft', async () => { + setSessions([storedSession()]) + + 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 + }) + let actions: HarnessHandle | null = null + + render( (actions = handle)} requestGateway={requestGateway} />) + await waitFor(() => expect(actions).not.toBeNull()) + + await act(async () => { + const pendingResume = actions!.resumeSession('stored-1', true) + + // resolveStoredSession yields before the gateway resume. New Chat must + // invalidate that stale continuation rather than let it reselect stored-1. + actions!.startFreshSessionDraft(true) + await pendingResume + }) + + expect(requestGateway).not.toHaveBeenCalledWith('session.resume', expect.anything()) + expect($activeSessionId.get()).toBeNull() + expect($selectedStoredSessionId.get()).toBeNull() + expect($messages.get()).toEqual([]) + }) + + it('does not let a resume continue after New Chat during its gateway profile swap', async () => { + setSessions([storedSession({ profile: 'other-profile' })]) + const gatewaySwap = deferred() + ensureGatewayProfileMock.mockReturnValueOnce(gatewaySwap.promise) + + 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 + }) + let actions: HarnessHandle | null = null + + render( (actions = handle)} requestGateway={requestGateway} />) + await waitFor(() => expect(actions).not.toBeNull()) + + const pendingResume = actions!.resumeSession('stored-1', true) + await waitFor(() => expect(ensureGatewayProfileMock).toHaveBeenCalledWith('other-profile')) + + actions!.startFreshSessionDraft(true) + gatewaySwap.resolve(undefined) + await pendingResume + + expect(requestGateway).not.toHaveBeenCalledWith('session.resume', expect.anything()) + expect($activeSessionId.get()).toBeNull() + expect($selectedStoredSessionId.get()).toBeNull() + expect($messages.get()).toEqual([]) + }) + it('leaves the failure latch clear when resume succeeds', async () => { // Pre-arm to prove a successful resume clears it (entry-clear path). setResumeFailedSessionId('stored-1') @@ -581,7 +661,7 @@ describe('resumeSession warm-cache mapping integrity', () => { let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null render( (resume = r)} + onReady={actions => (resume = actions.resumeSession)} requestGateway={requestGateway} runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef} sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef} @@ -623,7 +703,7 @@ describe('resumeSession warm-cache mapping integrity', () => { let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null render( (resume = r)} + onReady={actions => (resume = actions.resumeSession)} requestGateway={requestGateway} runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef} sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef} 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 1e1e37405d088..389b65491d57b 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 @@ -217,6 +217,11 @@ export function useSessionActions({ ? normalizeNewChatWorkspaceTarget(draftOptions.workspaceTarget) : undefined + // A resume may be between awaits (profile lookup / gateway swap) when the + // user starts a new chat. Invalidate that request before clearing the + // view; otherwise its stale continuation can select and paint the old + // session over this fresh draft. + resumeRequestRef.current += 1 resetViewSync() busyRef.current = false setBusy(false) @@ -499,12 +504,16 @@ export function useSessionActions({ const storedForProfile = await resolveStoredSession(storedSessionId) const sessionProfile = storedForProfile?.profile - if (resumeRequestRef.current !== requestId) { + if (!isCurrentResume()) { return } await ensureGatewayProfile(sessionProfile) + if (!isCurrentResume()) { + return + } + // Re-check after the profile-resolve / gateway-swap awaits above: the // cache may have changed, and takeWarmCache re-validates belongs-to and // purges a cross-wired mapping before we trust the fast-path.