diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index e52defbc78..7ceff9f071 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -4708,6 +4708,78 @@ describe('usePromptActions busy-gateway churn tolerance (#64327)', () => { ]) }) + it('keeps an explicit background queue target isolated from foreground routed recovery (#90428)', async () => { + const FOREGROUND_STORED_ID = 'stored-foreground-b' + const FOREGROUND_RUNTIME_ID = 'rt-foreground-b' + const QUEUED_STORED_ID = 'stored-queued-c' + const QUEUED_RUNTIME_ID = 'rt-queued-c-recovered' + const calls: { method: string; params?: Record }[] = [] + const stateWrites: { sessionId: string; storedSessionId: null | string | undefined }[] = [] + const selectedStoredSessionIdRef: MutableRefObject = { current: FOREGROUND_STORED_ID } + const activeSessionIdRef: MutableRefObject = { current: FOREGROUND_RUNTIME_ID } + + const runtimeIdByStoredSessionIdRef: MutableRefObject> = { + current: new Map([[FOREGROUND_STORED_ID, FOREGROUND_RUNTIME_ID]]) + } + + const resumeStoredSession = vi.fn(async () => undefined) + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'session.resume') { + expect(params?.session_id).toBe(QUEUED_STORED_ID) + + return { session_id: QUEUED_RUNTIME_ID } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + FOREGROUND_STORED_ID} + getRouteToken={() => `/${FOREGROUND_STORED_ID}::`} + getRuntimeIdForStoredSession={storedId => runtimeIdByStoredSessionIdRef.current.get(storedId) ?? null} + onReady={h => (handle = h)} + onUpdateState={(sessionId, storedSessionId) => stateWrites.push({ sessionId, storedSessionId })} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + resumeStoredSession={resumeStoredSession} + runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={FOREGROUND_STORED_ID} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + expect( + await handle!.submitText('queued prompt for C', { + fromQueue: true, + sessionId: null, + storedSessionId: QUEUED_STORED_ID + }) + ).toBe(true) + expect(resumeStoredSession).not.toHaveBeenCalled() + expect(calls).toEqual([ + { + method: 'session.resume', + params: { session_id: QUEUED_STORED_ID, source: 'desktop', omit_messages: true } + }, + { + method: 'prompt.submit', + params: { session_id: QUEUED_RUNTIME_ID, text: 'queued prompt for C', queued: true } + } + ]) + expect(stateWrites.some(write => write.sessionId === FOREGROUND_RUNTIME_ID)).toBe(false) + expect(selectedStoredSessionIdRef.current).toBe(FOREGROUND_STORED_ID) + expect(activeSessionIdRef.current).toBe(FOREGROUND_RUNTIME_ID) + expect(runtimeIdByStoredSessionIdRef.current).toEqual(new Map([[FOREGROUND_STORED_ID, FOREGROUND_RUNTIME_ID]])) + }) + it('still aborts when the user genuinely moves to a different chat mid-submit', async () => { // The churn tolerance must not weaken the real guard: selection AND route // moving to another actual chat is a user switch and must abort. diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 97b060922f..033c7918b2 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -527,7 +527,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { scope.setMessages(current => [...current, buildUserMessage()]) } - if (!sessionId && routedStoredSessionId && routedSessionNeedsResume) { + if (!options?.storedSessionId && !sessionId && routedStoredSessionId && routedSessionNeedsResume) { // The URL still names a durable conversation, but a profile // swap/reconnect left its volatile session binding incomplete or // cross-wired. Run the full profile-aware resume path. Creating here