diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx index 3418e0bad80..e7dfe9d7da5 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx @@ -42,6 +42,7 @@ function sessionInfo(overrides: Partial = {}): SessionInfo { } interface HarnessHandle { + cancelRun: () => Promise steerPrompt: (text: string) => Promise submitText: ( text: string, @@ -102,8 +103,12 @@ function Harness({ }) useEffect(() => { - onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText }) - }, [actions.steerPrompt, actions.submitText, onReady]) + onReady({ + cancelRun: actions.cancelRun, + steerPrompt: actions.steerPrompt, + submitText: actions.submitText + }) + }, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady]) return null } @@ -629,6 +634,43 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' }) }) + it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => { + const calls: { method: string; params?: Record }[] = [] + let interruptAttempts = 0 + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + if (method === 'session.interrupt') { + interruptAttempts += 1 + if (interruptAttempts === 1) { + throw new Error('session not found') + } + 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} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + await handle!.cancelRun() + + expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt']) + expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID }) + }) + it('surfaces the original error (no resume) when the failure is not "session not found"', async () => { const calls: string[] = [] const states: Record[] = [] @@ -818,4 +860,3 @@ describe('uploadComposerAttachment remote read failures', () => { ).rejects.toThrow('ENOENT: no such file') }) }) - diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 15831bb4189..b09d86ffd10 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -108,6 +108,12 @@ function inlineErrorMessage(error: unknown, fallback: string): string { return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim() } +function isSessionNotFoundError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /session not found/i.test(message) +} + function base64FromDataUrl(dataUrl: string): string { const comma = dataUrl.indexOf(',') @@ -661,9 +667,7 @@ export function usePromptActions({ try { await requestGateway('prompt.submit', { session_id: sessionId, text }) } catch (firstErr) { - const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr) - - if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) { + if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) { // Re-register the session in the gateway and get a fresh live ID. const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current @@ -1273,11 +1277,39 @@ export function usePromptActions({ try { await requestGateway('session.interrupt', { session_id: sessionId }) } catch (err) { + let stopError = err + + if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) { + try { + const resumed = await requestGateway<{ session_id: string }>('session.resume', { + session_id: selectedStoredSessionIdRef.current + }) + const recoveredId = resumed?.session_id + + if (recoveredId) { + activeSessionIdRef.current = recoveredId + await requestGateway('session.interrupt', { session_id: recoveredId }) + + return + } + } catch (resumeErr) { + stopError = resumeErr + } + } + setMutableRef(busyRef, false) setBusy(false) - notifyError(err, copy.stopFailed) + notifyError(stopError, copy.stopFailed) } - }, [activeSessionId, activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, updateSessionState]) + }, [ + activeSessionId, + activeSessionIdRef, + busyRef, + copy.stopFailed, + requestGateway, + selectedStoredSessionIdRef, + updateSessionState + ]) // Steer = nudge the live turn without interrupting: the gateway appends the // text to the next tool result so the model reads it on its next iteration