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 9ac368fd44e14..b85a52fd0c539 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 @@ -3385,6 +3385,393 @@ 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 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 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 stateWrites: { sessionId: string; storedSessionId: null | string | undefined }[] = [] + + 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, storedSessionId) => stateWrites.push({ sessionId, storedSessionId })} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + + 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 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 () => { + // 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() + }) + + 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', omit_messages: true }) + 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 36c8bfd82d49f..531b6b31d1ce5 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 @@ -257,6 +257,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 + // recoverDeadRuntime 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 + // Reason string (or null) for why the session context genuinely drifted // under this in-flight submit. sessionContextDrift ignores the churn a // busy gateway produces (selection null-resets on a gateway/profile @@ -365,7 +372,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // (what made drained-after-interrupt sends go silent). interrupted: false }), - targetStoredSessionId + optimisticStoredSessionId ) } @@ -378,7 +385,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { ...state, messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message)) }), - targetStoredSessionId + optimisticStoredSessionId ) const dropOptimistic = (sid: null | string) => { @@ -399,7 +406,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { awaitingResponse: false, pendingBranchGroup: null }), - targetStoredSessionId + optimisticStoredSessionId ) } @@ -410,6 +417,131 @@ 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 { + // Re-register on the session's OWNING profile — resuming on whichever + // profile is live would fork the conversation into the wrong DB (#67603). + const resumeProfile = await resolveSessionProfile(recoverStoredSessionId) + + resumed = await requestGateway<{ session_id: string }>('session.resume', { + session_id: recoverStoredSessionId, + source: 'desktop', + omit_messages: true, + ...(resumeProfile ? { profile: resumeProfile } : {}) + }) + } catch (err) { + resumeErr = err + } + + const resumeRetryDrift = sessionDriftReason() + + if (resumeRetryDrift) { + console.warn('[submit-drift-abort]', resumeRetryDrift, { phase: 'post-resume-retry' }) + + 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(bubbleText) + } 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. + const createNullDrift = sessionDriftReason() + + if (createNullDrift) { + console.warn('[submit-drift-abort]', createNullDrift, { phase: 'post-recovery-create-null' }) + + 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()) { @@ -584,9 +716,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 + }) + } const attachmentsDrift = sessionDriftReason() @@ -603,9 +766,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { rewriteOptimistic(sessionId) const text = buildContextText(syncedAttachments) - const submitParams = (targetId: string) => ({ + // submitText overrides `text` when a recovery re-stages attachments + // into a freshly minted session (the @file: refs change with it). + const submitParams = (targetId: string, submitText: string = text) => ({ session_id: targetId, - text, + text: submitText, ...(interrupted && { interrupted }), // A queue drain is a "run after" message, never a live-turn // correction. The flag tells the gateway's busy path to hold it for @@ -620,51 +785,51 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // resume the stored session to re-register it, and retry once. let submitErr: unknown = null + // Pinned outside the retry callback: recoverDeadRuntime reassigns + // sessionId from a nested closure, so TS drops its non-null narrowing + // inside callbacks. + const submitSessionId = sessionId + try { await withSessionBusyRetry(() => - requestGateway('prompt.submit', submitParams(sessionId), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + requestGateway('prompt.submit', submitParams(submitSessionId), 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. - const resumeProfile = await resolveSessionProfile(recoverStoredSessionId) - - const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: recoverStoredSessionId, - source: 'desktop', - omit_messages: true, - ...(resumeProfile ? { profile: resumeProfile } : {}) - }) + const recovery = await recoverDeadRuntime(firstErr) - const resumeRetryDrift = sessionDriftReason() + if (recovery === 'aborted') { + return false + } - if (resumeRetryDrift) { - console.warn('[submit-drift-abort]', resumeRetryDrift, { phase: 'post-resume-retry' }) + if (recovery === null) { + submitErr = firstErr + } else { + const retryId = recovery.sessionId + let retryText = text - return abortForSessionSwitch(sessionId) - } + 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 + }) - const recoveredId = resumed?.session_id + const resyncDrift = sessionDriftReason() - if (recoveredId) { - if (targetIsCurrentView()) { - activeSessionIdRef.current = recoveredId + if (resyncDrift) { + console.warn('[submit-drift-abort]', resyncDrift, { phase: 'post-recovery-attachments' }) + + return abortForSessionSwitch(retryId) } - await withSessionBusyRetry(() => - requestGateway('prompt.submit', submitParams(recoveredId), 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', submitParams(retryId, retryText), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + ) } } @@ -712,7 +877,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { pendingBranchGroup: null, sawAssistantPayload: true }), - targetStoredSessionId + optimisticStoredSessionId ) if (targetIsCurrentView() && isProviderSetupError(err)) {