From 8fb0fc6ae653f74e9e2c53bc567549e7e12809d6 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 23 Sep 2026 10:06:51 -0400 Subject: [PATCH] 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 --- .../submit-view-binding.test.tsx | 205 ++++++++++++++++++ .../hooks/use-prompt-actions/submit.ts | 33 ++- 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/app/session/hooks/use-prompt-actions/submit-view-binding.test.tsx diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit-view-binding.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit-view-binding.test.tsx new file mode 100644 index 0000000000..99618264bb --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit-view-binding.test.tsx @@ -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>()), + 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 +type Submit = (text: string) => Promise + +// 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: (method: string, params?: Record) => Promise + selectedStoredSessionIdRef: MutableRefObject +}) { + const activeSessionId = useStore($activeSessionId) + const busyRef: MutableRefObject = { 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, cache) + + return null +} + +async function mountOnStaleRuntime() { + const selectedStoredSessionIdRef: MutableRefObject = { 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( + { + 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']) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 7c2a00bc0c..0c4b133aaf 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -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 {