fix(desktop): retry empty resumed transcripts

This commit is contained in:
helix4u 2026-06-25 13:35:40 -06:00
parent 73c8d5a1e7
commit 5191ebba22
2 changed files with 178 additions and 42 deletions

View file

@ -3,9 +3,18 @@ import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages } from '@/hermes'
import { getSessionMessages, type SessionInfo } from '@/hermes'
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
import {
$activeSessionId,
$currentCwd,
$messages,
$resumeFailedSessionId,
setActiveSessionId,
setMessages,
setResumeFailedSessionId,
setSessions
} from '@/store/session'
import type { ClientSessionState } from '../../types'
@ -22,6 +31,25 @@ vi.mock('@/hermes', async importOriginal => ({
const RUNTIME_SESSION_ID = 'rt-new-001'
function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
ended_at: null,
id: 'stored-1',
input_tokens: 0,
is_active: false,
last_active: 1,
message_count: 0,
model: null,
output_tokens: 0,
preview: null,
source: 'desktop',
started_at: 1,
title: 'stored',
tool_call_count: 0,
...overrides
}
}
function Harness({
onReady,
requestGateway
@ -126,10 +154,14 @@ describe('createBackendSessionForSend profile routing', () => {
// succeeds must NOT leave the flag armed.
function ResumeHarness({
onReady,
requestGateway
requestGateway,
runtimeIdByStoredSessionIdRef,
sessionStateByRuntimeIdRef
}: {
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
runtimeIdByStoredSessionIdRef?: MutableRefObject<Map<string, string>>
sessionStateByRuntimeIdRef?: MutableRefObject<Map<string, ClientSessionState>>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
@ -142,10 +174,10 @@ function ResumeHarness({
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
runtimeIdByStoredSessionIdRef: runtimeIdByStoredSessionIdRef ?? ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
sessionStateByRuntimeIdRef: sessionStateByRuntimeIdRef ?? ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
})
@ -160,16 +192,22 @@ function ResumeHarness({
describe('resumeSession failure recovery', () => {
afterEach(() => {
cleanup()
setActiveSessionId(null)
setResumeFailedSessionId(null)
setMessages([])
setSessions([])
vi.restoreAllMocks()
})
async function runResume(
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>,
options: {
runtimeIdByStoredSessionIdRef?: MutableRefObject<Map<string, string>>
sessionStateByRuntimeIdRef?: MutableRefObject<Map<string, ClientSessionState>>
} = {}
): Promise<void> {
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} />)
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} {...options} />)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
}
@ -281,4 +319,84 @@ describe('resumeSession failure recovery', () => {
expect(resumeParams).not.toHaveProperty('lazy')
expect(resumeParams).not.toHaveProperty('eager_build')
})
it('arms the failure latch when resume succeeds with an empty transcript for a non-empty stored session', async () => {
setSessions([storedSession({ message_count: 4 })])
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [], session_id: 'stored-1' } as never)
await runResume(requestGateway)
expect($resumeFailedSessionId.get()).toBe('stored-1')
expect($activeSessionId.get()).toBeNull()
expect($messages.get()).toEqual([])
})
it('does not reuse an empty cached runtime view for a stored session with history', async () => {
const runtimeIdByStoredSessionIdRef = {
current: new Map([['stored-1', 'runtime-stale']])
} satisfies MutableRefObject<Map<string, string>>
const sessionStateByRuntimeIdRef = {
current: new Map([
[
'runtime-stale',
{
awaitingResponse: false,
branch: '',
busy: false,
cwd: '',
fast: false,
interrupted: false,
messages: [],
model: '',
needsInput: false,
pendingBranchGroup: null,
personality: '',
provider: '',
reasoningEffort: '',
sawAssistantPayload: false,
serviceTier: '',
storedSessionId: 'stored-1',
streamId: null,
turnStartedAt: null,
yolo: false
}
]
])
} satisfies MutableRefObject<Map<string, ClientSessionState>>
setSessions([storedSession({ message_count: 4 })])
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [{ content: 'existing text', role: 'user', timestamp: 1 }],
session_id: 'stored-1'
} as never)
await runResume(requestGateway, {
runtimeIdByStoredSessionIdRef,
sessionStateByRuntimeIdRef
})
expect(requestGateway).not.toHaveBeenCalledWith('session.usage', { session_id: 'runtime-stale' })
expect(runtimeIdByStoredSessionIdRef.current.has('stored-1')).toBe(false)
expect(sessionStateByRuntimeIdRef.current.has('runtime-stale')).toBe(false)
expect($activeSessionId.get()).toBe('runtime-1')
expect($messages.get().length).toBe(1)
})
})

View file

@ -221,6 +221,10 @@ function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string):
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
}
function sessionShouldHaveTranscript(session: SessionInfo | undefined): boolean {
return (session?.message_count ?? 0) > 0
}
function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
const lineage = session._lineage_root_id ?? session.id
@ -616,7 +620,7 @@ export function useSessionActions({
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
if (cachedRuntimeId && cachedState) {
const stored = $sessions.get().find(session => session.id === storedSessionId)
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile
const cachedViewState =
!cachedState.model && stored?.model != null
@ -630,41 +634,46 @@ export function useSessionActions({
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
}
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setActiveSessionId(cachedRuntimeId)
activeSessionIdRef.current = cachedRuntimeId
syncSessionStateToView(cachedRuntimeId, cachedViewState)
setCurrentCwd(cachedViewState.cwd)
setCurrentBranch(cachedViewState.branch)
setSessionStartedAt(Date.now())
try {
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
if (!isCurrentResume()) {
return
}
if (usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
return
} catch {
// The cached runtime id was minted by a prior backend instance. A
// pooled profile backend that gets idle-reaped (pruneSecondaryGateways)
// and respawned across a profile swap mints fresh ids, so this mapping
// now 404s ("session not found"). Drop it and fall through to a full
// resume that rebinds a live runtime id.
if (!isCurrentResume()) {
return
}
if (sessionShouldHaveTranscript(stored) && cachedViewState.messages.length === 0) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
} else {
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setActiveSessionId(cachedRuntimeId)
activeSessionIdRef.current = cachedRuntimeId
syncSessionStateToView(cachedRuntimeId, cachedViewState)
setCurrentCwd(cachedViewState.cwd)
setCurrentBranch(cachedViewState.branch)
setSessionStartedAt(Date.now())
try {
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
if (!isCurrentResume()) {
return
}
if (usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
return
} catch {
// The cached runtime id was minted by a prior backend instance. A
// pooled profile backend that gets idle-reaped (pruneSecondaryGateways)
// and respawned across a profile swap mints fresh ids, so this mapping
// now 404s ("session not found"). Drop it and fall through to a full
// resume that rebinds a live runtime id.
if (!isCurrentResume()) {
return
}
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
}
}
}
@ -678,7 +687,7 @@ export function useSessionActions({
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setSessionStartedAt(Date.now())
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile
applyStoredSessionPreviewRuntimeInfo(stored)
if (stored) {
@ -767,6 +776,15 @@ export function useSessionActions({
? currentMessages
: preserveLocalAssistantErrors(preferredMessages, currentMessages)
if (sessionShouldHaveTranscript(stored) && messagesForView.length === 0) {
setActiveSessionId(null)
activeSessionIdRef.current = null
setResumeFailedSessionId(storedSessionId)
resumedRunning = false
return
}
setActiveSessionId(resumed.session_id)
activeSessionIdRef.current = resumed.session_id
const runtimeInfo = applyRuntimeInfo(resumed.info)