fix(desktop): recover the tile and regenerate paths from a dead runtime id

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.
This commit is contained in:
pierrenode
2026-08-08 15:21:54 +03:00
committed by Teknium
parent 243352e7b8
commit 93ed11379b
3 changed files with 220 additions and 6 deletions
@@ -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<string, unknown> }[] = []
let interruptAttempts = 0
requestGatewayMock.mockImplementation(async (method: string, params?: Record<string, unknown>) => {
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<string, unknown> }[] = []
let redirectAttempts = 0
requestGatewayMock.mockImplementation(async (method: string, params?: Record<string, unknown>) => {
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' })
})
})
@@ -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,
@@ -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<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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(
<Harness
onReady={h => (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.