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 c348db6b00b..d0bdc37a8e2 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 @@ -55,16 +55,20 @@ function Harness({ onReady, onSeedState, refreshSessions, - requestGateway + requestGateway, + storedSessionId }: { busyRef?: MutableRefObject onReady: (handle: HarnessHandle) => void onSeedState?: (state: Record) => void refreshSessions: () => Promise requestGateway: (method: string, params?: Record) => Promise + storedSessionId?: null | string }) { const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } - const selectedStoredSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } + const selectedStoredSessionIdRef: MutableRefObject = { + current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId + } const localBusyRef = busyRef ?? { current: false } const actions = usePromptActions({ @@ -408,3 +412,109 @@ describe('usePromptActions file attachment sync', () => { }) }) }) + +describe('usePromptActions sleep/wake session recovery', () => { + const STORED_SESSION_ID = 'stored-db-xyz789' + const RECOVERED_SESSION_ID = 'rt-recovered-456' + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('resumes the stored session and retries once when prompt.submit reports "session not found"', async () => { + // After sleep/wake the gateway's in-memory session table is cleared, so the + // first prompt.submit with the stale runtime id fails. The hook resumes the + // durable stored id (which survives gateway restarts), gets a fresh live id, + // and retries the send transparently. + 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('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} + /> + ) + + const ok = await handle!.submitText('message after wake') + + expect(ok).toBe(true) + // First submit (stale id) → session.resume (stored id) → retry submit (fresh id). + 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 after wake' }) + }) + + it('surfaces the original error (no resume) when the failure is not "session not found"', async () => { + const calls: string[] = [] + const states: Record[] = [] + const requestGateway = vi.fn(async (method: string) => { + calls.push(method) + if (method === 'prompt.submit') { + throw new Error('session busy') + } + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + onSeedState={s => states.push(s)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + // submitText swallows the error into an inline bubble and returns false. + expect(await handle!.submitText('message')).toBe(false) + // No resume attempt for a non-recoverable error. + expect(calls).not.toContain('session.resume') + }) + + it('surfaces "session not found" (no resume) when there is no stored session id', async () => { + const calls: string[] = [] + const requestGateway = vi.fn(async (method: string) => { + calls.push(method) + if (method === 'prompt.submit') { + throw new Error('session not found') + } + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={null} + /> + ) + + // With a null stored ref, the `&& selectedStoredSessionIdRef.current` guard + // short-circuits — no resume is attempted and the error surfaces normally. + expect(await handle!.submitText('message')).toBe(false) + expect(calls).not.toContain('session.resume') + }) +}) + diff --git a/scripts/release.py b/scripts/release.py index 9b3c3830d58..3bdfad32c61 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -104,6 +104,7 @@ AUTHOR_MAP = { "metalclaudbot@gmail.com": "HashClawAI", "tonybear55665566@gmail.com": "TonyPepeBear", "kaspersniels@gmail.com": "nielskaspers", + "daxxpasquini@gmail.com": "bpasquini", "kurobaryo@gmail.com": "kurobaryo", "scubamount@users.noreply.github.com": "scubamount", "251514042+youngstar-eth@users.noreply.github.com": "youngstar-eth",