fix(desktop): preserve routed session on profile rebind (#65283)
Co-authored-by: geoffreybutler94 <257877469+geoffreybutler94@users.noreply.github.com>
This commit is contained in:
parent
bd37ff9138
commit
f0ff8d5097
8 changed files with 349 additions and 13 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue