Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 128 additions & 4 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ function Harness({
requestGateway,
resumeStoredSession,
seedMessages,
storedSessionId
storedSessionId,
activeSessionId,
createBackendSessionForSend
}: {
busyRef?: MutableRefObject<boolean>
onReady: (handle: HarnessHandle) => void
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)', () => {
Expand Down
38 changes: 37 additions & 1 deletion apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
inlineErrorMessage,
isProviderSetupError,
isSessionBusyError,
isGatewayTimeoutError,
isSessionNotFoundError,
type SubmitTextOptions,
withSessionBusyRetry
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
})
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading