Session isolation and cross-contamination bugs in desktop
NousResearch/hermes-agent · cluster 39086 · agent
Data as of
5 open items. Fix merged in #63948. Check it covers your case.
The short answer
- Known?
- Yes. 44 related items grouped here, 24 issues already closed.
- Fix event
- A fix was merged to main in PR #63948. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #63948, #122781, #122953. That is separate from the fix event. - Still open
- 2 issues and 3 PRs.
Next step: read #63948, then check whether your report is one of the 2 still open.
Read #63948Pick up a Lab taskA cluster of bugs and fixes centered on desktop session isolation: messages, events, history, and UI state leaking or routing incorrectly across sessions when switching or creating new chats. Accompanying fixes attempt to scope streams, sync view state, and enforce correct session targeting.
Open issues2
Open PRs3
Fixes on record
-
#63948 fix(desktop): pin unscoped streams + clear view sync on switch (supersedes #48281)
files:
apps/desktop/src/app/desktop-controller.tsx,apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts,apps/desktop/src/app/session/hooks/use-session-actions.test.tsx,apps/desktop/src/app/session/hooks/use-session-actions/index.ts,apps/desktop/src/app/session/hooks/use-session-state-cache.ts,apps/desktop/src/lib/gateway-events.test.ts,apps/desktop/src/lib/gateway-events.ts -
#66001 fix(desktop): drain queued prompts to their originating session (supersedes #57849)
files:
apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts,apps/desktop/src/app/chat/composer/types.ts,apps/desktop/src/app/chat/index.tsx,apps/desktop/src/app/contrib/wiring.tsx,apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx,apps/desktop/src/app/session/hooks/use-background-queue-drain.ts,apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx,apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts,apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts,apps/desktop/src/app/session/hooks/use-prompt-actions/utils.tsShow the diff
-
apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts@@ -8,6 +8,7 @@ import { resetBrowseState } from '@/store/composer-input-history' import { $queuedPromptsBySession, enqueueQueuedPrompt, + getQueuedPrompts, MAX_AUTO_DRAIN_ATTEMPTS, migrateQueuedPrompts, promoteQueuedPrompt, @@ -189,7 +190,9 @@ export function useComposerQueue({ return false } - const entry = pickEntry(queuedPrompts) + const drainQueueSessionKey = activeQueueSessionKey + const drainRuntimeSessionId = sessionId ?? null + const entry = pickEntry(getQueuedPrompts(drainQueueSessionKey)) if (!entry) { return false @@ -199,7 +202,12 @@ export function useComposerQueue({ try { const accepted = await Promise.resolve( - onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true }) + onSubmit(entry.text, { + attachments: entry.attachments, + fromQueue: true, + sessionId: drainRuntimeSessionId, + storedSessionId: drainQueueSessionKey + }) ) if (accepted === false) { @@ -207,15 +215,15 @@ export function useComposerQueue({ } drainFailuresRef.current.delete(entry.id) - removeQueuedPrompt(activeQueueSessionKey, entry.id) - resetBrowseState(sessionId) + removeQueuedPrompt(drainQueueSessionKey, entry.id) + resetBrowseState(drainRuntimeSessionId) return true } finally { drainingQueueRef.current = false } }, - [activeQueueSessionKey, onSubmit, queuedPrompts, sessionId] + [activeQueueSessionKey, onSubmit, sessionId] ) const pickDrainHead = useCallback( -
apps/desktop/src/app/chat/composer/types.ts@@ -1,7 +1,7 @@ import type { ReactNode } from 'react' +import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils' import type { HermesGateway } from '@/hermes' -import type { ComposerAttachment } from '@/store/composer' import type { DroppedFile } from '../hooks/use-composer-actions' @@ -52,10 +52,7 @@ export interface ChatBarProps { onPickImages?: () => void onRemoveAttachment?: (id: string) => void onSteer?: (text: string) => Promise<boolean> | boolean - onSubmit: ( - value: string, - options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean } - ) => Promise<boolean> | boolean + onSubmit: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean onTranscribeAudio?: (audio: Blob) => Promise<string> } -
apps/desktop/src/app/chat/index.tsx@@ -5,6 +5,7 @@ import type * as React from 'react' import { Suspense, useCallback, useMemo } from 'react' import { useLocation } from 'react-router-dom' +import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils' import { Thread } from '@/components/assistant-ui/thread' import { Backdrop } from '@/components/Backdrop' import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts' @@ -19,7 +20,6 @@ import type { ChatMessage } from '@/lib/chat-messages' import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { cn } from '@/lib/utils' -import type { ComposerAttachment } from '@/store/composer' import { $pinnedSessionIds } from '@/store/layout' import { $petActive } from '@/store/pet' import { $petOverlayActive } from '@/store/pet-overlay' @@ -74,10 +74,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> { onPickImages: () => void onRemoveAttachment: (id: string) => void onSteer: (text: string) => Promise<boolean> | boolean - onSubmit: ( - text: string, - options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean } - ) => Promise<boolean> | boolean + onSubmit: (text: string, options?: SubmitTextOptions) => Promise<boolean> | boolean onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void onEdit: (message: AppendMessage) => Promise<void> onReload: (parentId: string | null) => Promise<void> -
apps/desktop/src/app/contrib/wiring.tsx@@ -75,6 +75,7 @@ import { PersistentTerminal } from '../right-sidebar/terminal/persistent' import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes' import { SessionPickerOverlay } from '../session-picker-overlay' import { SessionSwitcher } from '../session-switcher' +import { useBackgroundQueueDrain } from '../session/hooks/use-background-queue-drain' import { useContextSuggestions } from '../session/hooks/use-context-suggestions' import { useCwdActions } from '../session/hooks/use-cwd-actions' import { useHermesConfig } from '../session/hooks/use-hermes-config' @@ -536,6 +537,15 @@ export function ContribWiring({ children }: { children: ReactNode }) { updateSessionState }) + // Runs outside the selected ChatBar so queues belonging to background + // sessions continue once those sessions are idle. + useBackgroundQueueDrain({ + enabled: gatewayState === 'open', + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + submitText + }) + // Session-tile delegate (resume/submit/interrupt/slash + the session verbs // the tile TAB menu needs, without touching the primary view). useSessionTileDelegate({ -
apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx@@ -0,0 +1,139 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $queuedPromptsBySession, enqueueQueuedPrompt, getQueuedPrompts } from '@/store/composer-queue' +import { $workingSessionIds } from '@/store/session' + +import { useBackgroundQueueDrain } from './use-background-queue-drain' +import type { SubmitTextOptions } from './use-prompt-actions/utils' + +function Harness({ + enabled = true, + runtimeMap, + selectedStoredSessionId = 'stored-session-b', + submitText +}: { + enabled?: boolean + runtimeMap: MutableRefObject<Map<string, string>> + selectedStoredSessionId?: string | null + submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean> | boolean +}) { + useBackgroundQueueDrain({ + enabled, + runtimeIdByStoredSessionIdRef: runtimeMap, + selectedStoredSessionId, + submitText + }) + + return null +} + +describe('useBackgroundQueueDrain', () => { + beforeEach(() => { + vi.useRealTimers() + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.useRealTimers() + $queuedPromptsBySession.set({}) + $workingSessionIds.set([]) + }) + + it('drains an idle queued prompt for a non-selected background session', async () => { + const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) } + const submitText = vi.fn(async () => true) + + enqueueQueuedPrompt('stored-session-a', { text: 'continue in the background', attachments: [] }) + $workingSessionIds.set([]) + + render(<Harness runtimeMap={runtimeMap} submitText={submitText} />) + + await waitFor(() => { + expect(submitText).toHaveBeenCalledWith('continue in the background', { + attachments: [], + fromQueue: true, + sessionId: 'rt-session-a', + storedSessionId: 'stored-session-a' + }) + }) + + await waitFor(() => expect(getQueuedPrompts('stored-session-a')).toHaveLength(0)) + }) + + it('leaves the selected session queue to the mounted ChatBar drainer', async () => { + const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) } + const submitText = vi.fn(async () => true) + + enqueueQueuedPrompt('stored-session-a', { text: 'visible queue entry', attachments: [] }) + $workingSessionIds.set([]) + + render(<Harness runtimeMap={runtimeMap} selectedStoredSessionId="stored-session-a" submitText={submitText} />) + + await new Promise(resolve => window.setTimeout(resolve, 0)) + + expect(submitText).not.toHaveBeenCalled() + expect(getQueuedPrompts('stored-session-a')).toHaveLength(1) + }) + + it('does not drain a background session that is still marked working', async () => { + const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) } + const submitText = vi.fn(async () => true) + + enqueueQueuedPrompt('stored-session-a', { text: 'wait for current turn', attachments: [] }) + $workingSessionIds.set(['stored-session-a']) + + render(<Harness runtimeMap={runtimeMap} submitText={submitText} />) + + await new Promise(resolve => window.setTimeout(resolve, 0)) + + expect(submitText).not.toHaveBeenCalled() + expect(getQueuedPrompts('stored-session-a')).toHaveLength(1) + }) + + it('passes a null runtime id so submitText can resume stale background sessions by stored id', async () => { + const runtimeMap = { current: new Map<string, string>() } + const submitText = vi.fn(async () => true) + + enqueueQueuedPrompt('stored-session-a', { text: 'resume then send', attachments: [] }) + + render(<Harness runtimeMap={runtimeMap} submitText={submitText} />) + + await waitFor(() => { + expect(submitText).toHaveBeenCalledWith('resume then send', { + attachments: [], + fromQueue: true, + sessionId: null, + storedSessionId: 'stored-session-a' + }) + }) + }) + + it('retries a rejected background drain without waiting for another queue or busy-state change', async () => { + vi.useFakeTimers() + + const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) } + const submitText = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + + enqueueQueuedPrompt('stored-session-a', { text: 'retry me', attachments: [] }) + + render(<Harness runtimeMap={runtimeMap} submitText={submitText} />) + + await act(async () => { + await Promise.resolve() + }) + + expect(submitText).toHaveBeenCalledTimes(1) + expect(getQueuedPrompts('stored-session-a')).toHaveLength(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(750) + await Promise.resolve() + }) + + expect(submitText).toHaveBeenCalledTimes(2) + expect(getQueuedPrompts('stored-session-a')).toHaveLength(0) + }) +}) -
apps/desktop/src/app/session/hooks/use-background-queue-drain.ts@@ -0,0 +1,174 @@ +import { useStore } from '@nanostores/react' +import { type MutableRefObject, useCallback, useEffect, useRef, useState } from 'react' + +import { useI18n } from '@/i18n' +import { resetBrowseState } from '@/store/composer-input-history' +import { + $queuedPromptsBySession, + getQueuedPrompts, + MAX_AUTO_DRAIN_ATTEMPTS, + type QueuedPromptEntry, + removeQueuedPrompt, + shouldAutoDrain +} from '@/store/composer-queue' +import { notify } from '@/store/notifications' +import { $workingSessionIds } from '@/store/session' + +import type { SubmitTextOptions } from './use-prompt-actions/utils' + +type SubmitQueuedPrompt = (text: string, options?: SubmitTextOptions) => Promise<boolean> | boolean + +interface BackgroundQueueDrainOptions { + enabled: boolean + runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> + selectedStoredSessionId: string | null + submitText: SubmitQueuedPrompt +} + +const BACKGROUND_DRAIN_RETRY_MS = 750 + +/** + * Drain queued prompts for sessions that are not currently rendered by ChatBar. + * + * The visible ChatBar owns the interactive queue panel for the selected session. + * Without this background drain, a prompt queued in Session A can sit forever + * after the user switches to Session B: the only auto-drain effect lives inside + * the mounted ChatBar, so Session A's queue is not observed when A is offscreen. + */ +export function useBackgroundQueueDrain({ + enabled, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + submitText +}: BackgroundQueueDrainOptions) { + const { t } = useI18n() + const queuedPromptsBySession = useStore($queuedPromptsBySession) + const workingSessionIds = useStore($workingSessionIds) + const submitTextRef = useRef(submitText) + const drainingSessionIdsRef = useRef(new Set<string>()) + const drainFailuresRef = useRef(new Map<string, number>()) + const retryTimersRef = useRef<number[]>([]) + const [retryTick, setRetryTick] = useState(0) + + useEffect(() => { + submitTextRef.current = submitText + }, [submitText]) + + const scheduleRetry = useCallback(() => { + if (typeof window === 'undefined') { + return + } + + const timer = window.setTimeout(() => { + retryTimersRef.current = retryTimersRef.current.filter(id => id !== timer) + setRetryTick(tick => tick + 1) + }, BACKGROUND_DRAIN_RETRY_MS) + + retryTimersRef.current.push(timer) + }, []) + + useEffect( + () => () => { + for (const timer of retryTimersRef.current) { + window.clearTimeout(timer) + } + + retryTimersRef.current = [] + }, + [] + ) + + const drainSessionQueue = useCallback( + (sessionKey: string, entry: QueuedPromptEntry) => { + if (drainingSessionIdsRef.current.has(sessionKey)) { + return + } + + drainingSessionIdsRef.current.add(sessionKey) + + const onFail = () => { + const failures = (drainFailuresRef.current.get(entry.id) ?? 0) + 1 + drainFailuresRef.current.set(entry.id, failures) + + if (failures >= MAX_AUTO_DRAIN_ATTEMPTS) { + notify({ + id: `composer-background-queue-stuck-${sessionKey}`, + kind: 'error', + title: t.composer.queueStuckTitle, + message: t.composer.queueStuckBody + }) + + return + } + + scheduleRetry() + } + + void Promise.resolve() + .then(async () => { + const liveEntry = getQueuedPrompts(sessionKey).find(candidate => candidate.id === entry.id) + + if (!liveEntry) { + return true + } + + const runtimeSessionId = runtimeIdByStoredSessionIdRef.current.get(sessionKey) ?? null + + const accepted = await Promise.resolve( + submitTextRef.current(liveEntry.text, { + attachments: liveEntry.attachments, + fromQueue: true, + sessionId: runtimeSessionId, + storedSessionId: sessionKey + }) + ) + + if (accepted === false) { + return false + } + + drainFailuresRef.current.delete(liveEntry.id) + removeQueuedPrompt(sessionKey, liveEntry.id) + resetBrowseState(runtimeSessionId) + + return true + }) + .then(accepted => { + if (!accepted) { + onFail() + } + }) + .catch(onFail) + .finally(() => { + drainingSessionIdsRef.current.delete(sessionKey) + }) + }, + [runtimeIdByStoredSessionIdRef, scheduleRetry, t] + ) + + useEffect(() => { + if (!enabled) { + return + } + + const working = new Set(workingSessionIds) + + for (const [sessionKey, entries] of Object.entries(queuedPromptsBySession)) { + if ( + sessionKey === selectedStoredSessionId || + drainingSessionIdsRef.current.has(sessionKey) || + !shouldAutoDrain({ isBusy: working.has(sessionKey), queueLength: entries.length }) + ) { + continue + } + + const entry = entries[0] + + if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) { + continue + } + + drainSessionQueue(sessionKey, entry) + } + }, [drainSessionQueue, enabled, queuedPromptsBySession, retryTick, selectedStoredSessionId, workingSessionIds]) +} -
apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx@@ -8,6 +8,8 @@ import { $composerAttachments, $composerDraft, type ComposerAttachment, setCompo import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session' import type { SessionInfo } from '@/types/hermes' +import type { SubmitTextOptions } from './utils' + import { uploadComposerAttachment, usePromptActions } from '.' vi.mock('@/hermes', () => ({ @@ -56,10 +58,11 @@ async function actRender(ui: React.ReactElement) { } interface HarnessHandle { + activeSessionIdRef: MutableRefObject<string | null> cancelRun: () => Promise<void> restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void> steerPrompt: (text: string) => Promise<boolean> - submitText: (text: string, options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }) => Promise<boolean> + submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean> } function Harness({ @@ -68,6 +71,7 @@ function Harness({ getRoutedStoredSessionId, getRuntimeIdForStoredSession, getRouteToken, + onUpdateState, onReady, onSeedState, openMemoryGraph, @@ -85,6 +89,11 @@ function Harness({ getRoutedStoredSessionId?: () => null | string getRuntimeIdForStoredSession?: (storedSessionId: string) => null | string getRouteToken?: () => string + onUpdateState?: ( + sessionId: string, + storedSessionId: null | string | undefined, + state: Record<string, unknown> + ) => void onReady: (handle: HarnessHandle) => void onSeedState?: (state: Record<string, unknown>) => void openMemoryGraph?: () => void @@ -97,9 +106,8 @@ function Harness({ activeSessionId?: null | string createBackendSessionForSend?: () => Promise<null | string> }) { - const activeSessionIdRef: MutableRefObject<string | null> = activeSessionIdRefProp ?? { - current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId - } + const localActiveSessionIdRef = useRef<string | null>(activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId) + const activeSessionIdRef = activeSessionIdRefProp ?? localActiveSessionIdRef const selectedStoredSessionIdRef: MutableRefObject<string | null> = selectedStoredSessionIdRefProp ?? { current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId @@ -131,11 +139,12 @@ function Harness({ selectedStoredSessionIdRef, startFreshSessionDraft: () => undefined, sttEnabled: false, - updateSessionState: (_sessionId, updater) => { + updateSessionState: (sessionId, updater, storedSessionId) => { // Seed with interrupted:true so we can prove a fresh submit clears it. const next = updater(stateRef.current) as unknown as Record<string, unknown> stateRef.current = next as never onSeedState?.(next) + onUpdateState?.(sessionId, storedSessionId, next) return next as never } @@ -143,6 +152,7 @@ function Harness({ useEffect(() => { onReady({ + activeSessionIdRef, cancelRun: (...args: Parameters<typeof actions.cancelRun>) => act(async () => actions.cancelRun(...args)) as Promise<void>, restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) => @@ -152,7 +162,7 @@ function Harness({ submitText: (...args: Parameters<typeof actions.submitText>) => act(async () => actions.submitText(...args)) as Promise<boolean> }) - }, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady]) + }, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, activeSessionIdRef, onReady]) return null } @@ -540,6 +550,45 @@ describe('usePromptActions submit / queue drain semantics', () => { ) }) + it('a fromQueue drain sends to its queued session even after the active session changes', async () => { + $busy.set(false) + + const updates: { sessionId: string; state: Record<string, unknown>; storedSessionId: null | string | undefined }[] = [] + const requestGateway = vi.fn(async () => ({}) as never) + + let handle: HarnessHandle | null = null + render( + <Harness + onReady={h => (handle = h)} + onUpdateState={(sessionId, storedSessionId, state) => updates.push({ sessionId, state, storedSessionId })} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + const accepted = await handle!.submitText('queued for background session', { + fromQueue: true, + sessionId: 'rt-session-a', + storedSessionId: 'stored-session-a' + }) + + expect(accepted).toBe(true) + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + session_id: 'rt-session-a', + text: 'queued for background session' + }, + 1_800_000 + ) + expect(requestGateway).not.toHaveBeenCalledWith('session.resume', expect.anything()) + expect( + updates.some(update => update.sessionId === 'rt-session-a' && update.storedSessionId === 'stored-session-a') + ).toBe(true) + // Offscreen queue drains must not flip the foreground composer into Thinking. + expect($busy.get()).toBe(false) + }) + it('a rejected fromQueue drain returns false (entry stays queued) and a later retry sends it', async () => { // A stale-session 404 must not strand the queued entry: submitPrompt returns // false on failure so the composer keeps it, and the edge-independent @@ -1132,6 +1181,58 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' }) }) + it('background queue resume uses the queued stored id and leaves foreground runtime selected', async () => { + const calls: { method: string; params?: Record<string, unknown> }[] = [] + let submitAttempts = 0 + + 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('session not found') + } + + return {} as never + } + + if (method === 'session.resume') { + return { session_id: RECOVERED_SESSION_ID } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( + <Harness + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId="stored-foreground" + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + const ok = await handle!.submitText('queued background message after wake', { + fromQueue: true, + sessionId: 'rt-background-stale', + storedSessionId: STORED_SESSION_ID + }) + + expect(ok).toBe(true) + expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit']) + expect(calls[0]?.params).toEqual({ session_id: 'rt-background-stale', text: 'queued background message after wake' }) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) + expect(calls[2]?.params).toEqual({ + session_id: RECOVERED_SESSION_ID, + text: 'queued background message after wake' + }) + expect(handle!.activeSessionIdRef.current).toBe(RUNTIME_SESSION_ID) + }) + it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => { const calls: { method: string; params?: Record<string, unknown> }[] = [] let interruptAttempts = 0 @@ -1346,7 +1447,7 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(ok).toBe(true) expect(createBackendSessionForSend).not.toHaveBeenCalled() expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit']) - expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID }) + expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' }) expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID }) }) @@ -1655,7 +1756,8 @@ describe('usePromptActions submit session-context isolation (#54527)', () => { 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 + session_id: STORED_SESSION_A, + source: 'desktop' }) }) -
apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts@@ -14,7 +14,7 @@ import { } from '@/lib/desktop-slash-commands' import { setSessionYolo } from '@/lib/yolo-session' import { openCommandPalettePage } from '@/store/command-palette' -import { type ComposerAttachment, setComposerDraft } from '@/store/composer' +import { setComposerDraft } from '@/store/composer' import { notify, notifyError } from '@/store/notifications' import { setPetScale } from '@/store/pet-gallery' import { $petGenInput, openPetGenerate } from '@/store/pet-generate' @@ -31,7 +31,13 @@ import { import type { BrowserManageResponse, SessionTitleResponse, SlashExecResponse } from '../../../types' -import { type GatewayRequest, isSessionIdCandidate, renderCommandsCatalog, slashStatusText } from './utils' +import { + type GatewayRequest, + isSessionIdCandidate, + renderCommandsCatalog, + slashStatusText, + type SubmitTextOptions +} from './utils' /** Everything a slash handler needs about the invocation it's serving. */ interface SlashActionCtx { @@ -59,10 +65,7 @@ interface SlashCommandDeps { requestGateway: GatewayRequest resumeStoredSession: (storedSessionId: string) => Promise<void> | void startFreshSessionDraft: () => void - submitPromptText: ( - rawText: string, - options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean } - ) => Promise<boolean> + submitPromptText: (rawText: string, options?: SubmitTextOptions) => Promise<boolean> } /** The /slash command dispatcher, extracted from usePromptActions. */ -
apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts@@ -141,9 +141,19 @@ 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). Mutable — + // Queue drains carry their source session explicitly. A background drain + // must never inherit the currently selected session after the user moves + // to another chat. + const targetStoredSessionId = options?.storedSessionId ?? selectedStoredSessionIdRef.current + + const targetStartedInCurrentView = + !targetStoredSessionId || targetStoredSessionId === selectedStoredSessionIdRef.current + + let sessionId: null | string = options?.sessionId ?? activeSessionIdRef.current + + // Pin the foreground 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). Mutable — // 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 @@ -166,11 +176,16 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { let startingRouteToken = [redacted:secret-assignment] const sessionContextDrifted = (): boolean => - selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken + targetStartedInCurrentView && + (selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken) + + const targetIsCurrentView = (): boolean => targetStartedInCurrentView && !sessionContextDrifted() // 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 = startingStoredSessionId || startingActiveSessionId || '__pending_new__' + // stalled turn can't stack the same prompt into multiple real turns. The + // foreground ChatBar and background drainers can briefly overlap during a + // session switch; this per-session lock makes that safe. + const submitLockKey = targetStoredSessionId || sessionId || startingActiveSessionId || '__pending_new__' if (_submitInFlight.has(submitLockKey)) { return false @@ -197,9 +212,12 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { const releaseBusy = () => { releaseSubmitLock() - setMutableRef(busyRef, false) - scope.setBusy(false) - scope.setAwaitingResponse(false) + + if (targetIsCurrentView()) { + setMutableRef(busyRef, false) + scope.setBusy(false) + scope.setAwaitingResponse(false) + } } // Idempotent optimistic insert — re-running with the resolved sessionId @@ -221,7 +239,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // (what made drained-after-interrupt sends go silent). interrupted: false }), - startingStoredSessionId + targetStoredSessionId ) // After sync rewrites refs, refresh the optimistic message in place so the @@ -233,12 +251,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { ...state, messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message)) }), - startingStoredSessionId + targetStoredSessionId ) const dropOptimistic = (sid: null | string) => { if (!sid) { - scope.setMessages(current => current.filter(m => m.id !== optimisticId)) + if (targetIsCurrentView()) { + scope.setMessages(current => current.filter(m => m.id !== optimisticId)) + } return } @@ -252,7 +272,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { awaitingResponse: false, pendingBranchGroup: null }), - startingStoredSessionId + targetStoredSessionId ) } @@ -263,19 +283,26 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { return false } - setMutableRef(busyRef, true) - scope.setBusy(true) - scope.setAwaitingResponse(true) - clearNotifications() + // Foreground-only state: a background queue drain must never write the + // selected view's busy/awaiting flags or clear its notifications. + if (targetIsCurrentView()) { + setMutableRef(busyRef, true) + scope.setBusy(true) + scope.setAwaitingResponse(true) + clearNotifications() + } // 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 + // outranks a stale render-time runtime id (often from the previous + // profile): force the full routed resume path below. An explicit queued + // runtime id (background drain) is authoritative and is left untouched. + if (!options?.sessionId && routedSessionNeedsResume) { + sessionId = null + } if (sessionId) { seedOptimistic(sessionId) - } else { + } else if (targetIsCurrentView()) { scope.setMessages(current => [...current, buildUserMessage()]) } @@ -313,17 +340,15 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { 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 - // activeSessionId). Continuing the selected conversation must mean - // resuming it — minting a brand-new backend session here silently - // splits the user's chat in two (#55578 symptom b). Only fall through - // to session creation when NO stored session is selected (a genuine - // new-chat draft). + if (!sessionId && targetStoredSessionId) { + // A target stored session exists but its runtime binding is gone (the + // live session was orphan-reaped, a timeout/reconnect cleared it, or a + // background queue drain only has the durable id). Continue that target + // conversation; only a genuine new-chat draft may create a new session. try { const resumed = await requestGateway<{ session_id: string }>('session.resume', { - session_id: startingStoredSessionId + session_id: targetStoredSessionId, + source: 'desktop' }) if (sessionContextDrifted()) { @@ -332,12 +357,17 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { if (resumed?.session_id) { sessionId = resumed.session_id - activeSessionIdRef.current = sessionId + + if (targetIsCurrentView()) { + activeSessionIdRef.current = sessionId + } } } catch { - // A selected stored conversation is not a new-chat draft. If its + // A target 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. + // it with a contextless session (#55578). For a background/queued + // drain this abort is a no-op on foreground state (both helpers are + // targetIsCurrentView-guarded) and simply drops the queued send. return abortForSessionSwitch(null) } @@ -358,7 +388,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } catch (err) { dropOptimistic(null) releaseBusy() - notifyError(err, copy.sessionUnavailable) + + if (targetIsCurrentView()) { + notifyError(err, copy.sessionUnavailable) + } return false } @@ -373,7 +406,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { dropOptimistic(null) releaseBusy() - notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) + + if (targetIsCurrentView()) { + notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) + } return false } @@ -424,14 +460,19 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) ) } catch (firstErr) { - if ((isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && startingStoredSessionId) { + const recoverStoredSessionId = targetStoredSessionId ?? selectedStoredSessionIdRef.current + + if ( + (isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && + recoverStoredSessionId + ) { // Re-register the session in the gateway and get a fresh live ID. // Timeouts recover the same way as "session not found": a starved // backend loop (#55578 symptom d) rejects the submit even though // 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: startingStoredSessionId, + session_id: recoverStoredSessionId, source: 'desktop' }) @@ -442,7 +483,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { const recoveredId = resumed?.session_id if (recoveredId) { - activeSessionIdRef.current = recoveredId + if (targetIsCurrentView()) { + activeSessionIdRef.current = recoveredId + } + await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) ) @@ -479,31 +523,37 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { const message = inlineErrorMessage(err, copy.promptFailed) - updateSessionState(sessionId, state => ({ - ...state, - messages: [ - ...state.messages, - { - id: `assistant-error-${Date.now()}`, - role: 'assistant', - parts: [], - error: message || copy.promptFailed, - branchGroupId: state.pendingBranchGroup ?? undefined - } - ], - busy: false, - awaitingResponse: false, - pendingBranchGroup: null, - sawAssistantPayload: true - })) - - if (isProviderSetupError(err)) { + updateSessionState( + sessionId, + state => ({ + ...state, + messages: [ + ...state.messages, + { + id: `assistant-error-${Date.now()}`, + role: 'assistant', + parts: [], + error: message || copy.promptFailed, + branchGroupId: state.pendingBranchGroup ?? undefined + } + ], + busy: false, + awaitingResponse: false, + pendingBranchGroup: null, + sawAssistantPayload: true + }), + targetStoredSessionId + ) + + if (targetIsCurrentView() && isProviderSetupError(err)) { requestDesktopOnboarding(copy.providerCredentialRequired) return false } - notifyError(err, copy.promptFailed) + if (targetIsCurrentView()) { + notifyError(err, copy.promptFailed) + } return false } -
apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts@@ -226,4 +226,11 @@ export function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targ export interface SubmitTextOptions { attachments?: ComposerAttachment[] fromQueue?: boolean + /** Runtime session id to submit into. Queue drains pass this so a + * backgrounded/source session cannot be replaced by the current foreground + * session between enqueue and drain. */ + sessionId?: string | null + /** Stable stored session id for optimistic/cache updates and stale-runtime + * recovery. Distinct from the runtime session id minted by the gateway. */ + storedSessionId?: string | null }
-
-
#86594 fix(desktop): salvage session-race cluster — Stop→edit/resend busy race + submit ownership proof (#83855, #65328)
files:
apps/desktop/src/app/chat/session-tile-actions.ts,apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts,apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts,apps/desktop/src/app/contrib/wiring.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.ts,apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts,apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts,contributors/emails/[redacted:email] -
#122781 fix(tui): never auto-resume a source='unknown' guard placeholder
files:
tests/tui_gateway/test_tui_gateway_server.py,tui_gateway/methods_session.py -
#122953 fix(desktop): sync the composer queue across windows
files:
apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx,apps/desktop/src/store/composer-queue.test.ts,apps/desktop/src/store/composer-queue.ts
10 PRs closed without merging
From the close comments: done elsewhere 3 · superseded by another PR 2 · closed without comment 1 · duplicate 1 · merged via another change 1 · other 2
-
#40499 feat(desktop): support opening sessions in multiple windows · done elsewhere →
39d0945 - #45841 fix(desktop): flip the foreground view to the warm session synchronously on resume · other
- #46743 fix: prevent guardrail and desktop duplicate messages · other
- #48281 fix(desktop): pin unscoped stream events · superseded by another PR
-
#49659 fix(cli): prevent desktop session transcript bleed from unscoped events · done elsewhere →
28f1590,#42359
Show 5 more
-
#51729 fix(dashboard): scope ChatSidebar tool feed to the live session · done elsewhere →
28f1590,#49077,#49106 -
#52025 fix(desktop): require session_id for all session-scoped gateway events · duplicate →
#49659 -
#56444 fix(desktop): bind queued sends to their source session · superseded by another PR →
#66001,#57849,#56390,#57850,#61573 - #57516 fix(desktop): sync the composer queue across windows (#46732) · closed without comment
-
#65381 fix(desktop): prove submit target belongs to selected session from both directions (#65328) · merged via another change →
#86594
Closed issues · 24
- Closed#39086Desktop: stale user bubbles/session busy rows can still render in a new chat after #37975
- Closed#40394[Feature]: Support opening multiple independent desktop windows
- Closed#44187bug: error messages from previous session bleed into current conversation
- Closed#45738Desktop can show another session’s transcript/status above the composer after switching sessions
- Closed#46194[Bug]: Desktop session switch leaks queued follow-up state and can show wrong chat contents
- Closed#46732Desktop: failed/unsent messages leak across multiple windows even after /new session
- Closed#47340Desktop: Messages sent in Window A appear in Window B (session routing broken after WebSocket reconnect)
- Closed#47475Bug: Messages leaking between conversation sessions
- Closed#47709[Bug]: Desktop shows newer session output in an older session
- Closed#49106Web/WeChat sessions leak history: turns from one Hermes session appear in another
- Closed#54320Desktop sidebar can highlight one session while chat pane sends to another
- Closed#54929Cross-session message leak: messages from one conversation appearing in another
- Closed#56390[Bug]: Queued messages can fire into the wrong session when switching between concurrent sessions (Desktop only)
- Closed#58105[Bug]: Message routing bug - User input sent to wrong session
- Closed#61573Desktop: message queued in a busy session is delivered to an unrelated idle session
- Closed#62652Desktop: complete previous session history renders in new chat — full transcript bleed
- Closed#65005[Bug]: Session-switch data merging / cross-contamination — can't switch chats cleanly
- Closed#65039Queued message leaks to wrong session on session switch
- Closed#65328[Bug]: New desktop session routes user input to existing old session instead (regression after 7/16 update)
- Closed#65596Desktop: new session window reuses existing agent context (no session isolation)
- Closed#65597Desktop: new session window reuses existing agent context (no session isolation)
- Closed#65598[Bug]: Desktop new session window reuses existing agent context — no session isolation
- Closed#65749Desktop: user input routed to wrong session in multi-session scenario
- Closed#65750Desktop: user input occasionally routed to wrong session (multi-session)
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.04: 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 Lab