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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user