When a stranded session window's bounded auto-retry gives up (gateway resume RPC + REST fallback fail through all MAX_RESUME_RETRIES attempts), the loader latched forever. Add a $resumeExhaustedSessionId atom armed at the give-up point so the chat view swaps the perpetual spinner for an explicit error state + manual Retry button. Retry / reconnect / reselect clears the latch and resets the auto-retry counter for a fresh cycle; a route-change away from the stranded session also clears it. Distinct from $resumeFailedSessionId (armed during the backoff window) so the error UI only appears once auto-recovery has actually given up, not mid-retry. Adds i18n strings across en/ja/zh/zh-hant and 3 tests covering latch-arms-on-exhaustion, stays-clear-while-retries-remain, and clears-on-route-change.
412 lines
16 KiB
TypeScript
412 lines
16 KiB
TypeScript
import { cleanup, render } from '@testing-library/react'
|
|
import type { MutableRefObject } from 'react'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { $resumeExhaustedSessionId, setResumeExhaustedSessionId } from '@/store/session'
|
|
|
|
import { useRouteResume } from './use-route-resume'
|
|
|
|
interface HarnessProps {
|
|
activeSessionId: null | string
|
|
activeSessionIdRef: MutableRefObject<null | string>
|
|
creatingSessionRef: MutableRefObject<boolean>
|
|
currentView: string
|
|
freshDraftReady: boolean
|
|
gatewayState: string
|
|
locationPathname: string
|
|
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
|
resumeFailedSessionId?: null | string
|
|
routedSessionId: null | string
|
|
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
|
selectedStoredSessionId: null | string
|
|
selectedStoredSessionIdRef: MutableRefObject<null | string>
|
|
startFreshSessionDraft: (focus: boolean) => unknown
|
|
}
|
|
|
|
function RouteResumeHarness({ resumeFailedSessionId = null, ...props }: HarnessProps) {
|
|
useRouteResume({ ...props, resumeFailedSessionId })
|
|
|
|
return null
|
|
}
|
|
|
|
describe('useRouteResume', () => {
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('does not re-resume the old session during a /:sid -> /new transition', () => {
|
|
const resumeSession = vi.fn(async () => undefined)
|
|
const startFreshSessionDraft = vi.fn()
|
|
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
|
|
const creatingSessionRef = { current: false }
|
|
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
|
|
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
|
|
|
|
const { rerender } = render(
|
|
<RouteResumeHarness
|
|
activeSessionId="runtime-1"
|
|
activeSessionIdRef={activeSessionIdRef}
|
|
creatingSessionRef={creatingSessionRef}
|
|
currentView="chat"
|
|
freshDraftReady={false}
|
|
gatewayState="open"
|
|
locationPathname="/session-1"
|
|
resumeSession={resumeSession}
|
|
routedSessionId="session-1"
|
|
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
|
selectedStoredSessionId="session-1"
|
|
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
|
startFreshSessionDraft={startFreshSessionDraft}
|
|
/>
|
|
)
|
|
|
|
expect(resumeSession).not.toHaveBeenCalled()
|
|
|
|
// Simulate startFreshSessionDraft state updates landing before route update.
|
|
activeSessionIdRef.current = null
|
|
selectedStoredSessionIdRef.current = null
|
|
rerender(
|
|
<RouteResumeHarness
|
|
activeSessionId={null}
|
|
activeSessionIdRef={activeSessionIdRef}
|
|
creatingSessionRef={creatingSessionRef}
|
|
currentView="chat"
|
|
freshDraftReady
|
|
gatewayState="open"
|
|
locationPathname="/session-1"
|
|
resumeSession={resumeSession}
|
|
routedSessionId="session-1"
|
|
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
|
selectedStoredSessionId={null}
|
|
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
|
startFreshSessionDraft={startFreshSessionDraft}
|
|
/>
|
|
)
|
|
|
|
expect(resumeSession).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('self-heals a stranded routed session (null selected/active, same pathname, not a fresh draft)', () => {
|
|
const resumeSession = vi.fn(async () => undefined)
|
|
const startFreshSessionDraft = vi.fn()
|
|
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
|
|
const creatingSessionRef = { current: false }
|
|
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
|
|
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
|
|
|
|
const { rerender } = render(
|
|
<RouteResumeHarness
|
|
activeSessionId="runtime-1"
|
|
activeSessionIdRef={activeSessionIdRef}
|
|
creatingSessionRef={creatingSessionRef}
|
|
currentView="chat"
|
|
freshDraftReady={false}
|
|
gatewayState="open"
|
|
locationPathname="/session-1"
|
|
resumeSession={resumeSession}
|
|
routedSessionId="session-1"
|
|
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
|
selectedStoredSessionId="session-1"
|
|
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
|
startFreshSessionDraft={startFreshSessionDraft}
|
|
/>
|
|
)
|
|
|
|
expect(resumeSession).not.toHaveBeenCalled()
|
|
|
|
// A create/stream race nulls selected/active but the route stays on the
|
|
// session and freshDraftReady is false (NOT a new-chat transition).
|
|
activeSessionIdRef.current = null
|
|
selectedStoredSessionIdRef.current = null
|
|
rerender(
|
|
<RouteResumeHarness
|
|
activeSessionId={null}
|
|
activeSessionIdRef={activeSessionIdRef}
|
|
creatingSessionRef={creatingSessionRef}
|
|
currentView="chat"
|
|
freshDraftReady={false}
|
|
gatewayState="open"
|
|
locationPathname="/session-1"
|
|
resumeSession={resumeSession}
|
|
routedSessionId="session-1"
|
|
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
|
selectedStoredSessionId={null}
|
|
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
|
startFreshSessionDraft={startFreshSessionDraft}
|
|
/>
|
|
)
|
|
|
|
expect(resumeSession).toHaveBeenCalledTimes(1)
|
|
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
|
|
})
|
|
|
|
it('resumes when pathname changes to a routed session', () => {
|
|
const resumeSession = vi.fn(async () => undefined)
|
|
const startFreshSessionDraft = vi.fn()
|
|
const activeSessionIdRef: MutableRefObject<null | string> = { current: null }
|
|
const creatingSessionRef = { current: false }
|
|
const runtimeIdByStoredSessionIdRef = { current: new Map() }
|
|
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: null }
|
|
|
|
const { rerender } = render(
|
|
<RouteResumeHarness
|
|
activeSessionId={null}
|
|
activeSessionIdRef={activeSessionIdRef}
|
|
creatingSessionRef={creatingSessionRef}
|
|
currentView="chat"
|
|
freshDraftReady
|
|
gatewayState="open"
|
|
locationPathname="/"
|
|
resumeSession={resumeSession}
|
|
routedSessionId={null}
|
|
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
|
selectedStoredSessionId={null}
|
|
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
|
startFreshSessionDraft={startFreshSessionDraft}
|
|
/>
|
|
)
|
|
|
|
expect(resumeSession).not.toHaveBeenCalled()
|
|
|
|
rerender(
|
|
<RouteResumeHarness
|
|
activeSessionId={null}
|
|
activeSessionIdRef={activeSessionIdRef}
|
|
creatingSessionRef={creatingSessionRef}
|
|
currentView="chat"
|
|
freshDraftReady
|
|
gatewayState="open"
|
|
locationPathname="/session-2"
|
|
resumeSession={resumeSession}
|
|
routedSessionId="session-2"
|
|
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
|
selectedStoredSessionId={null}
|
|
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
|
startFreshSessionDraft={startFreshSessionDraft}
|
|
/>
|
|
)
|
|
|
|
expect(resumeSession).toHaveBeenCalledTimes(1)
|
|
expect(resumeSession).toHaveBeenCalledWith('session-2', true)
|
|
})
|
|
|
|
it('resumes the selected route again when the gateway reconnects', () => {
|
|
const resumeSession = vi.fn(async () => undefined)
|
|
const startFreshSessionDraft = vi.fn()
|
|
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
|
|
const creatingSessionRef = { current: false }
|
|
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
|
|
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
|
|
|
|
const { rerender } = render(
|
|
<RouteResumeHarness
|
|
activeSessionId="runtime-1"
|
|
activeSessionIdRef={activeSessionIdRef}
|
|
creatingSessionRef={creatingSessionRef}
|
|
currentView="chat"
|
|
freshDraftReady={false}
|
|
gatewayState="open"
|
|
locationPathname="/session-1"
|
|
resumeSession={resumeSession}
|
|
routedSessionId="session-1"
|
|
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
|
selectedStoredSessionId="session-1"
|
|
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
|
startFreshSessionDraft={startFreshSessionDraft}
|
|
/>
|
|
)
|
|
|
|
expect(resumeSession).not.toHaveBeenCalled()
|
|
|
|
rerender(
|
|
<RouteResumeHarness
|
|
activeSessionId="runtime-1"
|
|
activeSessionIdRef={activeSessionIdRef}
|
|
creatingSessionRef={creatingSessionRef}
|
|
currentView="chat"
|
|
freshDraftReady={false}
|
|
gatewayState="closed"
|
|
locationPathname="/session-1"
|
|
resumeSession={resumeSession}
|
|
routedSessionId="session-1"
|
|
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
|
selectedStoredSessionId="session-1"
|
|
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
|
startFreshSessionDraft={startFreshSessionDraft}
|
|
/>
|
|
)
|
|
|
|
rerender(
|
|
<RouteResumeHarness
|
|
activeSessionId="runtime-1"
|
|
activeSessionIdRef={activeSessionIdRef}
|
|
creatingSessionRef={creatingSessionRef}
|
|
currentView="chat"
|
|
freshDraftReady={false}
|
|
gatewayState="open"
|
|
locationPathname="/session-1"
|
|
resumeSession={resumeSession}
|
|
routedSessionId="session-1"
|
|
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
|
selectedStoredSessionId="session-1"
|
|
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
|
startFreshSessionDraft={startFreshSessionDraft}
|
|
/>
|
|
)
|
|
|
|
expect(resumeSession).toHaveBeenCalledTimes(1)
|
|
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
|
|
})
|
|
})
|
|
|
|
describe('useRouteResume bounded auto-retry after a failed resume', () => {
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.useRealTimers()
|
|
vi.restoreAllMocks()
|
|
setResumeExhaustedSessionId(null)
|
|
})
|
|
|
|
// Common stranded-window props: gateway open, route on the session, no runtime
|
|
// yet, and the ref already synced to the route (resumeSession sets it at entry
|
|
// before failing) — the exact state that defeats the main effect's self-heal.
|
|
function strandedProps(resumeSession: (sid: string, focus: boolean) => Promise<unknown>) {
|
|
return {
|
|
activeSessionId: null,
|
|
activeSessionIdRef: { current: null } as MutableRefObject<null | string>,
|
|
creatingSessionRef: { current: false },
|
|
currentView: 'chat',
|
|
freshDraftReady: false,
|
|
gatewayState: 'open',
|
|
locationPathname: '/session-1',
|
|
resumeSession,
|
|
routedSessionId: 'session-1',
|
|
runtimeIdByStoredSessionIdRef: { current: new Map<string, string>() },
|
|
selectedStoredSessionId: 'session-1',
|
|
// Synced to the route by the failed resume's synchronous entry-write.
|
|
selectedStoredSessionIdRef: { current: 'session-1' } as MutableRefObject<null | string>,
|
|
startFreshSessionDraft: vi.fn()
|
|
}
|
|
}
|
|
|
|
it('retries the resume on backoff when the routed session is flagged as failed', () => {
|
|
vi.useFakeTimers()
|
|
const resumeSession = vi.fn(async () => undefined)
|
|
|
|
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="session-1" />)
|
|
|
|
// The main effect fires one resume on mount (pathname-changed). Clear it so
|
|
// we assert purely the bounded-retry effect's scheduled retry below.
|
|
resumeSession.mockClear()
|
|
|
|
// No immediate fire — the retry is scheduled behind the backoff timer.
|
|
expect(resumeSession).not.toHaveBeenCalled()
|
|
|
|
// First backoff window (1s) elapses → one retry.
|
|
vi.advanceTimersByTime(1_000)
|
|
expect(resumeSession).toHaveBeenCalledTimes(1)
|
|
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
|
|
})
|
|
|
|
it('does NOT retry a failed session that is not the routed one', () => {
|
|
vi.useFakeTimers()
|
|
const resumeSession = vi.fn(async () => undefined)
|
|
|
|
// The failure flag points at a different session than the route.
|
|
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="other-session" />)
|
|
resumeSession.mockClear() // drop the mount resume
|
|
|
|
vi.advanceTimersByTime(10_000)
|
|
expect(resumeSession).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('skips the scheduled retry if the session already recovered when the timer fires', () => {
|
|
vi.useFakeTimers()
|
|
const resumeSession = vi.fn(async () => undefined)
|
|
const props = strandedProps(resumeSession)
|
|
|
|
render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
|
resumeSession.mockClear() // drop the mount resume
|
|
|
|
// A resume landed while we waited: runtime is now bound.
|
|
props.activeSessionIdRef.current = 'runtime-1'
|
|
|
|
vi.advanceTimersByTime(8_000)
|
|
expect(resumeSession).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('stops retrying after MAX_RESUME_RETRIES consecutive failures', () => {
|
|
vi.useFakeTimers()
|
|
const resumeSession = vi.fn(async () => undefined)
|
|
const props = strandedProps(resumeSession)
|
|
|
|
// Model the real re-arm loop: resumeSession clears $resumeFailedSessionId at
|
|
// entry (null) and a repeat failure re-sets it ('session-1'). That null->id
|
|
// toggle is what re-runs the effect and advances the bounded counter. The
|
|
// routed session never changes, so the counter is NOT reset between cycles.
|
|
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
|
resumeSession.mockClear() // drop the mount resume; count only the retries
|
|
|
|
for (let i = 0; i < 8; i += 1) {
|
|
vi.advanceTimersByTime(8_000) // fire the scheduled retry (if any)
|
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />) // cleared at entry
|
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />) // re-armed on failure
|
|
}
|
|
|
|
// Capped at MAX_RESUME_RETRIES (4): a persistently dead backend can't
|
|
// hot-loop the resume forever.
|
|
expect(resumeSession.mock.calls.length).toBe(4)
|
|
|
|
// Once auto-retry gives up, the exhausted latch is armed for the routed
|
|
// session so the chat view can swap the perpetual loader for an explicit
|
|
// error + manual Retry instead of spinning forever.
|
|
expect($resumeExhaustedSessionId.get()).toBe('session-1')
|
|
})
|
|
|
|
it('does not arm the exhausted latch while retries remain', () => {
|
|
vi.useFakeTimers()
|
|
const resumeSession = vi.fn(async () => undefined)
|
|
const props = strandedProps(resumeSession)
|
|
|
|
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
|
resumeSession.mockClear()
|
|
|
|
// Two failure cycles — still under the 4-retry cap, so the latch must stay
|
|
// clear and the loader keeps spinning (auto-recovery hasn't given up yet).
|
|
for (let i = 0; i < 2; i += 1) {
|
|
vi.advanceTimersByTime(8_000)
|
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
|
|
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
|
|
}
|
|
|
|
expect($resumeExhaustedSessionId.get()).toBeNull()
|
|
})
|
|
|
|
it('clears a stale exhausted latch when the route moves off the stranded session', () => {
|
|
vi.useFakeTimers()
|
|
const resumeSession = vi.fn(async () => undefined)
|
|
const props = strandedProps(resumeSession)
|
|
|
|
// Pre-arm the latch as if this session had exhausted its retries.
|
|
setResumeExhaustedSessionId('session-1')
|
|
|
|
// Route is now on a different, healthy session that is not flagged as
|
|
// failed — the retry effect's "route moved off" branch clears the latch.
|
|
render(
|
|
<RouteResumeHarness
|
|
{...props}
|
|
activeSessionId="runtime-2"
|
|
activeSessionIdRef={{ current: 'runtime-2' }}
|
|
locationPathname="/session-2"
|
|
resumeFailedSessionId={null}
|
|
routedSessionId="session-2"
|
|
selectedStoredSessionId="session-2"
|
|
selectedStoredSessionIdRef={{ current: 'session-2' }}
|
|
/>
|
|
)
|
|
|
|
expect($resumeExhaustedSessionId.get()).toBeNull()
|
|
})
|
|
})
|