From 8e734810dfcb4f19eac2f442cb75b3962dfec728 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:14:31 -0700 Subject: [PATCH] fix(desktop): continue the selected stored session instead of minting a new one (#55578) (#60874) Two client-side halves of the #55578 session split: 1. Submit with a null activeSessionId but a SELECTED stored session now resumes that stored session instead of falling straight through to createBackendSessionForSend - which silently forked the user's conversation into a brand-new session that then got orphan-reaped. New-chat drafts (no stored selection) still create sessions as before. 2. prompt.submit recovery now also fires on gateway request timeouts, not only 'session not found'. A starved backend loop (the async- delegation poller spin) rejects the submit with 'request timed out' even though the stored session is fine; previously that surfaced an error, left the binding cleared, and set up the split on the next send. Fail-then-pass: 2 new tests fail with production code reverted. --- .../hooks/use-prompt-actions/index.test.tsx | 132 +++++++++++++++++- .../hooks/use-prompt-actions/submit.ts | 38 ++++- .../session/hooks/use-prompt-actions/utils.ts | 12 ++ 3 files changed, 177 insertions(+), 5 deletions(-) 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 681002311fd..5ce2ad8aa56 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 @@ -59,7 +59,9 @@ function Harness({ requestGateway, resumeStoredSession, seedMessages, - storedSessionId + storedSessionId, + activeSessionId, + createBackendSessionForSend }: { busyRef?: MutableRefObject onReady: (handle: HarnessHandle) => void @@ -70,8 +72,12 @@ function Harness({ resumeStoredSession?: (storedSessionId: string) => Promise | void seedMessages?: unknown[] storedSessionId?: null | string + activeSessionId?: null | string + createBackendSessionForSend?: () => Promise }) { - const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } + const activeSessionIdRef: MutableRefObject = { + current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId + } const selectedStoredSessionIdRef: MutableRefObject = { current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId @@ -87,11 +93,11 @@ function Harness({ } as never) const actions = usePromptActions({ - activeSessionId: RUNTIME_SESSION_ID, + activeSessionId: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId, activeSessionIdRef, branchCurrentSession: async () => true, busyRef: localBusyRef, - createBackendSessionForSend: async () => RUNTIME_SESSION_ID, + createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID), handleSkinCommand: () => '', openMemoryGraph: openMemoryGraph ?? (() => undefined), refreshSessions, @@ -1190,6 +1196,124 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(await handle!.submitText('message')).toBe(false) expect(calls).not.toContain('session.resume') }) + + it('recovers via session.resume when prompt.submit TIMES OUT and a stored session is selected (#55578)', async () => { + // A starved gateway loop rejects with "request timed out: prompt.submit". + // With a stored session selected, that must recover exactly like + // "session not found" — resume + retry — not surface an error that leaves + // activeSessionId null and lets the next send mint a new session. + const calls: { method: string; params?: Record }[] = [] + let submitAttempts = 0 + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'prompt.submit') { + submitAttempts += 1 + + if (submitAttempts === 1) { + throw new Error('request timed out: prompt.submit') + } + + return {} as never + } + + if (method === 'session.resume') { + return { session_id: RECOVERED_SESSION_ID } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + const ok = await handle!.submitText('message during starved loop') + + expect(ok).toBe(true) + expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[2]?.params).toEqual({ + session_id: RECOVERED_SESSION_ID, + text: 'message during starved loop' + }) + }) + + it('resumes the SELECTED stored session instead of minting a new one when activeSessionId is null (#55578 split)', async () => { + // The exact split path from #55578 symptom (b): the runtime binding is + // gone (orphan-reaped / cleared by a timeout) but a stored session is + // still selected in the sidebar. A follow-up submit must continue that + // conversation via session.resume — createBackendSessionForSend would + // silently fork the user's chat in two. + const calls: { method: string; params?: Record }[] = [] + const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG') + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'session.resume') { + return { session_id: RECOVERED_SESSION_ID } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + const ok = await handle!.submitText('follow-up in the selected chat') + + expect(ok).toBe(true) + expect(createBackendSessionForSend).not.toHaveBeenCalled() + expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit']) + expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID }) + }) + + it('still creates a new session for a genuine new-chat draft (no stored session selected)', async () => { + const createBackendSessionForSend = vi.fn(async () => RUNTIME_SESSION_ID) + const calls: string[] = [] + + const requestGateway = vi.fn(async (method: string) => { + calls.push(method) + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={null} + /> + ) + + const ok = await handle!.submitText('first message of a new chat') + + expect(ok).toBe(true) + expect(createBackendSessionForSend).toHaveBeenCalledTimes(1) + expect(calls).not.toContain('session.resume') + }) }) describe('usePromptActions eager attachment upload (drop-time)', () => { 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 09df1221af8..76a25885854 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 @@ -23,6 +23,7 @@ import { inlineErrorMessage, isProviderSetupError, isSessionBusyError, + isGatewayTimeoutError, isSessionNotFoundError, type SubmitTextOptions, withSessionBusyRetry @@ -213,6 +214,34 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { setMessages(current => [...current, buildUserMessage()]) } + if (!sessionId && selectedStoredSessionIdRef.current) { + // A stored session is SELECTED but its runtime binding is gone (the + // live session was orphan-reaped, or a timeout/reconnect cleared + // activeSessionId). Continuing the selected conversation must mean + // resuming it — minting a brand-new backend session here silently + // splits the user's chat in two (#55578 symptom b). Only fall through + // to session creation when NO stored session is selected (a genuine + // new-chat draft). + try { + const resumed = await requestGateway<{ session_id: string }>('session.resume', { + session_id: selectedStoredSessionIdRef.current + }) + + if (resumed?.session_id) { + sessionId = resumed.session_id + activeSessionIdRef.current = sessionId + } + } catch { + // Resume failed (session gone from state.db, gateway hiccup) — + // fall through to creating a fresh session rather than dead-ending + // the user's message. + } + + if (sessionId) { + seedOptimistic(sessionId) + } + } + if (!sessionId) { try { sessionId = await createBackendSessionForSend(visibleText) @@ -257,8 +286,15 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) ) } catch (firstErr) { - if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) { + if ( + (isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && + selectedStoredSessionIdRef.current + ) { // Re-register the session in the gateway and get a fresh live ID. + // Timeouts recover the same way as "session not found": a starved + // backend loop (#55578 symptom d) rejects the submit even though + // the stored session is fine — resume + retry instead of erroring + // out and losing the session binding. const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current, source: 'desktop' diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index 00fc5085532..1df123824d1 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -52,6 +52,18 @@ export function isSessionNotFoundError(error: unknown): boolean { return /session not found/i.test(message) } +// Gateway JSON-RPC calls reject with "request timed out: " when the +// backend event loop is starved (e.g. a poller spin or a heavy async-injected +// turn). For prompt.submit this is indistinguishable from a dead runtime +// session on the client side — recovery must treat it like one (#55578): +// resume the SELECTED stored session and retry, instead of surfacing an error +// that leads to a null activeSessionId and a silently minted new session. +export function isGatewayTimeoutError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /request timed out/i.test(message) +} + // The gateway refuses prompt.submit while a turn is running (4009 "session // busy"). It's a transient concurrency guard, never a user-facing error: a // submit racing the settle edge (or a rewind interrupting mid-turn) just waits