Merge pull request #44047 from kshitijk4poor/salvage/desktop-stop-stale-session

fix(desktop): recover stale session before stop
This commit is contained in:
kshitij 2026-06-10 23:23:38 -07:00 committed by GitHub
commit 4829f8d2c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 81 additions and 8 deletions

View file

@ -42,6 +42,7 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
}
interface HarnessHandle {
cancelRun: () => Promise<void>
steerPrompt: (text: string) => Promise<boolean>
submitText: (
text: string,
@ -102,8 +103,12 @@ function Harness({
})
useEffect(() => {
onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText })
}, [actions.steerPrompt, actions.submitText, onReady])
onReady({
cancelRun: actions.cancelRun,
steerPrompt: actions.steerPrompt,
submitText: actions.submitText
})
}, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady])
return null
}
@ -629,6 +634,43 @@ 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 session.interrupt reports "session not found"', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let interruptAttempts = 0
const requestGateway = vi.fn(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 {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
await waitFor(() => expect(handle).not.toBeNull())
await handle!.cancelRun()
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 })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
})
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
const calls: string[] = []
const states: Record<string, unknown>[] = []
@ -818,4 +860,3 @@ describe('uploadComposerAttachment remote read failures', () => {
).rejects.toThrow('ENOENT: no such file')
})
})

View file

@ -108,6 +108,12 @@ function inlineErrorMessage(error: unknown, fallback: string): string {
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
}
function isSessionNotFoundError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /session not found/i.test(message)
}
function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
@ -661,9 +667,7 @@ export function usePromptActions({
try {
await requestGateway('prompt.submit', { session_id: sessionId, text })
} catch (firstErr) {
const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr)
if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) {
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
// Re-register the session in the gateway and get a fresh live ID.
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
@ -1273,11 +1277,39 @@ export function usePromptActions({
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch (err) {
let stopError = err
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
try {
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
await requestGateway('session.interrupt', { session_id: recoveredId })
return
}
} catch (resumeErr) {
stopError = resumeErr
}
}
setMutableRef(busyRef, false)
setBusy(false)
notifyError(err, copy.stopFailed)
notifyError(stopError, copy.stopFailed)
}
}, [activeSessionId, activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, updateSessionState])
}, [
activeSessionId,
activeSessionIdRef,
busyRef,
copy.stopFailed,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
])
// Steer = nudge the live turn without interrupting: the gateway appends the
// text to the next tool result so the model reads it on its next iteration