diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 783bc4e75fc8..576714c49561 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getSession } from '@/hermes' import { textPart } from '@/lib/chat-messages' +import { createClientSessionState } from '@/lib/chat-runtime' import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer' import { $queuedPromptsBySession, getQueuedPrompts } from '@/store/composer-queue' import { $notifications, clearNotifications } from '@/store/notifications' @@ -19,6 +20,7 @@ import { setMessages, setSessions } from '@/store/session' +import { dropSessionState, publishSessionState } from '@/store/session-states' import type { SessionInfo } from '@/types/hermes' import type { SubmitTextOptions } from './utils' @@ -1043,6 +1045,83 @@ describe('usePromptActions slash.exec dispatch payloads', () => { $queuedPromptsBySession.set({}) }) + it('gates the busy queue on the TARGET session, not the foreground busy flag', async () => { + // `busyRef` is the FOREGROUND view's busy flag; a slash command runs against + // the session `resolveTargetSessionId` picked, which is frequently not the + // foreground one (tile, route rebind, freshly created session). A stale + // foreground `true` — e.g. left behind by a warm resume of a *different*, + // still-running session — parked the kickoff of an idle session's command + // on the queue and told the user "session busy" about a session that was + // doing nothing. + $queuedPromptsBySession.set({}) + publishSessionState(RUNTIME_SESSION_ID, createClientSessionState(RUNTIME_SESSION_ID)) + + const calls: string[] = [] + const busyRef = { current: true } + + const requestGateway = vi.fn(async (method: string) => { + calls.push(method) + + return (method === 'slash.exec' ? { type: 'send', message: 'audit the session states' } : {}) as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + await handle!.submitText('/audit-only audit the session states') + + // The target session is idle, so the command sends now — nothing queues. + expect(calls).toContain('prompt.submit') + expect(getQueuedPrompts(RUNTIME_SESSION_ID)).toEqual([]) + + dropSessionState(RUNTIME_SESSION_ID) + $queuedPromptsBySession.set({}) + }) + + it('still queues when the TARGET session is busy and the foreground flag is not', async () => { + // The converse leak: a background/tile command must not submit mid-turn + // just because the foreground view happens to be idle. + $queuedPromptsBySession.set({}) + publishSessionState(RUNTIME_SESSION_ID, { + ...createClientSessionState(RUNTIME_SESSION_ID), + busy: true + }) + + const calls: string[] = [] + const busyRef = { current: false } + + const requestGateway = vi.fn(async (method: string) => { + calls.push(method) + + return (method === 'slash.exec' ? { type: 'send', message: 'audit the session states' } : {}) as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + await handle!.submitText('/audit-only audit the session states') + + expect(calls).toEqual(['slash.exec']) + expect(getQueuedPrompts(RUNTIME_SESSION_ID).map(entry => entry.text)).toEqual(['audit the session states']) + + dropSessionState(RUNTIME_SESSION_ID) + $queuedPromptsBySession.set({}) + }) + it('slash status header carries the command token, not the full invocation', async () => { // `/goal ` used to echo the entire invocation in the mono // header AND the goal text again in the backend notice right under it. diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 343c474b2f0f..72f5b571d90e 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -48,6 +48,7 @@ import { resolveTargetSessionId } from './resolve-target-session' import { type GatewayRequest, isSessionIdCandidate, + isTargetSessionBusy, renderCommandsCatalog, renderRpcResult, slashStatusText, @@ -252,7 +253,14 @@ export function useSlashCommand(deps: SlashCommandDeps) { renderSlashOutput(`⚡ loading skill: ${dispatch.name}`) } - if (busyRef.current) { + // Gate on the TARGET session's own busy state, not the foreground + // view's — see isTargetSessionBusy. `busyRef` mirrors whatever chat + // is on screen, while this command runs against the session + // resolveTargetSessionId picked, routinely a different one. + const sessionStates = $sessionStates.get() + const targetState = sessionStates[sessionId] + + if (isTargetSessionBusy(sessionStates, sessionId, busyRef.current)) { // The backend already executed the command — for `/goal ` // the goal is set and `message` is its kickoff prompt. Dropping // it here loses the kickoff silently (the goal exists but the @@ -266,8 +274,7 @@ export function useSlashCommand(deps: SlashCommandDeps) { // would otherwise park the kickoff on whichever chat is now in // front. Fall back through the live selection for a session whose // cache entry hasn't landed yet. - const storedId = - storedSessionId || $sessionStates.get()[sessionId]?.storedSessionId || $selectedStoredSessionId.get() + const storedId = storedSessionId || targetState?.storedSessionId || $selectedStoredSessionId.get() const queueKey = resolveComposerSessionKey(storedId, $sessions.get()) || storedId || sessionId diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 1282508301a1..e7cc0d439917 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -21,6 +21,7 @@ import { import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' import { $sessions, resolveComposerSessionKey, setAwaitingResponse, setBusy, setMessages } from '@/store/session' +import { $sessionStates } from '@/store/session-states' import type { ClientSessionState } from '../../../types' import { sessionContextDrift } from '../session-context-drift' @@ -34,6 +35,7 @@ import { isProviderSetupError, isSessionBusyError, isSessionNotFoundError, + isTargetSessionBusy, type SubmitTextOptions, withSessionBusyRetry } from './utils' @@ -143,9 +145,19 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // from $busy by a separate effect) may still read true — honoring it would // bounce the drained send. The drain lock serializes them; the user path // keeps the guard so a stray Enter mid-turn can't double-submit. + // + // The guard reads the TARGET session's busy state (isTargetSessionBusy), + // not the foreground flag: an explicit target (tile, queue drain) is + // frequently not the session on screen, so the foreground flag would gate + // one session's send on another session's turn. const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage) - if (!hasSendable || (!options?.fromQueue && busyRef.current)) { + const guardSessionId = options?.sessionId ?? activeSessionIdRef.current + + if ( + !hasSendable || + (!options?.fromQueue && isTargetSessionBusy($sessionStates.get(), guardSessionId, busyRef.current)) + ) { return false } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index ad9167ebb050..b193b1aebf10 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -52,6 +52,32 @@ export function isSessionNotFoundError(error: unknown): boolean { return /session not found/i.test(message) } +/** + * Is the session a prompt is about to run against currently mid-turn? + * + * The foreground `busyRef` is NOT the answer. It mirrors whatever chat is on + * screen, while submit and slash both resolve their target through + * `resolveTargetSessionId` — routinely a different session (a tile, a route + * rebind, a session created by this very call). Reading the foreground flag + * therefore gates one session's send on another session's turn: a stale + * foreground `true` (a warm resume of a still-running chat leaves one behind) + * blocks an IDLE target and reports "session busy" about a session doing + * nothing, and the converse lets a background send fire mid-turn. + * + * The published per-session state is authoritative. Fall back to the + * foreground flag only when the target has no state yet — a just-minted + * session whose first publish hasn't landed. + */ +export function isTargetSessionBusy( + sessionStates: Record, + sessionId: null | string, + foregroundBusy: boolean +): boolean { + const state = sessionId ? sessionStates[sessionId] : undefined + + return state ? state.busy : foregroundBusy +} + // Gateway JSON-RPC calls reject with "request timed out: " when the // backend event loop is starved (e.g. a poller spin or a heavy async-injected // turn). For prompt.submit this is indistinguishable from a dead runtime