From 806a86ae7db9ec0058281ab206bd603d7df060a8 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:00:04 -0700 Subject: [PATCH] test(desktop): pin the wrong-session close-out scenario across the mapped producers Composes the producers mapped on #86106 into one regression against the real cache, session-actions and prompt-actions hooks plus the production owner-routing dispatcher (gateway edge mocked, no injected bindings): A active -> B queued send -> user focuses tile C -> B runtime reaped -> A's delayed stored-id rotation -> B drain (text + image) -> B recovers On origin/main the rotation consumer navigates the primary to A-next while the user is on tile C (route/focus steal); with the #86359 gate the user stays on C, B's attachment and prompt reach connection-B on B's recovered runtime, and every stored id maps to exactly its own runtime. --- .../hooks/wrong-session-closeout.test.tsx | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 apps/desktop/src/app/session/hooks/wrong-session-closeout.test.tsx diff --git a/apps/desktop/src/app/session/hooks/wrong-session-closeout.test.tsx b/apps/desktop/src/app/session/hooks/wrong-session-closeout.test.tsx new file mode 100644 index 0000000000..d512e7defc --- /dev/null +++ b/apps/desktop/src/app/session/hooks/wrong-session-closeout.test.tsx @@ -0,0 +1,264 @@ +// @vitest-environment jsdom +/** + * Wrong-session close-out scenario for #86106 (a Desktop message persisted + * to a stale session after switching chats), composed from the producers + * mapped on the issue: a background session's delayed stored-id rotation + * (#86359), a queued drain whose runtime went stale (fromQueue fence), and a + * queued send with an attachment recovering against its OWN chat (#116287). + * + * A active → B queued send created → user focuses tile C → B's runtime goes + * stale → A's delayed rotation lands → B's queued text + image drain → B's + * recovery mints a new runtime + * + * Invariants: focus stays where the user left it; prompt and attachment both + * reach B's owner on B's new runtime; stored B maps only to B's runtime and + * stored A only to A's. Real hooks (cache, session actions, prompt actions) + * and the production owner-routing dispatcher; only the gateway edge is mocked. + */ +import { useStore } from '@nanostores/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useRef } from 'react' +import { afterEach, expect, it, vi } from 'vitest' + +import { createSessionRpcDispatcher } from '@/app/contrib/session-rpc-dispatcher' +import { sessionRoute } from '@/app/routes' +import { group } from '@/components/pane-shell/tree/model' +import { $layoutTree, noteActiveTreeGroup } from '@/components/pane-shell/tree/store' +import { textPart } from '@/lib/chat-messages' +import { requestGatewayForAgent, requestGatewayForProfile } from '@/store/gateway' +import { + $activeSessionId, + $activeSessionStoredIdRotation, + $selectedStoredSessionId, + setActiveSessionId, + setActiveSessionStoredIdRotation, + setAwaitingResponse, + setBusy, + setMessages, + setSelectedStoredSessionId, + setSessions +} from '@/store/session' +import { $focusedStoredSessionId, $sessionTiles, clearAllSessionStates } from '@/store/session-states' +import type { SessionInfo } from '@/types/hermes' + +import { usePromptActions } from './use-prompt-actions' +import { clearSingleFlightSessionResumeState } from './use-prompt-actions/single-flight-resume' +import { useSessionActions } from './use-session-actions' +import { useSessionStateCache } from './use-session-state-cache' + +vi.mock('@/store/gateway', async original => ({ + ...(await original>()), + requestGatewayForAgent: vi.fn(), + requestGatewayForProfile: vi.fn(), + retainGatewayForSessionTurn: vi.fn(async () => () => undefined) +})) + +let routedStoredId: string | null = 'stored-A' +const navigate = vi.fn() + +let handle: { + cache: ReturnType + prompts: ReturnType +} + +function Harness() { + const activeSessionId = useStore($activeSessionId) + const selectedStoredSessionId = useStore($selectedStoredSessionId) + const busyRef = useRef(false) + const creatingSessionRef = useRef(false) + + const cache = useSessionStateCache({ + activeSessionId, + busyRef, + selectedStoredSessionId, + setAwaitingResponse, + setBusy, + setMessages + }) + + const requestGateway = createSessionRpcDispatcher({ + ...cache, + ambientRequest: async () => { + throw new Error('unexpected ambient request') + } + }) + + const sessionActions = useSessionActions({ + activeSessionId, + ...cache, + busyRef, + creatingSessionRef, + getRouteToken: () => `${routedStoredId ? sessionRoute(routedStoredId) : '/'}::`, + getRoutedStoredSessionId: () => routedStoredId, + navigate: navigate as never, + requestGateway, + selectedStoredSessionId + }) + + const prompts = usePromptActions({ + activeSessionId, + ...cache, + busyRef, + branchCurrentSession: async () => false, + createBackendSessionForSend: sessionActions.createBackendSessionForSend, + getRouteToken: () => `${routedStoredId ? sessionRoute(routedStoredId) : '/'}::`, + getRoutedStoredSessionId: () => routedStoredId, + handleSkinCommand: () => '', + openMemoryGraph: () => undefined, + refreshSessions: async () => undefined, + requestGateway, + resumeStoredSession: async () => { + throw new Error('unexpected foreground resume') + }, + startFreshSessionDraft: () => undefined, + sttEnabled: false + }) + + handle = { cache, prompts } + + return null +} + +afterEach(() => { + cleanup() + clearAllSessionStates() + clearSingleFlightSessionResumeState() + setActiveSessionStoredIdRotation(null) + setActiveSessionId(null) + setSelectedStoredSessionId(null) + setSessions([]) + $sessionTiles.set([]) + $layoutTree.set(null) + noteActiveTreeGroup(null) + setBusy(false) + setAwaitingResponse(false) + setMessages([]) + window.history.pushState({}, '', '/') + window.localStorage.clear() + vi.clearAllMocks() +}) + +it('a queued B send drained through A’s delayed rotation and B’s recovery lands in B without moving the user off tile C', async () => { + const sessionIdOf = (params?: Record) => String(params?.session_id ?? '') + vi.mocked(requestGatewayForAgent).mockImplementation(async (_connection, _profile, rpc, params) => { + const sessionId = sessionIdOf(params) + + if (rpc === 'session.resume') { + return { session_id: sessionId === 'stored-B' ? 'rt-B2' : `rt-unexpected-${sessionId}` } + } + + if (sessionId === 'rt-B') { + // B's runtime was reaped while its send sat in the queue. + throw new Error('4007 session not found') + } + + if (rpc === 'image.attach') { + return { attached: true, path: '/scratch/shot.png' } + } + + if (rpc === 'prompt.submit') { + return { status: 'streaming' } + } + + throw new Error(`unexpected ${rpc} for ${sessionId}`) + }) + + // Distinct owners per chat: routing to the wrong runtime is visible as a + // call on the wrong connection, not only as a wrong session_id. + setSessions( + ['A', 'B', 'C'].map(id => ({ + id: `stored-${id}`, + connection_id: `connection-${id}`, + profile: 'default', + source: 'desktop', + message_count: 1 + })) as SessionInfo[] + ) + // A is the primary: selection, HashRouter route and active runtime all name it. + routedStoredId = 'stored-A' + window.history.pushState({}, '', '/#/stored-A') + setSelectedStoredSessionId('stored-A') + setActiveSessionId('rt-A') + render() + act(() => { + for (const id of ['A', 'B', 'C']) { + handle.cache.updateSessionState( + `rt-${id}`, + state => ({ + ...state, + messages: [{ id: `history-${id}`, role: 'assistant', parts: [textPart(`history ${id}`)] }] + }), + `stored-${id}` + ) + } + }) + + // The user opens C as a tile and is typing there when everything below lands. + act(() => { + $sessionTiles.set([{ runtimeId: 'rt-C', storedSessionId: 'stored-C' }]) + $layoutTree.set(group(['workspace', 'session-tile:stored-C'], { active: 'session-tile:stored-C', id: 'grp-main' })) + noteActiveTreeGroup('grp-main') + }) + expect($focusedStoredSessionId.get()).toBe('stored-C') + + // A's auto-compression rotates its stored id in the background (real + // producer in useSessionStateCache, real consumer in useSessionActions). + act(() => { + handle.cache.updateSessionState('rt-A', state => state, 'stored-A-next') + }) + await act(async () => undefined) + + expect(navigate).not.toHaveBeenCalled() + expect($activeSessionStoredIdRotation.get()).toBeNull() + expect($focusedStoredSessionId.get()).toBe('stored-C') + expect($selectedStoredSessionId.get()).toBe('stored-A') + expect(handle.cache.runtimeIdByStoredSessionIdRef.current.get('stored-A-next')).toBe('rt-A') + + // The composer queue drains B's send (text + image) with the runtime id it + // captured when the send was queued — stale by now. + await act(async () => { + expect( + await handle.prompts.submitText('B queued text', { + attachments: [{ id: 'att-1', kind: 'image', label: 'shot.png', path: '/scratch/shot.png' } as never], + composerScope: 'stored-B', + fromQueue: true, + sessionId: 'rt-B', + storedSessionId: 'stored-B' + }) + ).toBe(true) + }) + + const calls = vi + .mocked(requestGatewayForAgent) + .mock.calls.map(([connection, , rpc, params]) => [connection, rpc, sessionIdOf(params as never)]) + + await waitFor(() => expect(calls).toContainEqual(['connection-B', 'prompt.submit', 'rt-B2'])) + expect(calls).toContainEqual(['connection-B', 'session.resume', 'stored-B']) + expect(calls).toContainEqual(['connection-B', 'image.attach', 'rt-B2']) + expect(calls.filter(([, rpc]) => rpc === 'session.resume')).toEqual([['connection-B', 'session.resume', 'stored-B']]) + expect(calls.some(([connection]) => connection !== 'connection-B')).toBe(false) + expect(calls.some(([, , sessionId]) => sessionId === 'rt-A' || sessionId === 'rt-C')).toBe(false) + expect(requestGatewayForProfile).not.toHaveBeenCalled() + + // Each stored id maps only to its own runtime; the foreground never moved. + const bindings = handle.cache.runtimeIdByStoredSessionIdRef.current + expect(bindings.get('stored-B')).toBe('rt-B2') + expect(bindings.get('stored-A-next')).toBe('rt-A') + expect(bindings.get('stored-C')).toBe('rt-C') + expect([...bindings.values()].filter(runtime => runtime === 'rt-B2')).toEqual(['rt-B2']) + expect(handle.cache.sessionStateByRuntimeIdRef.current.get('rt-B2')?.storedSessionId).toBe('stored-B') + expect($activeSessionId.get()).toBe('rt-A') + expect(handle.cache.activeSessionIdRef.current).toBe('rt-A') + expect($selectedStoredSessionId.get()).toBe('stored-A') + expect($focusedStoredSessionId.get()).toBe('stored-C') + expect(navigate).not.toHaveBeenCalled() + + // B's user row is filed under B only (never under A's or C's state). The + // optimistic bubble stays on the cache entry the queued send was seeded on; + // the persisted row is the prompt.submit on rt-B2 asserted above. + const userRowOwners = [...handle.cache.sessionStateByRuntimeIdRef.current.values()].flatMap(state => + state.messages.filter(message => message.role === 'user').map(() => state.storedSessionId) + ) + + expect(userRowOwners).toEqual(['stored-B']) +})