Session isolation fixes for routing and drafts
NousResearch/hermes-agent · cluster 54527 · apps/desktop
Data as of
Nothing open on GitHub. Fix merged in #62413. Check it covers your case.
The short answer
- Known?
- Yes. 6 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #62413. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #62413. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple fixes addressing message routing and composer draft state bleeding across sessions in the desktop client. Issues involve input being misdirected or lost when switching between sessions or sidebar contexts.
Open issues0
Open PRs0
Fixes on record
-
#62413 fix(desktop): keep draft and submit routing session-bound
files:
apps/desktop/src/app/chat/composer/composer-utils.test.ts,apps/desktop/src/app/chat/composer/composer-utils.ts,apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts,apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts,apps/desktop/src/app/desktop-controller.tsx,apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx,apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts,apps/desktop/src/app/session/hooks/use-prompt-actions/submit.tsShow the diff
-
apps/desktop/src/app/chat/composer/composer-utils.test.ts@@ -1,7 +1,14 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' import { describe, expect, it } from 'vitest' -import { pickPlaceholder, slashArgStage, slashChipKindForItem, slashCommandToken } from './composer-utils' +import { + isPendingDraftPersistCurrent, + type PendingDraftPersist, + pickPlaceholder, + slashArgStage, + slashChipKindForItem, + slashCommandToken +} from './composer-utils' const item = (group: string): Unstable_TriggerItem => ({ id: 'x', type: 'slash', label: 'x', metadata: { group } }) as unknown as Unstable_TriggerItem @@ -38,3 +45,36 @@ describe('pickPlaceholder', () => { expect(pool).toContain(pickPlaceholder(pool)) }) }) + +describe('isPendingDraftPersistCurrent (#54527 integrity guard)', () => { + it('accepts a write when the pending entry still matches what was captured', () => { + const entry: PendingDraftPersist = { scope: 'session-a', text: 'hello' } + + expect(isPendingDraftPersistCurrent(entry, entry)).toBe(true) + expect(isPendingDraftPersistCurrent({ scope: 'session-a', text: 'hello' }, entry)).toBe(true) + }) + + it('rejects when the pending slot was cleared (session swap / newer flush already committed)', () => { + const entry: PendingDraftPersist = { scope: 'session-a', text: 'hello' } + + expect(isPendingDraftPersistCurrent(null, entry)).toBe(false) + }) + + it('rejects when the pending slot now belongs to a different session (the #54527 misroute shape)', () => { + const captured: PendingDraftPersist = { scope: 'session-a', text: 'carefully composed prompt' } + const supersededBy: PendingDraftPersist = { scope: 'session-b', text: 'different draft' } + + expect(isPendingDraftPersistCurrent(supersededBy, captured)).toBe(false) + }) + + it('rejects when the pending slot was replaced by a newer keystroke in the same session', () => { + const captured: PendingDraftPersist = { scope: 'session-a', text: 'first draft' } + const supersededBy: PendingDraftPersist = { scope: 'session-a', text: 'first draft continued' } + + expect(isPendingDraftPersistCurrent(supersededBy, captured)).toBe(false) + }) + + it('rejects when nothing was ever captured', () => { + expect(isPendingDraftPersistCurrent(null, null)).toBe(false) + }) +}) -
apps/desktop/src/app/chat/composer/composer-utils.ts@@ -58,3 +58,28 @@ export interface QueueEditState { } export const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) + +export interface PendingDraftPersist { + scope: string | null + text: string +} + +/** + * Defense-in-depth for #54527: the debounce timer and the `pagehide` flush + * both write a captured `{ scope, text }` pair some time after it was + * scheduled. Before either commits the write, this checks the pair is still + * the one currently on file — i.e. nothing cleared or replaced it in the + * meantime (a session swap, a newer keystroke). The scope-capture fix + * upstream (`draftScopeRef`) already makes every captured pair correct by + * construction; this guard exists so that if a future change reintroduces a + * stale/live-ref read at one of these call sites, the write is dropped + * instead of silently filing one session's text under another session's key. + */ +export function isPendingDraftPersistCurrent( + pending: PendingDraftPersist | null, + expected: PendingDraftPersist | null +): boolean { + return ( + pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text + ) +} -
apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts@@ -5,7 +5,12 @@ import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer' import { isBrowsingHistory } from '@/store/composer-input-history' -import { cloneAttachments, DRAFT_PERSIST_DEBOUNCE_MS, type QueueEditState } from '../composer-utils' +import { + cloneAttachments, + DRAFT_PERSIST_DEBOUNCE_MS, + isPendingDraftPersistCurrent, + type QueueEditState +} from '../composer-utils' import { type ComposerInsertMode, focusComposerInput, @@ -77,6 +82,13 @@ export function useComposerDraft({ const draftPersistTimerRef = useRef<number | undefined>(undefined) const activeQueueSessionKeyRef = useRef(activeQueueSessionKey) activeQueueSessionKeyRef.current = activeQueueSessionKey + // Owned only by the swap effect below — unlike activeQueueSessionKeyRef this + // does NOT update on every render, so it always reflects the session whose + // text is actually loaded in the editor. Async work (debounce timers, + // pagehide flush) must persist against this, not the render-time ref, or a + // session switch mid-flight files one session's draft under another's key + // (#54527). + const draftScopeRef = useRef(activeQueueSessionKey) const sessionIdRef = useRef(sessionId) sessionIdRef.current = sessionId const queueEditStateRef = useRef<QueueEditState | null>(queueEditRef.current) @@ -222,10 +234,20 @@ export function useComposerDraft({ return } - const scope = activeQueueSessionKeyRef.current - pendingDraftPersistRef.current = { scope, text } + const scope = draftScopeRef.current + const entry = { scope, text } + pendingDraftPersistRef.current = entry window.clearTimeout(draftPersistTimerRef.current) draftPersistTimerRef.current = window.setTimeout(() => { + // Integrity guard (defense-in-depth, #54527): only commit if this is + // still the pending write on file. A session swap or a newer + // keystroke clears/replaces it before firing in the normal case; this + // catches any future call site that skips that bookkeeping instead of + // silently filing text under the wrong session. + if (!isPendingDraftPersistCurrent(pendingDraftPersistRef.current, entry)) { + return + } + pendingDraftPersistRef.current = null stashAt(scope, text) }, DRAFT_PERSIST_DEBOUNCE_MS) @@ -285,6 +307,14 @@ export function useComposerDraft({ // never clears composer state; this effect alone stashes on leave, restores // on enter. Keyed writes are idempotent, so no skip-sentinel. useEffect(() => { + // A pending debounce timer from the outgoing session is now stale — its + // scope was correct when scheduled, but the authoritative stash below + // (and the cleanup on the way out) already covers that text. Letting it + // fire later would just clobber with an older snapshot. + window.clearTimeout(draftPersistTimerRef.current) + pendingDraftPersistRef.current = null + draftScopeRef.current = activeQueueSessionKey + const { attachments, text } = takeSessionDraft(activeQueueSessionKey) loadIntoComposer(text, attachments) @@ -304,7 +334,7 @@ export function useComposerDraft({ // inside the debounce/rAF window would drop trailing keystrokes without this. useEffect(() => { const flushPendingDraftPersist = () => { - const scope = activeQueueSessionKeyRef.current + const scope = draftScopeRef.current const editing = queueEditStateRef.current if (editing?.sessionKey === scope || isBrowsingHistory(sessionIdRef.current)) { -
apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts@@ -79,7 +79,11 @@ export function useComposerSubmit({ const restore = () => { loadIntoComposer(text, submittedAttachments) - stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments) + // Use the scope captured at dispatch, not whatever session is focused + // now — the gateway can reject well after the user has switched away, + // and re-stashing into the currently-focused session would overwrite + // its draft with the rejected text from a different session (#54527). + stashAt(submittedScope, text, submittedAttachments) } void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) -
apps/desktop/src/app/desktop-controller.tsx@@ -812,6 +812,7 @@ export function DesktopController() { branchCurrentSession: branchInNewChat, busyRef, createBackendSessionForSend, + getRouteToken, handleSkinCommand, openMemoryGraph: openStarmap, refreshSessions, -
apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx@@ -51,7 +51,9 @@ interface HarnessHandle { } function Harness({ + activeSessionIdRef: activeSessionIdRefProp, busyRef, + getRouteToken, onReady, onSeedState, openMemoryGraph, @@ -59,11 +61,14 @@ function Harness({ requestGateway, resumeStoredSession, seedMessages, + selectedStoredSessionIdRef: selectedStoredSessionIdRefProp, storedSessionId, activeSessionId, createBackendSessionForSend }: { + activeSessionIdRef?: MutableRefObject<string | null> busyRef?: MutableRefObject<boolean> + getRouteToken?: () => string onReady: (handle: HarnessHandle) => void onSeedState?: (state: Record<string, unknown>) => void openMemoryGraph?: () => void @@ -71,15 +76,16 @@ function Harness({ requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> resumeStoredSession?: (storedSessionId: string) => Promise<void> | void seedMessages?: unknown[] + selectedStoredSessionIdRef?: MutableRefObject<string | null> storedSessionId?: null | string activeSessionId?: null | string createBackendSessionForSend?: () => Promise<null | string> }) { - const activeSessionIdRef: MutableRefObject<string | null> = { + const activeSessionIdRef: MutableRefObject<string | null> = activeSessionIdRefProp ?? { current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId } - const selectedStoredSessionIdRef: MutableRefObject<string | null> = { + const selectedStoredSessionIdRef: MutableRefObject<string | null> = selectedStoredSessionIdRefProp ?? { current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId } @@ -98,6 +104,7 @@ function Harness({ branchCurrentSession: async () => true, busyRef: localBusyRef, createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID), + getRouteToken: getRouteToken ?? (() => 'token'), handleSkinCommand: () => '', openMemoryGraph: openMemoryGraph ?? (() => undefined), refreshSessions, @@ -1239,7 +1246,7 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(ok).toBe(true) expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) - expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message during starved loop' @@ -1316,6 +1323,125 @@ describe('usePromptActions sleep/wake session recovery', () => { }) }) +describe('usePromptActions submit session-context isolation (#54527)', () => { + const STORED_SESSION_A = 'stored-project-a' + const STORED_SESSION_B = 'stored-project-b' + const RUNTIME_SESSION_B = 'rt-session-b-wrong' + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('aborts submit when the user switches sessions during session.resume (no misroute)', async () => { + // Exact #54527 failure: user submits in Session A while its runtime binding + // is gone; before resume returns they switch to Session B. Without a pinned + // context the resumed runtime id belongs to B and A's text lands in the + // wrong chat — permanently lost from A. + let releaseResume: () => void = () => {} + const calls: { method: string; params?: Record<string, unknown> }[] = [] + + const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A } + const activeSessionIdRef: MutableRefObject<string | null> = { current: null } + + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { + calls.push({ method, params }) + + if (method === 'session.resume') { + await new Promise<void>(resolve => { + releaseResume = resolve + }) + + // Simulate the user switching to Session B while resume is in flight. + selectedStoredSessionIdRef.current = STORED_SESSION_B + activeSessionIdRef.current = RUNTIME_SESSION_B + + return { session_id: RUNTIME_SESSION_B } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + <Harness + activeSessionId={null} + activeSessionIdRef={activeSessionIdRef} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={STORED_SESSION_A} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + const submitting = handle!.submitText('carefully composed prompt for project A') + await waitFor(() => expect(calls.some(c => c.method === 'session.resume')).toBe(true)) + releaseResume() + + expect(await submitting).toBe(false) + expect(calls.some(c => c.method === 'prompt.submit')).toBe(false) + expect(calls.find(c => c.method === 'session.resume')?.params).toEqual({ + session_id: STORED_SESSION_A + }) + }) + + it('aborts recovery submit when the user switches sessions during timeout resume', async () => { + const calls: { method: string; params?: Record<string, unknown> }[] = [] + let submitAttempts = 0 + let releaseResume: () => void = () => {} + + const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A } + + const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => { + calls.push({ method, params }) + + if (method === 'prompt.submit') { + submitAttempts += 1 + + if (submitAttempts === 1) { + throw new Error('request timed out: prompt.submit') + } + } + + if (method === 'session.resume') { + await new Promise<void>(resolve => { + releaseResume = resolve + }) + selectedStoredSessionIdRef.current = STORED_SESSION_B + + return { session_id: RUNTIME_SESSION_B } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + <Harness + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={STORED_SESSION_A} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + const submitting = handle!.submitText('message that must not land in session B') + await waitFor(() => expect(calls.some(c => c.method === 'session.resume')).toBe(true)) + releaseResume() + + expect(await submitting).toBe(false) + expect(submitAttempts).toBe(1) + expect(calls.filter(c => c.method === 'prompt.submit')).toHaveLength(1) + expect(calls.find(c => c.method === 'session.resume')?.params).toMatchObject({ + session_id: STORED_SESSION_A + }) + }) +}) + describe('usePromptActions eager attachment upload (drop-time)', () => { afterEach(() => { cleanup() -
apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts@@ -158,6 +158,7 @@ interface PromptActionsOptions { busyRef: MutableRefObject<boolean> branchCurrentSession: () => Promise<boolean> createBackendSessionForSend: (preview?: string | null) => Promise<string | null> + getRouteToken: () => string handleSkinCommand: (arg: string) => string openMemoryGraph: () => void refreshSessions: () => Promise<void> @@ -186,6 +187,7 @@ export function usePromptActions({ busyRef, branchCurrentSession, createBackendSessionForSend, + getRouteToken, handleSkinCommand, openMemoryGraph, refreshSessions, @@ -354,6 +356,7 @@ export function usePromptActions({ busyRef, copy, createBackendSessionForSend, + getRouteToken, requestGateway, selectedStoredSessionIdRef, syncAttachmentsForSubmit, -
apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts@@ -35,6 +35,7 @@ interface SubmitPromptDeps { busyRef: MutableRefObject<boolean> copy: Translations['desktop'] createBackendSessionForSend: (preview?: string | null) => Promise<string | null> + getRouteToken: () => string requestGateway: GatewayRequest selectedStoredSessionIdRef: MutableRefObject<string | null> syncAttachmentsForSubmit: ( @@ -57,6 +58,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { busyRef, copy, createBackendSessionForSend, + getRouteToken, requestGateway, selectedStoredSessionIdRef, syncAttachmentsForSubmit, @@ -113,9 +115,20 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { return false } + // Pin the session context for the whole async submit pipeline. Without + // this, a fast session switch during session.resume / file.attach can + // redirect the user's text into a different chat (#54527). + const startingActiveSessionId = activeSessionIdRef.current + const startingStoredSessionId = selectedStoredSessionIdRef.current + const startingRouteToken = [redacted:secret-assignment] + + const sessionContextDrifted = (): boolean => + selectedStoredSessionIdRef.current !== startingStoredSessionId || + getRouteToken() !== startingRouteToken + // One submit in flight per session — drop any concurrent re-fire so a // stalled turn can't stack the same prompt into multiple real turns. - const submitLockKey = selectedStoredSessionIdRef.current || activeSessionId || '__pending_new__' + const submitLockKey = startingStoredSessionId || startingActiveSessionId || '__pending_new__' if (_submitInFlight.has(submitLockKey)) { return false @@ -166,7 +179,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // (what made drained-after-interrupt sends go silent). interrupted: false }), - selectedStoredSessionIdRef.current + startingStoredSessionId ) // After sync rewrites refs, refresh the optimistic message in place so the @@ -178,7 +191,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { ...state, messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message)) }), - selectedStoredSessionIdRef.current + startingStoredSessionId ) const dropOptimistic = (sid: null | string) => { @@ -197,10 +210,17 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { awaitingResponse: false, pendingBranchGroup: null }), - selectedStoredSessionIdRef.current + startingStoredSessionId ) } + const abortForSessionSwitch = (optimisticSessionId: null | string): false => { + dropOptimistic(optimisticSessionId) + releaseBusy() + + return false + } + setMutableRef(busyRef, true) setBusy(true) setAwaitingResponse(true) @@ -214,7 +234,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { setMessages(current => [...current, buildUserMessage()]) } - if (!sessionId && selectedStoredSessionIdRef.current) { + 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 // activeSessionId). Continuing the selected conversation must mean @@ -224,9 +244,13 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // new-chat draft). try { const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: selectedStoredSessionIdRef.current + session_id: startingStoredSessionId }) + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + if (resumed?.session_id) { sessionId = resumed.session_id activeSessionIdRef.current = sessionId @@ -237,6 +261,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // the user's message. } + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + if (sessionId) { seedOptimistic(sessionId) } @@ -253,6 +281,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { return false } + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + if (!sessionId) { dropOptimistic(null) releaseBusy() @@ -269,6 +301,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { updateComposerAttachments: usingComposerAttachments }) + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + // Rewrite the optimistic message + prompt text with the synced refs so // the gateway receives @file: paths that resolve in its workspace. // (Images keep their inline base64 preview — see optimisticAttachmentRef.) @@ -288,7 +324,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } catch (firstErr) { if ( (isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && - selectedStoredSessionIdRef.current + startingStoredSessionId ) { // Re-register the session in the gateway and get a fresh live ID. // Timeouts recover the same way as "session not found": a starved @@ -296,10 +332,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // the stored session is fine — resume + retry instead of erroring // out and losing the session binding. const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: selectedStoredSessionIdRef.current, + session_id: startingStoredSessionId, source: 'desktop' }) + if (sessionContextDrifted()) { + return abortForSessionSwitch(sessionId) + } + const recoveredId = resumed?.session_id if (recoveredId) { @@ -375,6 +415,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { busyRef, copy, createBackendSessionForSend, + getRouteToken, requestGateway, selectedStoredSessionIdRef, syncAttachmentsForSubmit,
-
3 PRs closed without merging
From the close comments: merged via another change 2 · superseded by another PR 1
-
#62130 fix(desktop): isolate composer drafts across session switches (#54527) · superseded by another PR →
#62134 -
#62134 fix(desktop): isolate session routing across sidebar switches (#54527) · merged via another change →
#62413 -
#62144 fix(desktop): pin composer draft scope to the swap-effect owner, not the render ref · merged via another change →
#62413,#62134
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- apps/desktop
- Merged PRs
- #62413
- Closed issues
- 2
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 11 Jul 2026