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.
This commit is contained in:
Teknium 2026-07-08 08:14:31 -07:00 committed by GitHub
parent ae5e39005b
commit 8e734810df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 177 additions and 5 deletions

View file

@ -59,7 +59,9 @@ function Harness({
requestGateway,
resumeStoredSession,
seedMessages,
storedSessionId
storedSessionId,
activeSessionId,
createBackendSessionForSend
}: {
busyRef?: MutableRefObject<boolean>
onReady: (handle: HarnessHandle) => void
@ -70,8 +72,12 @@ function Harness({
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
seedMessages?: unknown[]
storedSessionId?: null | string
activeSessionId?: null | string
createBackendSessionForSend?: () => Promise<null | string>
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const activeSessionIdRef: MutableRefObject<string | null> = {
current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId
}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
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<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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(
<Harness
onReady={h => (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<string, unknown> }[] = []
const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG')
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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(
<Harness
activeSessionId={null}
createBackendSessionForSend={createBackendSessionForSend}
onReady={h => (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(
<Harness
activeSessionId={null}
createBackendSessionForSend={createBackendSessionForSend}
onReady={h => (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)', () => {

View file

@ -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'

View file

@ -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: <method>" 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