fix(desktop): a submit that resumes the selected session moves the chat to the resumed runtime (#120273)

When submit cannot prove the pane's runtime owns the selected stored session
(reverse binding lost to eviction, reconnect, or a compression rotation the
selection never followed), it resumes the stored session and continues on
the runtime id that session.resume returns. That path pinned only
activeSessionIdRef. ChatView renders the $sessionStates slice named by
$activeSessionId, so the optimistic prompt, the reply, and every later turn
landed in a slice the pane never painted, while the legacy $messages mirror
(which the ref drives) looked correct. The chat stayed frozen until a relaunch.

Rebind the atom together with the ref, as the session-not-found recovery in
the same file already does, and carry the transcript the pane was showing
into the resumed runtime's empty slice (session.resume omits messages) when
both name the same conversation by lineage. A pane runtime that belongs to a
different stored session is never carried over.

Refs #71733
Refs #117867
This commit is contained in:
Austin Pickett
2026-09-23 10:06:51 -04:00
committed by GitHub
parent 3591bf3bb8
commit 8fb0fc6ae6
2 changed files with 237 additions and 1 deletions

View File

@@ -0,0 +1,205 @@
import { useStore } from '@nanostores/react'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PRIMARY_SESSION_VIEW } from '@/app/chat/session-view'
import { type ChatMessage, chatMessageText } from '@/lib/chat-messages'
import {
$activeSessionId,
$messages,
setActiveSessionId,
setActiveSessionStoredIdRotation,
setSelectedStoredSessionId,
setSessions
} from '@/store/session'
import { clearAllSessionStates } from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'
import { useSessionStateCache } from '../use-session-state-cache'
import { clearSingleFlightSessionResumeState } from './single-flight-resume'
import { usePromptActions } from '.'
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
getSession: vi.fn(),
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000,
setApiRequestProfile: vi.fn(),
transcribeAudio: vi.fn()
}))
vi.mock('@/store/gateway', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
requestGatewayForAgent: vi.fn()
}))
const STORED = 'stored-b'
const STALE_RUNTIME = 'rt-stale'
const RESUMED_RUNTIME = 'rt-resumed'
const row = (id: string, role: ChatMessage['role'], text: string): ChatMessage => ({
id,
role,
parts: [{ type: 'text', text }]
})
const EARLIER = [row('u1', 'user', 'earlier prompt'), row('a1', 'assistant', 'earlier reply')]
type Cache = ReturnType<typeof useSessionStateCache>
type Submit = (text: string) => Promise<boolean>
// Real session-state cache + real prompt actions, with the pane's active id
// read from the same atom PRIMARY_SESSION_VIEW reads. Assertions are on what
// the chat RENDERS, not on the legacy `$messages` mirror.
function Harness({
onReady,
requestGateway,
selectedStoredSessionIdRef
}: {
onReady: (submit: Submit, cache: Cache) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
selectedStoredSessionIdRef: MutableRefObject<string | null>
}) {
const activeSessionId = useStore($activeSessionId)
const busyRef: MutableRefObject<boolean> = { current: false }
const cache = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId: selectedStoredSessionIdRef.current,
setAwaitingResponse: () => undefined,
setBusy: () => undefined,
setMessages: messages => $messages.set(messages)
})
const actions = usePromptActions({
activeSessionId,
activeSessionIdRef: cache.activeSessionIdRef,
branchCurrentSession: async () => true,
busyRef,
createBackendSessionForSend: async () => null,
getRoutedStoredSessionId: () => null,
getRuntimeIdForStoredSession: cache.getRuntimeIdForStoredSession,
getRouteToken: () => 'token',
handleSkinCommand: () => '',
openMemoryGraph: () => undefined,
refreshSessions: async () => undefined,
requestGateway,
resumeStoredSession: () => undefined,
runtimeIdByStoredSessionIdRef: cache.runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
startFreshSessionDraft: () => undefined,
sttEnabled: false,
updateSessionState: cache.updateSessionState
})
onReady(text => act(async () => actions.submitText(text)) as Promise<boolean>, cache)
return null
}
async function mountOnStaleRuntime() {
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED }
setSelectedStoredSessionId(STORED)
setActiveSessionId(STALE_RUNTIME)
const requestGateway = vi.fn(
async (method: string) => (method === 'session.resume' ? { session_id: RESUMED_RUNTIME } : {}) as never
)
let submit!: Submit
let cache!: Cache
render(
<Harness
onReady={(s, c) => {
submit = s
cache = c
}}
requestGateway={requestGateway}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
/>
)
await waitFor(() => expect(submit).toBeDefined())
return { cache, requestGateway, submit: (text: string) => submit(text) }
}
const rendered = () => PRIMARY_SESSION_VIEW.$messages.get().map(chatMessageText)
describe('a submit that resumes the selected session moves the chat to the resumed runtime (#71733, #117867)', () => {
beforeEach(() => {
clearSingleFlightSessionResumeState()
clearAllSessionStates()
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
clearAllSessionStates()
$messages.set([])
setActiveSessionId(null)
setActiveSessionStoredIdRotation(null)
setSelectedStoredSessionId(null)
setSessions([])
})
it('paints the prompt and keeps the thread when the ownership proof was lost', async () => {
const { cache, requestGateway, submit } = await mountOnStaleRuntime()
act(() => {
cache.updateSessionState(STALE_RUNTIME, state => ({ ...state, messages: EARLIER }), STORED)
})
// The stored→runtime entry is gone (evicted, reconnect, rotation), so
// submit's entry-time ownership check refuses the pane's runtime and
// resumes the stored session, which answers with a NEW runtime id.
cache.runtimeIdByStoredSessionIdRef.current.delete(STORED)
expect(rendered()).toEqual(['earlier prompt', 'earlier reply'])
await submit('the prompt that vanished')
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
expect.objectContaining({ session_id: RESUMED_RUNTIME }),
expect.anything()
)
expect($activeSessionId.get()).toBe(RESUMED_RUNTIME)
expect(rendered()).toEqual(['earlier prompt', 'earlier reply', 'the prompt that vanished'])
})
it('follows the resumed runtime after a compression rotation the selection never followed', async () => {
setSessions(() => [{ _lineage_root_id: STORED, id: `${STORED}-next` } as SessionInfo])
const { cache, submit } = await mountOnStaleRuntime()
act(() => {
cache.updateSessionState(STALE_RUNTIME, state => ({ ...state, messages: EARLIER }), STORED)
// Auto-compression rotates the stored id on the live runtime. The
// reverse entry for the old id is dropped; the pane selection still
// names the old id.
cache.updateSessionState(STALE_RUNTIME, state => state, `${STORED}-next`)
})
await submit('asked again after compaction')
expect($activeSessionId.get()).toBe(RESUMED_RUNTIME)
expect(rendered()).toEqual(['earlier prompt', 'earlier reply', 'asked again after compaction'])
})
it('never carries another conversation into the resumed runtime', async () => {
const { cache, submit } = await mountOnStaleRuntime()
act(() => {
// The pane's runtime is proven to belong to a DIFFERENT stored session.
cache.updateSessionState(STALE_RUNTIME, state => ({ ...state, messages: EARLIER }), 'stored-other')
})
await submit('first prompt in B')
expect($activeSessionId.get()).toBe(RESUMED_RUNTIME)
expect(rendered()).toEqual(['first prompt in B'])
})
})

View File

@@ -24,6 +24,7 @@ import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { consumePendingCredentialWarning, requestDesktopOnboarding } from '@/store/onboarding'
import { isStoredTranscriptReadOnly } from '@/store/read-only-transcript'
import {
$activeSessionId,
$sessions,
resolveComposerSessionKey,
setActiveSessionId,
@@ -393,6 +394,36 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
}
}
// Point the pane at a runtime this submit just resumed for its stored
// session. ChatView renders the `$sessionStates` slice named by
// `$activeSessionId`, while the optimistic row and every stream event
// land in the resumed runtime's slice — pinning only the ref left the
// chat painting the dead runtime, so the prompt, its reply, and every
// later turn stayed invisible until a relaunch (#71733, #117867).
// `session.resume` omits messages, so carry the transcript the pane is
// showing into the empty slice (same conversation only — lineage-
// matched, since compression rotates the tip id) instead of collapsing
// the thread to the new prompt until the next refresh.
const rebindPaneToResumedRuntime = (sid: string, storedId: string) => {
const paneRuntimeId = $activeSessionId.get()
const paneState = paneRuntimeId && paneRuntimeId !== sid ? $sessionStates.get()[paneRuntimeId] : undefined
const sessions = $sessions.get()
if (
paneState?.messages.length &&
paneState.storedSessionId &&
resolveComposerSessionKey(paneState.storedSessionId, sessions) ===
resolveComposerSessionKey(storedId, sessions)
) {
const carried = paneState.messages
updateSessionState(sid, state => (state.messages.length ? state : { ...state, messages: carried }), storedId)
}
activeSessionIdRef.current = sid
setActiveSessionId(sid)
}
// Idempotent optimistic insert — re-running with the resolved sessionId
// after createBackendSessionForSend just overwrites with the same id.
const seedOptimistic = (sid: string) => {
@@ -635,7 +666,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
sessionId = resumed.session_id
if (targetIsCurrentView()) {
activeSessionIdRef.current = sessionId
rebindPaneToResumedRuntime(sessionId, targetStoredSessionId)
}
}
} catch {