From 7161b7e7a777e7621e5bc637e4da014cbdb83325 Mon Sep 17 00:00:00 2001 From: Jeongseok Kang Date: Sun, 19 Jul 2026 22:17:13 +0900 Subject: [PATCH 1/2] fix(desktop): recover a dead first-submit draft instead of "session not found" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new chat's state.db row is only persisted by the gateway on its first successful prompt.submit (deliberately lazy, so abandoned drafts never leave "Untitled" rows behind). If the live session dies before that first submit lands — sleep/wake WS drop followed by the orphan reap, a backend restart — the next submit 4001s "session not found" and the sleep/wake recovery's session.resume 4007s the SAME "session not found" because there is no row to resume. That rejection escaped uncaught, surfaced as a raw "Prompt failed / session not found" toast, and dropped the user's message with no way forward. Catch the recovery resume and, when BOTH the live id and the stored row report "session not found" (the never-persisted-draft signature), mint a fresh backend session and land the message there — moving the optimistic message, drift baseline, and attachment sync over to the minted chat. Scoped tightly: a timed-out submit may have actually reached the backend (re-sending elsewhere would double-send), a non-404 resume failure says nothing about whether the stored chat exists (minting would split a real conversation, #55578 symptom b), and a background queue drain must never re-home the user's view — all of those keep the existing surface-the-error behavior. Co-Authored-By: Claude Fable 5 --- .../hooks/use-prompt-actions/index.test.tsx | 180 ++++++++++++++++++ .../hooks/use-prompt-actions/submit.ts | 106 ++++++++++- 2 files changed, 278 insertions(+), 8 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 a06ee1294c08d..766ce0359419e 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 @@ -1709,6 +1709,186 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(createBackendSessionForSend).toHaveBeenCalledTimes(1) expect(calls).not.toContain('session.resume') }) + + it('mints a fresh session and retries when the recovery resume also 404s (first-submit draft, no DB row)', async () => { + // The dead-end this covers: a new chat's FIRST submit never landed, so the + // gateway never persisted a state.db row (rows are written lazily on first + // prompt.submit). When the live session is then reaped (sleep/wake WS + // drop), prompt.submit 404s AND the recovery session.resume 404s — + // previously the resume rejection escaped uncaught and surfaced as the raw + // "Prompt failed / session not found" toast, losing the user's text. + const STALE_SESSION_ID = 'rt-stale-dead' + const FRESH_SESSION_ID = 'rt-fresh-789' + const activeSessionIdRef: MutableRefObject = { current: STALE_SESSION_ID } + + // Mirror the real createBackendSessionForSend: a successful create re-homes + // the active runtime ref to the minted session BEFORE returning (the + // fallback's drift guard reads it). + const createBackendSessionForSend = vi.fn(async () => { + activeSessionIdRef.current = FRESH_SESSION_ID + + return FRESH_SESSION_ID + }) + + const calls: { method: string; params?: Record }[] = [] + const seededSessionIds: string[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'prompt.submit' && params?.session_id === STALE_SESSION_ID) { + throw new Error('session not found') + } + + if (method === 'session.resume') { + throw new Error('session not found') + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + onUpdateState={sessionId => seededSessionIds.push(sessionId)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + const ok = await handle!.submitText('first message after reap') + + expect(ok).toBe(true) + // Stale submit → failed resume → fresh create → submit lands there. + expect(createBackendSessionForSend).toHaveBeenCalledTimes(1) + expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) + expect(calls[0]?.params).toMatchObject({ session_id: STALE_SESSION_ID }) + expect(calls[2]?.params).toEqual({ session_id: FRESH_SESSION_ID, text: 'first message after reap' }) + // The optimistic message was re-seeded under the minted session. + expect(seededSessionIds).toContain(FRESH_SESSION_ID) + }) + + it('surfaces the original error (no new session) when the recovery resume fails for a non-404 reason', async () => { + // A transport blip during the recovery resume says nothing about whether + // the stored chat exists — minting a fresh session here would split a real + // conversation in two (#55578 symptom b). + const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG') + const calls: string[] = [] + + const requestGateway = vi.fn(async (method: string) => { + calls.push(method) + + if (method === 'prompt.submit') { + throw new Error('session not found') + } + + if (method === 'session.resume') { + throw new Error('gateway exploded') + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + expect(await handle!.submitText('message')).toBe(false) + expect(createBackendSessionForSend).not.toHaveBeenCalled() + expect(calls).toEqual(['prompt.submit', 'session.resume']) + }) + + it('does not mint a new session when a TIMED-OUT submit fails recovery (double-send guard)', async () => { + // A timed-out prompt.submit may have actually reached the backend — even + // when the recovery resume 404s, re-sending into a freshly minted session + // risks the same prompt landing twice. Only a "session not found" submit + // failure (provably never landed) takes the fresh-create fallback. + const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG') + const calls: string[] = [] + + const requestGateway = vi.fn(async (method: string) => { + calls.push(method) + + if (method === 'prompt.submit') { + throw new Error('request timed out: prompt.submit') + } + + if (method === 'session.resume') { + throw new Error('session not found') + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + expect(await handle!.submitText('message')).toBe(false) + expect(createBackendSessionForSend).not.toHaveBeenCalled() + expect(calls).toEqual(['prompt.submit', 'session.resume']) + }) + + it('keeps a background queue drain out of the fresh-create fallback (never re-homes the view)', async () => { + // A queued drain targeting another chat hits the same double-404 shape when + // that chat's draft died before persisting. Minting a session here would + // navigate the user's current view to a chat they didn't open. + const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG') + const calls: string[] = [] + + const requestGateway = vi.fn(async (method: string) => { + calls.push(method) + + if (method === 'prompt.submit') { + throw new Error('session not found') + } + + if (method === 'session.resume') { + throw new Error('session not found') + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + const ok = await handle!.submitText('queued message for another chat', { + fromQueue: true, + sessionId: 'rt-other-chat-dead', + storedSessionId: 'stored-other-chat' + }) + + expect(ok).toBe(false) + expect(createBackendSessionForSend).not.toHaveBeenCalled() + }) }) describe('usePromptActions submit session-context isolation (#54527)', () => { 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 709652dce7bf5..deb561f5578b4 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 @@ -175,6 +175,13 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { let startingRouteToken = getRouteToken() + // Stored id the optimistic message (and error bubble) is keyed under. + // Tracks targetStoredSessionId until the dead-draft recovery in the + // prompt.submit catch below re-homes the send to a freshly minted chat — + // keying the new runtime under the dead stored id would cross-wire the + // session-state cache. + let optimisticStoredSessionId = targetStoredSessionId + const sessionContextDrifted = (): boolean => targetStartedInCurrentView && (selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken) @@ -239,7 +246,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // (what made drained-after-interrupt sends go silent). interrupted: false }), - targetStoredSessionId + optimisticStoredSessionId ) // After sync rewrites refs, refresh the optimistic message in place so the @@ -251,7 +258,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { ...state, messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message)) }), - targetStoredSessionId + optimisticStoredSessionId ) const dropOptimistic = (sid: null | string) => { @@ -272,7 +279,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { awaitingResponse: false, pendingBranchGroup: null }), - targetStoredSessionId + optimisticStoredSessionId ) } @@ -468,10 +475,23 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // 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: recoverStoredSessionId, - source: 'desktop' - }) + // The resume can itself fail: the gateway persists a chat's DB row + // only on its first successful prompt.submit, so a fresh chat whose + // live session died before that has no row and the resume 4007s the + // same "session not found". Catch it so the dead-draft fallback + // below can recover instead of surfacing the raw error and losing + // the user's text. + let resumed: { session_id: string } | null = null + let resumeErr: unknown = null + + try { + resumed = await requestGateway<{ session_id: string }>('session.resume', { + session_id: recoverStoredSessionId, + source: 'desktop' + }) + } catch (err) { + resumeErr = err + } if (sessionContextDrifted()) { return abortForSessionSwitch(sessionId) @@ -487,6 +507,76 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) ) + } else if ( + targetIsCurrentView() && + isSessionNotFoundError(firstErr) && + resumeErr !== null && + isSessionNotFoundError(resumeErr) + ) { + // Live session AND stored row both report "session not found": + // this chat never got past its first submit, so there is nothing + // to resume anywhere — mint a fresh backend session and land the + // message there. Scoped tightly: a timed-out firstErr may have + // actually reached the backend (re-sending elsewhere would + // double-send), a non-404 resume failure (transport blip) must + // not split a real stored chat in two (#55578 symptom b), and a + // background drain must never re-home the user's view — all of + // those keep the surface-the-error behavior below. + let createdId: null | string = null + + try { + createdId = await createBackendSessionForSend(visibleText) + } catch { + createdId = null + } + + if (!createdId) { + // Null means the user switched sessions mid-create (it closes + // the orphan itself) — abort silently; a create failure keeps + // the original error. + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + + submitErr = firstErr + } else if (activeSessionIdRef.current !== createdId) { + // Same re-home race as the primary create path above: every + // switch path re-nulls or retargets the active ref + // synchronously, so a mismatch means the user moved on. + return abortForSessionSwitch(sessionId) + } else { + const fallbackId = createdId + + // Move the optimistic message from the dead chat into the one + // the create re-homed to, and re-pin the drift baseline there. + dropOptimistic(sessionId) + optimisticStoredSessionId = selectedStoredSessionIdRef.current + startingStoredSessionId = selectedStoredSessionIdRef.current + startingRouteToken = getRouteToken() + sessionId = fallbackId + seedOptimistic(fallbackId) + + // Attachments were staged into the dead session — re-sync them + // against the fresh one so @file:/image refs resolve there. + const resyncedAttachments = await syncAttachmentsForSubmit(fallbackId, syncedAttachments, { + updateComposerAttachments: usingComposerAttachments + }) + + if (sessionContextDrifted()) { + return abortForSessionSwitch(fallbackId) + } + + attachmentRefs = resyncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r)) + rewriteOptimistic(fallbackId) + + await withSessionBusyRetry(() => + requestGateway( + 'prompt.submit', + { session_id: fallbackId, text: buildContextText(resyncedAttachments) }, + PROMPT_SUBMIT_REQUEST_TIMEOUT_MS + ) + ) + } } else { submitErr = firstErr } @@ -539,7 +629,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { pendingBranchGroup: null, sawAssistantPayload: true }), - targetStoredSessionId + optimisticStoredSessionId ) if (targetIsCurrentView() && isProviderSetupError(err)) { From d904d12c41d7fd080985b0bf10a7ff7e153edf49 Mon Sep 17 00:00:00 2001 From: Jeongseok Kang Date: Mon, 20 Jul 2026 11:36:31 +0900 Subject: [PATCH 2/2] fix(desktop): recover dead-draft attachment staging, not just prompt.submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit file.attach / image.attach are session-scoped RPCs and the initial attachment sync runs BEFORE prompt.submit, so a dead first-submit draft with a newly selected attachment failed in staging — ahead of the double-404 recovery, which never got a chance to run. Extract the resume-or-mint recovery into recoverDeadRuntime and invoke it from both session-scoped failure points: the initial attachment sync and prompt.submit itself. A sync-time "session not found" now resumes the stored session (or mints a replacement for the never-persisted-draft double-404 signature), then re-stages the attachments against the live runtime the recovery produced. Behavior at the prompt.submit failure point is unchanged — same guards (timeout double-send, non-404 resume failure, background drains), now routed through the shared helper. Tests: file and image staging regressions covering attach-404 → recovery → re-stage → submit on the minted runtime; a resumed-runtime rebind case (stored row exists → no minting); and the optimistic-state test now asserts the stored-session key passed to updateSessionState, pinning that post-recovery state is keyed under the minted stored id rather than cross-wiring the dead draft's. Co-Authored-By: Claude Fable 5 --- .../hooks/use-prompt-actions/index.test.tsx | 221 +++++++++++++- .../hooks/use-prompt-actions/submit.ts | 282 +++++++++++------- 2 files changed, 384 insertions(+), 119 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 766ce0359419e..90b849a5ca73c 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 @@ -1719,19 +1719,23 @@ describe('usePromptActions sleep/wake session recovery', () => { // "Prompt failed / session not found" toast, losing the user's text. const STALE_SESSION_ID = 'rt-stale-dead' const FRESH_SESSION_ID = 'rt-fresh-789' + const FRESH_STORED_ID = 'stored-fresh-123' const activeSessionIdRef: MutableRefObject = { current: STALE_SESSION_ID } + const selectedStoredSessionIdRef: MutableRefObject = { current: STORED_SESSION_ID } // Mirror the real createBackendSessionForSend: a successful create re-homes - // the active runtime ref to the minted session BEFORE returning (the - // fallback's drift guard reads it). + // the active runtime ref AND the selected stored id to the minted session + // BEFORE returning (the fallback's drift guard and stored-key re-pin read + // both). const createBackendSessionForSend = vi.fn(async () => { activeSessionIdRef.current = FRESH_SESSION_ID + selectedStoredSessionIdRef.current = FRESH_STORED_ID return FRESH_SESSION_ID }) const calls: { method: string; params?: Record }[] = [] - const seededSessionIds: string[] = [] + const stateWrites: { sessionId: string; storedSessionId: null | string | undefined }[] = [] const requestGateway = vi.fn(async (method: string, params?: Record) => { calls.push({ method, params }) @@ -1754,10 +1758,10 @@ describe('usePromptActions sleep/wake session recovery', () => { activeSessionIdRef={activeSessionIdRef} createBackendSessionForSend={createBackendSessionForSend} onReady={h => (handle = h)} - onUpdateState={sessionId => seededSessionIds.push(sessionId)} + onUpdateState={(sessionId, storedSessionId) => stateWrites.push({ sessionId, storedSessionId })} refreshSessions={async () => undefined} requestGateway={requestGateway} - storedSessionId={STORED_SESSION_ID} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} /> ) @@ -1769,8 +1773,14 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) expect(calls[0]?.params).toMatchObject({ session_id: STALE_SESSION_ID }) expect(calls[2]?.params).toEqual({ session_id: FRESH_SESSION_ID, text: 'first message after reap' }) - // The optimistic message was re-seeded under the minted session. - expect(seededSessionIds).toContain(FRESH_SESSION_ID) + // The optimistic message was re-seeded under the minted runtime AND its + // minted stored id — the cache maps runtime state through the stored id, + // so keying it under the dead draft's stored id would cross-wire it. + expect(stateWrites).toContainEqual({ sessionId: FRESH_SESSION_ID, storedSessionId: FRESH_STORED_ID }) + // No post-recovery write may still key state under the dead stored id. + const freshSeedIndex = stateWrites.findIndex(w => w.sessionId === FRESH_SESSION_ID) + + expect(stateWrites.slice(freshSeedIndex).every(w => w.storedSessionId === FRESH_STORED_ID)).toBe(true) }) it('surfaces the original error (no new session) when the recovery resume fails for a non-404 reason', async () => { @@ -1889,6 +1899,203 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(ok).toBe(false) expect(createBackendSessionForSend).not.toHaveBeenCalled() }) + + it('recovers a dead draft whose FILE staging 404s before prompt.submit ever runs', async () => { + // file.attach is a session-scoped RPC, so with a newly selected attachment + // a dead first-submit draft dies in the initial sync — before the + // prompt.submit recovery could fire. The sync failure must take the same + // resume-or-mint path, then stage the file against the minted runtime. + const STALE_SESSION_ID = 'rt-stale-dead' + const FRESH_SESSION_ID = 'rt-fresh-789' + const FRESH_STORED_ID = 'stored-fresh-123' + const activeSessionIdRef: MutableRefObject = { current: STALE_SESSION_ID } + const selectedStoredSessionIdRef: MutableRefObject = { current: STORED_SESSION_ID } + + const createBackendSessionForSend = vi.fn(async () => { + activeSessionIdRef.current = FRESH_SESSION_ID + selectedStoredSessionIdRef.current = FRESH_STORED_ID + + return FRESH_SESSION_ID + }) + + const calls: { method: string; params?: Record }[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (params?.session_id === STALE_SESSION_ID) { + throw new Error('session not found') + } + + if (method === 'session.resume') { + throw new Error('session not found') + } + + if (method === 'file.attach') { + return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + + const ok = await handle!.submitText('summarize', { + attachments: [ + { + id: 'file:report.txt', + kind: 'file', + label: 'report.txt', + path: '/Users/alice/Downloads/report.txt', + refText: '@file:`/Users/alice/Downloads/report.txt`' + } + ] + }) + + expect(ok).toBe(true) + expect(createBackendSessionForSend).toHaveBeenCalledTimes(1) + // Dead staging → failed resume → mint → re-stage on the minted runtime → + // submit lands there with the re-staged ref. + expect(calls.map(c => c.method)).toEqual(['file.attach', 'session.resume', 'file.attach', 'prompt.submit']) + expect(calls[0]?.params).toMatchObject({ session_id: STALE_SESSION_ID }) + expect(calls[2]?.params).toMatchObject({ session_id: FRESH_SESSION_ID }) + expect(calls[3]?.params).toEqual({ + session_id: FRESH_SESSION_ID, + text: '@file:data/report.txt\n\nsummarize' + }) + }) + + it('recovers a dead draft whose IMAGE staging 404s before prompt.submit ever runs', async () => { + // Same shape as the file case via the image.attach RPC (images are never + // eager-uploaded, so a dead draft always hits this at submit time). + const STALE_SESSION_ID = 'rt-stale-dead' + const FRESH_SESSION_ID = 'rt-fresh-789' + const activeSessionIdRef: MutableRefObject = { current: STALE_SESSION_ID } + const selectedStoredSessionIdRef: MutableRefObject = { current: STORED_SESSION_ID } + + const createBackendSessionForSend = vi.fn(async () => { + activeSessionIdRef.current = FRESH_SESSION_ID + selectedStoredSessionIdRef.current = 'stored-fresh-123' + + return FRESH_SESSION_ID + }) + + const calls: { method: string; params?: Record }[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (params?.session_id === STALE_SESSION_ID) { + throw new Error('session not found') + } + + if (method === 'session.resume') { + throw new Error('session not found') + } + + if (method === 'image.attach') { + return { attached: true, path: '/tmp/hermes/img.png' } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + + const ok = await handle!.submitText('what is this?', { + attachments: [{ id: 'image:img.png', kind: 'image', label: 'img.png', path: '/Users/alice/Pictures/img.png' }] + }) + + expect(ok).toBe(true) + expect(createBackendSessionForSend).toHaveBeenCalledTimes(1) + expect(calls.map(c => c.method)).toEqual(['image.attach', 'session.resume', 'image.attach', 'prompt.submit']) + expect(calls[0]?.params).toMatchObject({ session_id: STALE_SESSION_ID }) + expect(calls[2]?.params).toMatchObject({ session_id: FRESH_SESSION_ID }) + expect(calls[3]?.params).toMatchObject({ session_id: FRESH_SESSION_ID }) + }) + + it('rebinds attachment staging to the RESUMED runtime when the stored row still exists', async () => { + // Same sync-time 404, but the chat has a persisted row (a real stored + // conversation, not a dead draft): recovery must rebind via session.resume + // and stage against the resumed runtime — no minting. + const STALE_SESSION_ID = 'rt-stale-dead' + const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG') + + const calls: { method: string; params?: Record }[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (params?.session_id === STALE_SESSION_ID) { + throw new Error('session not found') + } + + if (method === 'session.resume') { + return { session_id: RECOVERED_SESSION_ID } as never + } + + if (method === 'file.attach') { + return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + const ok = await handle!.submitText('summarize', { + attachments: [ + { + id: 'file:report.txt', + kind: 'file', + label: 'report.txt', + path: '/Users/alice/Downloads/report.txt', + refText: '@file:`/Users/alice/Downloads/report.txt`' + } + ] + }) + + expect(ok).toBe(true) + expect(createBackendSessionForSend).not.toHaveBeenCalled() + expect(calls.map(c => c.method)).toEqual(['file.attach', 'session.resume', 'file.attach', 'prompt.submit']) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) + expect(calls[2]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID }) + expect(calls[3]?.params).toEqual({ + session_id: RECOVERED_SESSION_ID, + text: '@file:data/report.txt\n\nsummarize' + }) + }) }) describe('usePromptActions submit session-context isolation (#54527)', () => { 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 deb561f5578b4..69e4025fb449c 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 @@ -290,6 +290,117 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { return false } + // Shared dead-runtime recovery for the two session-scoped failure points + // in the send pipeline (the initial attachment sync and prompt.submit + // itself): re-register the stored session in the gateway for a fresh + // live id. Timeouts recover the same way as "session not found": a + // starved backend loop (#55578 symptom d) rejects the call even though + // the stored session is fine — resume instead of erroring out and losing + // the session binding. + // + // The resume can itself fail: the gateway persists a chat's DB row only + // on its first successful prompt.submit, so a fresh chat whose live + // session died before that has no row and the resume 4007s the same + // "session not found". When BOTH the live id and the stored row report + // "session not found" (the never-persisted-draft signature), mint a + // replacement chat and re-home the optimistic message there. Scoped + // tightly: a timed-out call may have actually reached the backend + // (re-sending elsewhere would double-send), a non-404 resume failure + // (transport blip) must not split a real stored chat in two (#55578 + // symptom b), and a background drain must never re-home the user's view — + // all of those return null so the caller surfaces the original error. + // + // Returns 'aborted' after a drift abort (the caller must return false), + // null when the caller should surface the original error, or the live + // runtime id to continue the send on. + const recoverDeadRuntime = async ( + originalErr: unknown + ): Promise<'aborted' | null | { minted: boolean; sessionId: string }> => { + const recoverStoredSessionId = targetStoredSessionId ?? selectedStoredSessionIdRef.current + + if (!(isSessionNotFoundError(originalErr) || isGatewayTimeoutError(originalErr)) || !recoverStoredSessionId) { + return null + } + + let resumed: { session_id: string } | null = null + let resumeErr: unknown = null + + try { + resumed = await requestGateway<{ session_id: string }>('session.resume', { + session_id: recoverStoredSessionId, + source: 'desktop' + }) + } catch (err) { + resumeErr = err + } + + if (sessionContextDrifted()) { + abortForSessionSwitch(sessionId) + + return 'aborted' + } + + const recoveredId = resumed?.session_id + + if (recoveredId) { + if (targetIsCurrentView()) { + activeSessionIdRef.current = recoveredId + } + + return { minted: false, sessionId: recoveredId } + } + + if ( + !targetIsCurrentView() || + !isSessionNotFoundError(originalErr) || + resumeErr === null || + !isSessionNotFoundError(resumeErr) + ) { + return null + } + + let createdId: null | string = null + + try { + createdId = await createBackendSessionForSend(visibleText) + } catch { + createdId = null + } + + if (!createdId) { + // Null means the user switched sessions mid-create (it closes the + // orphan itself) — abort silently; a create failure keeps the + // original error. + if (sessionContextDrifted()) { + abortForSessionSwitch(sessionId) + + return 'aborted' + } + + return null + } + + if (activeSessionIdRef.current !== createdId) { + // Same re-home race as the primary create path below: every switch + // path re-nulls or retargets the active ref synchronously, so a + // mismatch means the user moved on. + abortForSessionSwitch(sessionId) + + return 'aborted' + } + + // Move the optimistic message from the dead chat into the one the + // create re-homed to, and re-pin the drift baseline there. + dropOptimistic(sessionId) + optimisticStoredSessionId = selectedStoredSessionIdRef.current + startingStoredSessionId = selectedStoredSessionIdRef.current + startingRouteToken = getRouteToken() + sessionId = createdId + seedOptimistic(createdId) + + return { minted: true, sessionId: createdId } + } + // Foreground-only state: a background queue drain must never write the // selected view's busy/awaiting flags or clear its notifications. if (targetIsCurrentView()) { @@ -442,9 +553,40 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } try { - const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, { - updateComposerAttachments: usingComposerAttachments - }) + // file.attach / image.attach are session-scoped RPCs, so a dead + // runtime fails HERE — before prompt.submit ever runs — whenever the + // draft has newly selected attachments. Recover through the same + // resume-or-mint path, then stage the attachments against the live id + // it produced. + let syncedAttachments: ComposerAttachment[] + + try { + syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, { + updateComposerAttachments: usingComposerAttachments + }) + } catch (syncErr) { + const recovery = isSessionNotFoundError(syncErr) ? await recoverDeadRuntime(syncErr) : null + + if (recovery === 'aborted') { + return false + } + + if (recovery === null) { + throw syncErr + } + + if (!recovery.minted) { + // The resumed runtime replaces the dead one for the rest of the + // pipeline (a mint already re-homed inside recoverDeadRuntime). + dropOptimistic(sessionId) + sessionId = recovery.sessionId + seedOptimistic(sessionId) + } + + syncedAttachments = await syncAttachmentsForSubmit(recovery.sessionId, attachments, { + updateComposerAttachments: usingComposerAttachments + }) + } if (sessionContextDrifted()) { return abortForSessionSwitch(sessionId) @@ -467,121 +609,37 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) ) } catch (firstErr) { - const recoverStoredSessionId = targetStoredSessionId ?? selectedStoredSessionIdRef.current - - if ((isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && recoverStoredSessionId) { - // 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. - // The resume can itself fail: the gateway persists a chat's DB row - // only on its first successful prompt.submit, so a fresh chat whose - // live session died before that has no row and the resume 4007s the - // same "session not found". Catch it so the dead-draft fallback - // below can recover instead of surfacing the raw error and losing - // the user's text. - let resumed: { session_id: string } | null = null - let resumeErr: unknown = null - - try { - resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: recoverStoredSessionId, - source: 'desktop' - }) - } catch (err) { - resumeErr = err - } - - if (sessionContextDrifted()) { - return abortForSessionSwitch(sessionId) - } + const recovery = await recoverDeadRuntime(firstErr) - const recoveredId = resumed?.session_id + if (recovery === 'aborted') { + return false + } - if (recoveredId) { - if (targetIsCurrentView()) { - activeSessionIdRef.current = recoveredId - } + if (recovery === null) { + submitErr = firstErr + } else { + const retryId = recovery.sessionId + let retryText = text + + if (recovery.minted) { + // Attachments were staged into the dead session — re-sync them + // against the minted one so @file:/image refs resolve there. + const resyncedAttachments = await syncAttachmentsForSubmit(retryId, syncedAttachments, { + updateComposerAttachments: usingComposerAttachments + }) - await withSessionBusyRetry(() => - requestGateway('prompt.submit', { session_id: recoveredId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) - ) - } else if ( - targetIsCurrentView() && - isSessionNotFoundError(firstErr) && - resumeErr !== null && - isSessionNotFoundError(resumeErr) - ) { - // Live session AND stored row both report "session not found": - // this chat never got past its first submit, so there is nothing - // to resume anywhere — mint a fresh backend session and land the - // message there. Scoped tightly: a timed-out firstErr may have - // actually reached the backend (re-sending elsewhere would - // double-send), a non-404 resume failure (transport blip) must - // not split a real stored chat in two (#55578 symptom b), and a - // background drain must never re-home the user's view — all of - // those keep the surface-the-error behavior below. - let createdId: null | string = null - - try { - createdId = await createBackendSessionForSend(visibleText) - } catch { - createdId = null + if (sessionContextDrifted()) { + return abortForSessionSwitch(retryId) } - if (!createdId) { - // Null means the user switched sessions mid-create (it closes - // the orphan itself) — abort silently; a create failure keeps - // the original error. - if (sessionContextDrifted()) { - return abortForSessionSwitch(sessionId) - } - - submitErr = firstErr - } else if (activeSessionIdRef.current !== createdId) { - // Same re-home race as the primary create path above: every - // switch path re-nulls or retargets the active ref - // synchronously, so a mismatch means the user moved on. - return abortForSessionSwitch(sessionId) - } else { - const fallbackId = createdId - - // Move the optimistic message from the dead chat into the one - // the create re-homed to, and re-pin the drift baseline there. - dropOptimistic(sessionId) - optimisticStoredSessionId = selectedStoredSessionIdRef.current - startingStoredSessionId = selectedStoredSessionIdRef.current - startingRouteToken = getRouteToken() - sessionId = fallbackId - seedOptimistic(fallbackId) - - // Attachments were staged into the dead session — re-sync them - // against the fresh one so @file:/image refs resolve there. - const resyncedAttachments = await syncAttachmentsForSubmit(fallbackId, syncedAttachments, { - updateComposerAttachments: usingComposerAttachments - }) - - if (sessionContextDrifted()) { - return abortForSessionSwitch(fallbackId) - } - - attachmentRefs = resyncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r)) - rewriteOptimistic(fallbackId) - - await withSessionBusyRetry(() => - requestGateway( - 'prompt.submit', - { session_id: fallbackId, text: buildContextText(resyncedAttachments) }, - PROMPT_SUBMIT_REQUEST_TIMEOUT_MS - ) - ) - } - } else { - submitErr = firstErr + attachmentRefs = resyncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r)) + rewriteOptimistic(retryId) + retryText = buildContextText(resyncedAttachments) } - } else { - submitErr = firstErr + + await withSessionBusyRetry(() => + requestGateway('prompt.submit', { session_id: retryId, text: retryText }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + ) } }