diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index 00146a704..4eca47f5c 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -130,15 +130,19 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses // The REAL submit pipeline with tile seams: session always exists, and the // scope's writers replace the global view/attachment writes. const submitPromptText = useSubmitPrompt({ - activeSessionId: runtimeId, activeSessionIdRef: runtimeIdRef, busyRef, copy, createBackendSessionForSend: async () => runtimeIdRef.current, + getRoutedStoredSessionId: () => storedIdRef.current, + getRuntimeIdForStoredSession: storedId => (storedId === storedIdRef.current ? runtimeIdRef.current : null), // A tile IS its session — no route to abandon, so the create-abort guard's // token is a stable constant (the guard never trips for a tile). getRouteToken: () => runtimeId, requestGateway, + // Tile ids are always bound before this hook mounts, so routed recovery is + // unreachable here; keep the shared submit contract explicit. + resumeStoredSession: () => undefined, selectedStoredSessionIdRef: storedIdRef, syncAttachmentsForSubmit, updateSessionState: (sessionId, updater) => sessionTileDelegate()!.updateSession(sessionId, updater), diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 6055666fe..e2acd1601 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -139,11 +139,23 @@ export function ContribWiring({ children }: { children: ReactNode }) { const profileScope = useStore($profileScope) const routedSessionId = routeSessionId(location.pathname) + const routedSessionIdRef = useRef(routedSessionId) + + routedSessionIdRef.current = routedSessionId const routeToken = `${location.pathname}:${location.search}:${location.hash}` const routeTokenRef = useRef(routeToken) routeTokenRef.current = routeToken const getRouteToken = useCallback(() => routeTokenRef.current, []) + const getRoutedStoredSessionId = useCallback( + () => routedSessionIdRef.current, + [] + ) + + const clearRoutedSessionIntent = useCallback(() => { + routedSessionIdRef.current = null + }, []) + // Mirror "the workspace is showing a full page" into its atom — the // workspace pane contribution re-registers headerVeto from it, so the main // zone's tab bar stands down on pages (and returns with the chat). @@ -171,6 +183,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { const { activeSessionIdRef, ensureSessionState, + getRuntimeIdForStoredSession, resetViewSync, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, @@ -375,6 +388,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { ensureSessionState, getRouteToken, navigate, + onFreshDraftRouteIntent: clearRoutedSessionIntent, requestGateway, resetViewSync, runtimeIdByStoredSessionIdRef, @@ -502,6 +516,8 @@ export function ContribWiring({ children }: { children: ReactNode }) { branchCurrentSession: branchInNewChat, busyRef, createBackendSessionForSend, + getRoutedStoredSessionId, + getRuntimeIdForStoredSession, getRouteToken, handleSkinCommand, openMemoryGraph: openStarmap, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index e35589010..d721498d2 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -65,6 +65,8 @@ interface HarnessHandle { function Harness({ activeSessionIdRef: activeSessionIdRefProp, busyRef, + getRoutedStoredSessionId, + getRuntimeIdForStoredSession, getRouteToken, onReady, onSeedState, @@ -80,6 +82,8 @@ function Harness({ }: { activeSessionIdRef?: MutableRefObject busyRef?: MutableRefObject + getRoutedStoredSessionId?: () => null | string + getRuntimeIdForStoredSession?: (storedSessionId: string) => null | string getRouteToken?: () => string onReady: (handle: HarnessHandle) => void onSeedState?: (state: Record) => void @@ -116,6 +120,8 @@ function Harness({ branchCurrentSession: async () => true, busyRef: localBusyRef, createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID), + getRoutedStoredSessionId: getRoutedStoredSessionId ?? (() => null), + getRuntimeIdForStoredSession: getRuntimeIdForStoredSession ?? (() => null), getRouteToken: getRouteToken ?? (() => 'token'), handleSkinCommand: () => '', openMemoryGraph: openMemoryGraph ?? (() => undefined), @@ -1344,6 +1350,209 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID }) }) + it('never replaces a selected stored session when its direct runtime resume fails', async () => { + const activeSessionIdRef: MutableRefObject = { current: null } + const busyRef: MutableRefObject = { current: false } + const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG') + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.resume') { + throw new Error('4007 session not found on the active profile') + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + expect(await handle!.submitText('keep me in the selected conversation')).toBe(false) + expect(busyRef.current).toBe(false) + expect(createBackendSessionForSend).not.toHaveBeenCalled() + expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything(), expect.anything()) + }) + + it('resumes the ROUTED stored session instead of minting a new one when profile switching cleared both session refs', async () => { + // A profile swap/reconnect can temporarily clear both volatile ids while + // the durable route still points at the conversation the user is viewing. + // Enter during that window must resume the routed chat, never create a + // contextless session (or create it against the transient wrong profile). + const activeSessionIdRef: MutableRefObject = { current: 'rt-wrong-profile' } + const selectedStoredSessionIdRef: MutableRefObject = { current: null } + let boundRuntimeId: string | null = null + const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG') + const requestGateway = vi.fn(async () => ({}) as never) + + const resumeStoredSession = vi.fn(async (storedSessionId: string) => { + expect(storedSessionId).toBe(STORED_SESSION_ID) + selectedStoredSessionIdRef.current = STORED_SESSION_ID + activeSessionIdRef.current = RECOVERED_SESSION_ID + boundRuntimeId = RECOVERED_SESSION_ID + }) + + let handle: HarnessHandle | null = null + await actRender( + STORED_SESSION_ID} + getRuntimeIdForStoredSession={() => boundRuntimeId} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + resumeStoredSession={resumeStoredSession} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={null} + /> + ) + + expect(await handle!.submitText('follow-up while the profile route is rebinding')).toBe(true) + expect(resumeStoredSession).toHaveBeenCalledWith(STORED_SESSION_ID) + expect(createBackendSessionForSend).not.toHaveBeenCalled() + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { session_id: RECOVERED_SESSION_ID, text: 'follow-up while the profile route is rebinding' }, + 1_800_000 + ) + }) + + it('lets the durable route replace a stale selected session and runtime before submit', async () => { + const activeSessionIdRef: MutableRefObject = { current: 'rt-wrong-profile' } + const selectedStoredSessionIdRef: MutableRefObject = { current: 'stored-wrong-profile' } + let boundRuntimeId: string | null = null + const requestGateway = vi.fn(async () => ({}) as never) + + const resumeStoredSession = vi.fn(async () => { + selectedStoredSessionIdRef.current = STORED_SESSION_ID + activeSessionIdRef.current = RECOVERED_SESSION_ID + boundRuntimeId = RECOVERED_SESSION_ID + }) + + let handle: HarnessHandle | null = null + await actRender( + STORED_SESSION_ID} + getRuntimeIdForStoredSession={() => boundRuntimeId} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + resumeStoredSession={resumeStoredSession} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={STORED_SESSION_ID} + /> + ) + + expect(await handle!.submitText('stay in the routed profile session')).toBe(true) + expect(resumeStoredSession).toHaveBeenCalledWith(STORED_SESSION_ID) + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { session_id: RECOVERED_SESSION_ID, text: 'stay in the routed profile session' }, + 1_800_000 + ) + }) + + it('submits directly when the routed stored session already owns the live runtime', async () => { + const activeSessionIdRef: MutableRefObject = { current: RECOVERED_SESSION_ID } + const selectedStoredSessionIdRef: MutableRefObject = { current: STORED_SESSION_ID } + const requestGateway = vi.fn(async () => ({}) as never) + const resumeStoredSession = vi.fn() + + let handle: HarnessHandle | null = null + await actRender( + STORED_SESSION_ID} + getRuntimeIdForStoredSession={() => RECOVERED_SESSION_ID} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + resumeStoredSession={resumeStoredSession} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={STORED_SESSION_ID} + /> + ) + + expect(await handle!.submitText('normal follow-up')).toBe(true) + expect(resumeStoredSession).not.toHaveBeenCalled() + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { session_id: RECOVERED_SESSION_ID, text: 'normal follow-up' }, + 1_800_000 + ) + }) + + it('never falls through to session.create or a stale runtime when routed-session recovery fails', async () => { + const activeSessionIdRef: MutableRefObject = { current: 'rt-wrong-profile' } + const selectedStoredSessionIdRef: MutableRefObject = { current: STORED_SESSION_ID } + const busyRef: MutableRefObject = { current: false } + let recoverySucceeds = false + let boundRuntimeId: string | null = null + + const createBackendSessionForSend = vi.fn(async () => 'brand-new-session-WRONG') + const requestGateway = vi.fn(async () => ({}) as never) + + const resumeStoredSession = vi.fn(async () => { + if (!recoverySucceeds) { + return + } + + activeSessionIdRef.current = RECOVERED_SESSION_ID + boundRuntimeId = RECOVERED_SESSION_ID + }) + + $messages.set([]) + + let handle: HarnessHandle | null = null + await actRender( + STORED_SESSION_ID} + getRuntimeIdForStoredSession={() => boundRuntimeId} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + resumeStoredSession={resumeStoredSession} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={STORED_SESSION_ID} + /> + ) + + expect(await handle!.submitText('do not fork me')).toBe(false) + expect(busyRef.current).toBe(false) + expect($messages.get()).toEqual([]) + expect(resumeStoredSession).toHaveBeenCalledWith(STORED_SESSION_ID) + expect(createBackendSessionForSend).not.toHaveBeenCalled() + expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything(), expect.anything()) + + // Prove the failed attempt released the per-session submit lock. The next + // send can recover and submit instead of being silently rejected forever. + recoverySucceeds = true + expect(await handle!.submitText('retry after recovery')).toBe(true) + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { session_id: RECOVERED_SESSION_ID, text: 'retry after recovery' }, + 1_800_000 + ) + }) + it('still creates a new session for a genuine new-chat draft (no stored session selected)', async () => { const activeSessionIdRef: MutableRefObject = { current: null } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 2e163fcc8..421c90716 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -172,6 +172,8 @@ interface PromptActionsOptions { busyRef: MutableRefObject branchCurrentSession: () => Promise createBackendSessionForSend: (preview?: string | null) => Promise + getRoutedStoredSessionId: () => null | string + getRuntimeIdForStoredSession: (storedSessionId: string) => null | string getRouteToken: () => string handleSkinCommand: (arg: string) => string openMemoryGraph: () => void @@ -201,6 +203,8 @@ export function usePromptActions({ busyRef, branchCurrentSession, createBackendSessionForSend, + getRoutedStoredSessionId, + getRuntimeIdForStoredSession, getRouteToken, handleSkinCommand, openMemoryGraph, @@ -365,13 +369,15 @@ export function usePromptActions({ }, [activeSessionId, composerAttachments, eagerlyUploadAttachment]) const submitPromptText = useSubmitPrompt({ - activeSessionId, activeSessionIdRef, busyRef, copy, createBackendSessionForSend, + getRoutedStoredSessionId, + getRuntimeIdForStoredSession, getRouteToken, requestGateway, + resumeStoredSession, selectedStoredSessionIdRef, syncAttachmentsForSubmit, updateSessionState 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 c53c7fe23..44b2d3ea1 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 @@ -31,13 +31,15 @@ import { } from './utils' interface SubmitPromptDeps { - activeSessionId: string | null activeSessionIdRef: MutableRefObject busyRef: MutableRefObject copy: Translations['desktop'] createBackendSessionForSend: (preview?: string | null) => Promise + getRoutedStoredSessionId: () => null | string + getRuntimeIdForStoredSession: (storedSessionId: string) => null | string getRouteToken: () => string requestGateway: GatewayRequest + resumeStoredSession: (storedSessionId: string) => Promise | void selectedStoredSessionIdRef: MutableRefObject syncAttachmentsForSubmit: ( sessionId: string, @@ -74,13 +76,15 @@ const MAIN_SUBMIT_SCOPE: NonNullable = { /** The prompt submit pipeline, extracted from usePromptActions. */ export function useSubmitPrompt(deps: SubmitPromptDeps) { const { - activeSessionId, activeSessionIdRef, busyRef, copy, createBackendSessionForSend, + getRoutedStoredSessionId, + getRuntimeIdForStoredSession, getRouteToken, requestGateway, + resumeStoredSession, selectedStoredSessionIdRef, syncAttachmentsForSubmit, updateSessionState, @@ -143,7 +147,24 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // not const — because a new-chat submit legitimately re-homes to the // session it creates (see the re-pin after createBackendSessionForSend). const startingActiveSessionId = activeSessionIdRef.current - let startingStoredSessionId = selectedStoredSessionIdRef.current + const selectedStoredSessionId = selectedStoredSessionIdRef.current + const routedStoredSessionId = getRoutedStoredSessionId() + + const routedRuntimeId = routedStoredSessionId + ? getRuntimeIdForStoredSession(routedStoredSessionId) + : null + + const routedSessionNeedsResume = Boolean( + routedStoredSessionId && + (selectedStoredSessionId !== routedStoredSessionId || + !startingActiveSessionId || + startingActiveSessionId !== routedRuntimeId) + ) + + let startingStoredSessionId = routedSessionNeedsResume + ? routedStoredSessionId + : (selectedStoredSessionId ?? routedStoredSessionId) + let startingRouteToken = getRouteToken() const sessionContextDrifted = (): boolean => @@ -249,7 +270,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { scope.setAwaitingResponse(true) clearNotifications() - let sessionId: null | string = activeSessionId + // A route whose selected/runtime binding is incomplete or cross-wired + // outranks any stale render-time runtime id (often from the previous + // profile). Force the full routed resume path below in that case. + let sessionId: null | string = routedSessionNeedsResume ? null : activeSessionIdRef.current if (sessionId) { seedOptimistic(sessionId) @@ -257,6 +281,40 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { scope.setMessages(current => [...current, buildUserMessage()]) } + if (!sessionId && routedStoredSessionId && routedSessionNeedsResume) { + // The URL still names a durable conversation, but a profile + // swap/reconnect left its volatile session binding incomplete or + // cross-wired. Run the full profile-aware resume path. Creating here + // would fork a contextless chat against whichever profile is active. + try { + await resumeStoredSession(routedStoredSessionId) + } catch { + return abortForSessionSwitch(null) + } + + if (sessionContextDrifted()) { + return abortForSessionSwitch(null) + } + + const recoveredRuntimeId = activeSessionIdRef.current + const validatedRuntimeId = getRuntimeIdForStoredSession(routedStoredSessionId) + + // Recovery only succeeded when both sides of the cache agree that the + // live runtime belongs to the durable routed session. A failed profile + // swap may leave the previous profile's runtime active, while a recycled + // runtime id may leave a cross-wired stored-session mapping. + if ( + !recoveredRuntimeId || + recoveredRuntimeId !== validatedRuntimeId || + selectedStoredSessionIdRef.current !== routedStoredSessionId + ) { + return abortForSessionSwitch(null) + } + + sessionId = recoveredRuntimeId + seedOptimistic(sessionId) + } + if (!sessionId && startingStoredSessionId) { // A stored session is SELECTED but its runtime binding is gone (the // live session was orphan-reaped, or a timeout/reconnect cleared @@ -279,18 +337,21 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { activeSessionIdRef.current = sessionId } } catch { - // Resume failed (session gone from state.db, gateway hiccup) — - // fall through to creating a fresh session rather than dead-ending - // the user's message. + // A selected stored conversation is not a new-chat draft. If its + // runtime cannot be rebound, stop here rather than silently replacing + // it with a contextless session. + return abortForSessionSwitch(null) } if (sessionContextDrifted()) { return abortForSessionSwitch(sessionId) } - if (sessionId) { - seedOptimistic(sessionId) + if (!sessionId) { + return abortForSessionSwitch(null) } + + seedOptimistic(sessionId) } if (!sessionId) { @@ -450,13 +511,15 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } }, [ - activeSessionId, activeSessionIdRef, busyRef, copy, createBackendSessionForSend, + getRoutedStoredSessionId, + getRuntimeIdForStoredSession, getRouteToken, requestGateway, + resumeStoredSession, scope, selectedStoredSessionIdRef, syncAttachmentsForSubmit, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index cd1a4f35c..63d349ad1 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -83,6 +83,7 @@ interface SessionActionsOptions { ensureSessionState: (sessionId: string, storedSessionId?: string | null) => ClientSessionState getRouteToken: () => string navigate: NavigateFunction + onFreshDraftRouteIntent?: () => void requestGateway: (method: string, params?: Record) => Promise resetViewSync: () => void runtimeIdByStoredSessionIdRef: MutableRefObject> @@ -162,6 +163,7 @@ export function useSessionActions({ ensureSessionState, getRouteToken, navigate, + onFreshDraftRouteIntent, requestGateway, resetViewSync, runtimeIdByStoredSessionIdRef, @@ -193,6 +195,11 @@ export function useSessionActions({ setAwaitingResponse(false) clearNotifications() setIntroSeed(seed => seed + 1) + // Clear the durable route intent synchronously, before React Router + // publishes /new. Submit uses that intent to heal an existing-session + // rebind race, so leaving the old id here could revive it on a very fast + // New Chat -> Enter sequence. + onFreshDraftRouteIntent?.() navigate(NEW_CHAT_ROUTE, { replace: replaceRoute }) setActiveSessionId(null) activeSessionIdRef.current = null @@ -231,7 +238,7 @@ export function useSessionActions({ // Never clear the composer here — ChatBar's per-thread draft swap owns it. setFreshDraftReady(true) }, - [activeSessionIdRef, busyRef, navigate, resetViewSync, selectedStoredSessionIdRef] + [activeSessionIdRef, busyRef, navigate, onFreshDraftRouteIntent, resetViewSync, selectedStoredSessionIdRef] ) const createBackendSessionForSend = useCallback( diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx index 025cb34b9..f875266dc 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx @@ -318,4 +318,22 @@ describe('useSessionStateCache — cross-thread error isolation', () => { expect($messages.get().some(message => message.error === 'OpenRouter 403')).toBe(true) }) + + it('only returns a runtime whose cached state owns the requested stored session', () => { + let cache!: Cache + render( (cache = value)} selectedStoredSessionId={null} />) + + act(() => { + cache.ensureSessionState('runtime-A', 'stored-A') + cache.ensureSessionState('runtime-B', 'stored-B') + }) + + expect(cache.getRuntimeIdForStoredSession('stored-A')).toBe('runtime-A') + expect(cache.getRuntimeIdForStoredSession('missing')).toBeNull() + + // Simulate a recycled/cross-wired map entry. The reverse state ownership + // check must reject it instead of allowing a submit into stored-B. + cache.runtimeIdByStoredSessionIdRef.current.set('stored-A', 'runtime-B') + expect(cache.getRuntimeIdForStoredSession('stored-A')).toBeNull() + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 7f89eec64..3e59dd972 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -295,6 +295,18 @@ export function useSessionStateCache({ [ensureSessionState, syncSessionStateToView] ) + const getRuntimeIdForStoredSession = useCallback((storedSessionId: string): string | null => { + const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) + + if (!runtimeId) { + return null + } + + const runtimeState = sessionStateByRuntimeIdRef.current.get(runtimeId) + + return runtimeState?.storedSessionId === storedSessionId ? runtimeId : null + }, []) + // When the store watchdog force-clears a stuck session (8 min of stream // silence — a hung or looping turn that never delivered its terminal event), // also drop that session's busy/awaiting flags here. Clearing the sidebar dot @@ -323,6 +335,7 @@ export function useSessionStateCache({ return { activeSessionIdRef, ensureSessionState, + getRuntimeIdForStoredSession, resetViewSync, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef,