fix(desktop): preserve routed session on profile rebind (#65283)

Co-authored-by: geoffreybutler94 <257877469+geoffreybutler94@users.noreply.github.com>
This commit is contained in:
geoffreybutler94 2026-07-16 11:33:00 -07:00 committed by GitHub
parent bd37ff9138
commit f0ff8d5097
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 349 additions and 13 deletions

View file

@ -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),

View file

@ -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,

View file

@ -65,6 +65,8 @@ interface HarnessHandle {
function Harness({
activeSessionIdRef: activeSessionIdRefProp,
busyRef,
getRoutedStoredSessionId,
getRuntimeIdForStoredSession,
getRouteToken,
onReady,
onSeedState,
@ -80,6 +82,8 @@ function Harness({
}: {
activeSessionIdRef?: MutableRefObject<string | null>
busyRef?: MutableRefObject<boolean>
getRoutedStoredSessionId?: () => null | string
getRuntimeIdForStoredSession?: (storedSessionId: string) => null | string
getRouteToken?: () => string
onReady: (handle: HarnessHandle) => void
onSeedState?: (state: Record<string, unknown>) => 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<string | null> = { current: null }
const busyRef: MutableRefObject<boolean> = { 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(
<Harness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
busyRef={busyRef}
createBackendSessionForSend={createBackendSessionForSend}
onReady={h => (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<string | null> = { current: 'rt-wrong-profile' }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { 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(
<Harness
activeSessionId="rt-wrong-profile"
activeSessionIdRef={activeSessionIdRef}
createBackendSessionForSend={createBackendSessionForSend}
getRoutedStoredSessionId={() => 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<string | null> = { current: 'rt-wrong-profile' }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { 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(
<Harness
activeSessionId="rt-wrong-profile"
activeSessionIdRef={activeSessionIdRef}
getRoutedStoredSessionId={() => 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<string | null> = { current: RECOVERED_SESSION_ID }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_ID }
const requestGateway = vi.fn(async () => ({}) as never)
const resumeStoredSession = vi.fn()
let handle: HarnessHandle | null = null
await actRender(
<Harness
activeSessionId={RECOVERED_SESSION_ID}
activeSessionIdRef={activeSessionIdRef}
getRoutedStoredSessionId={() => 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<string | null> = { current: 'rt-wrong-profile' }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_ID }
const busyRef: MutableRefObject<boolean> = { 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(
<Harness
activeSessionId="rt-wrong-profile"
activeSessionIdRef={activeSessionIdRef}
busyRef={busyRef}
createBackendSessionForSend={createBackendSessionForSend}
getRoutedStoredSessionId={() => 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<string | null> = { current: null }

View file

@ -172,6 +172,8 @@ interface PromptActionsOptions {
busyRef: MutableRefObject<boolean>
branchCurrentSession: () => Promise<boolean>
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
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

View file

@ -31,13 +31,15 @@ import {
} from './utils'
interface SubmitPromptDeps {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
copy: Translations['desktop']
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
getRoutedStoredSessionId: () => null | string
getRuntimeIdForStoredSession: (storedSessionId: string) => null | string
getRouteToken: () => string
requestGateway: GatewayRequest
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
selectedStoredSessionIdRef: MutableRefObject<string | null>
syncAttachmentsForSubmit: (
sessionId: string,
@ -74,13 +76,15 @@ const MAIN_SUBMIT_SCOPE: NonNullable<SubmitPromptDeps['scope']> = {
/** 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,

View file

@ -83,6 +83,7 @@ interface SessionActionsOptions {
ensureSessionState: (sessionId: string, storedSessionId?: string | null) => ClientSessionState
getRouteToken: () => string
navigate: NavigateFunction
onFreshDraftRouteIntent?: () => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resetViewSync: () => void
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
@ -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(

View file

@ -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(<Harness activeSessionId={null} onReady={value => (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()
})
})

View file

@ -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,