From 93ed11379b6a7203f87ca8a9cb3e4e33dda4e6a7 Mon Sep 17 00:00:00 2001 From: pierrenode <298902573+pierrenode@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:21:54 +0300 Subject: [PATCH] fix(desktop): recover the tile and regenerate paths from a dead runtime id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit after a stale runtime-session drop (the sleep/wake 404 that resumes the stored session and retries once), but two call sites still build their gateway call directly instead of routing through withSessionNotFoundResume: - session-tile-actions.ts's own cancelRun/steerPrompt/reloadFromMessage — the tile's OWN UI handlers (wired directly by session-tile.tsx as onCancel/onSteer/onReload), distinct from use-session-tile-delegate.ts's interruptSession/submitToSession (used by external callers like quick-entry-bridge), which #81261 did wrap. - use-prompt-actions/index.ts's reloadFromMessage (the primary chat's own "Regenerate") — it builds its prompt.submit call inline instead of going through the shared send() helper every other action in this file uses, so it never picked up the recovery wrapper. After sleep/wake (the exact scenario #81261 targets), clicking Stop, sending a steering correction, or clicking Regenerate on a tile or the primary chat surfaces a raw "session not found" error instead of silently resuming, even though #81261 landed the day before. Wrap all four call sites in withSessionNotFoundResume, mirroring the existing pattern each file already uses elsewhere (submitRewind/ syncAttachmentsForSubmit in session-tile-actions.ts, redirectPrompt/send in index.ts) — resolve the stored session, resume once, retry, and rebind the live runtime ref via onRecovered. --- .../src/app/chat/session-tile-actions.test.ts | 134 ++++++++++++++++++ .../src/app/chat/session-tile-actions.ts | 33 ++++- .../hooks/use-prompt-actions/index.test.tsx | 59 ++++++++ 3 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/app/chat/session-tile-actions.test.ts diff --git a/apps/desktop/src/app/chat/session-tile-actions.test.ts b/apps/desktop/src/app/chat/session-tile-actions.test.ts new file mode 100644 index 0000000000..061c739dcd --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile-actions.test.ts @@ -0,0 +1,134 @@ +import { renderHook } from '@testing-library/react' +import { act } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { MAIN_COMPOSER_SCOPE } from './composer/scope' + +const requestGatewayMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ requestGateway: requestGatewayMock }) +})) + +const { setSessionTileDelegate } = await import('@/store/session-states') +const { useSessionTileActions } = await import('./session-tile-actions') + +const RUNTIME_SESSION_ID = 'rt-tile-current' +const STORED_SESSION_ID = 'stored-tile-db' +const RECOVERED_SESSION_ID = 'rt-tile-recovered' + +function renderTileActions() { + return renderHook(() => + useSessionTileActions({ + runtimeId: RUNTIME_SESSION_ID, + scope: MAIN_COMPOSER_SCOPE, + storedSessionId: STORED_SESSION_ID + }) + ) +} + +// A tile's cancelRun/steerPrompt/reloadFromMessage each build their own +// requestGateway call directly instead of going through the shared +// submitPromptText pipeline (which already wraps its call in +// withSessionNotFoundResume) — see use-prompt-actions/index.test.tsx's +// "sleep/wake session recovery" suite for the same regression on the +// primary chat's own reloadFromMessage. +describe('useSessionTileActions sleep/wake session recovery', () => { + beforeEach(() => { + setSessionTileDelegate({ + archiveSession: vi.fn(async () => undefined), + branchSession: vi.fn(async () => undefined), + deleteSession: vi.fn(async () => undefined), + executeSlash: vi.fn(async () => undefined), + interruptSession: vi.fn(async () => undefined), + resumeTile: vi.fn(async () => RUNTIME_SESSION_ID), + submitToSession: vi.fn(async () => undefined), + updateSession: vi.fn((_runtimeId, updater) => + updater({ + attachedImages: [], + busy: false, + cwd: null, + messages: [], + model: null, + streamId: null, + storedSessionId: STORED_SESSION_ID + } as never) + ) + }) + }) + + afterEach(() => { + requestGatewayMock.mockReset() + vi.restoreAllMocks() + }) + + 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 + + requestGatewayMock.mockImplementation(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 {} + } + + if (method === 'session.resume') { + return { session_id: RECOVERED_SESSION_ID } + } + + return {} + }) + + const { result } = renderTileActions() + + await act(async () => { + await result.current.cancelRun() + }) + + // First interrupt (stale id) → session.resume (stored id) → retry interrupt (fresh id). + 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, source: 'desktop', omit_messages: true }) + expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID }) + }) + + it('resumes the stored session and retries once when session.redirect (steer) reports "session not found"', async () => { + const calls: { method: string; params?: Record }[] = [] + let redirectAttempts = 0 + + requestGatewayMock.mockImplementation(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'session.redirect') { + redirectAttempts += 1 + + if (redirectAttempts === 1) { + throw new Error('session not found') + } + + return { status: 'redirected' } + } + + if (method === 'session.resume') { + return { session_id: RECOVERED_SESSION_ID } + } + + return {} + }) + + const { result } = renderTileActions() + + const ok = await act(async () => result.current.steerPrompt('actually use Postgres')) + + expect(ok).toBe(true) + expect(calls.map(c => c.method)).toEqual(['session.redirect', 'session.resume', 'session.redirect']) + expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'actually use Postgres' }) + }) +}) diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index 274be26c6f..9750c60955 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -49,7 +49,8 @@ import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit' import { markSessionRecentlyInterrupted, shouldInterruptBeforeRewind, - type SubmitTextOptions + type SubmitTextOptions, + withSessionNotFoundResume } from '../session/hooks/use-prompt-actions/utils' import { upsertOptimisticSession } from '../session/hooks/use-session-actions/utils' @@ -301,7 +302,17 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses clearClarifyRequest(undefined, sessionId) try { - await requestGateway('session.interrupt', { session_id: sessionId }) + await withSessionNotFoundResume( + sessionId, + storedIdRef.current, + liveId => requestGateway('session.interrupt', { session_id: liveId }), + { + requestGateway, + onRecovered: recoveredId => { + runtimeIdRef.current = recoveredId + } + } + ) } catch (err) { notifyError(err, copy.stopFailed) } @@ -352,10 +363,17 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses }) try { - const result = await requestGateway<{ status?: string }>('session.redirect', { - session_id: sessionId, - text - }) + const { result } = await withSessionNotFoundResume( + sessionId, + storedIdRef.current, + liveId => requestGateway<{ status?: string }>('session.redirect', { session_id: liveId, text }), + { + requestGateway, + onRecovered: recoveredId => { + runtimeIdRef.current = recoveredId + } + } + ) if (result?.status === 'redirected') { triggerHaptic('submit') @@ -446,6 +464,9 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses update(current => applyReloadOptimistic(current, plan)) try { + // Recovery for a dead runtime id rides inside submitRewind → + // runRewindSubmit (withSessionNotFoundResume + runtime rebind), so the + // PR-era inline prompt.submit wrapper is superseded on current main. applySurvivorRowIds( await submitRewind( plan.text, 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 ac7ff4e473..0f588ea51a 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 @@ -3202,6 +3202,65 @@ 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 reloadFromMessage (regenerate) reports "session not found"', async () => { + // reloadFromMessage builds its own prompt.submit call inline instead of + // going through the shared send() path submitText/redirectPrompt use, so + // it needs the same sleep/wake recovery independently — otherwise + // "Regenerate" on a stale session surfaces a raw error instead of + // silently resuming, same as the general submit case above. + // + // reloadFromMessage bails early on $busy — an earlier suite in this file + // can leave it true (see the stale-closure describe block's own note), + // so reset it defensively rather than relying on run order. + $busy.set(false) + setMessages([ + { id: 'u1', parts: [textPart('original prompt')], role: 'user', timestamp: 0 }, + { id: 'a1', parts: [textPart('reply')], role: 'assistant', timestamp: 1 } + ] as never) + + 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 + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + await handle!.reloadFromMessage('u1') + + // 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, source: 'desktop', omit_messages: true }) + expect(calls[2]?.params).toEqual( + expect.objectContaining({ session_id: RECOVERED_SESSION_ID, text: 'original prompt' }) + ) + }) + // #67603 (second symptom): a recovery resume must re-register on the session's // OWNING profile. Resuming on whichever profile is live forks the conversation // into the wrong profile's DB — the session then appears under both profiles.