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.