fix(desktop): gate the slash/submit busy queue on the target session

A slash command runs against the session `resolveTargetSessionId` picks,
which is routinely not the session on screen — a tile, a route rebind, or
a session created by the call itself. Both prompt pipelines gated on
`busyRef`, the FOREGROUND view's busy flag, so one session's send was
gated on another session's turn: a stale foreground `true` (a warm resume
of a still-running chat leaves one behind) parked an idle session's
command on the composer queue and reported "session busy" about a session
doing nothing. The converse also leaked — a background send could fire
mid-turn while the foreground happened to be idle.

Read the published per-session state instead, falling back to the
foreground flag only when the target has no state yet (a just-minted
session whose first publish hasn't landed). One shared resolver so submit
and slash cannot drift apart again.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 01:17:18 -05:00
parent 723fd67ac7
commit 7ef3f1407b
4 changed files with 128 additions and 4 deletions

View file

@ -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(
<Harness
busyRef={busyRef}
onReady={h => (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(
<Harness
busyRef={busyRef}
onReady={h => (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 <long prose>` used to echo the entire invocation in the mono
// header AND the goal text again in the backend notice right under it.

View file

@ -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 <text>`
// 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

View file

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

View file

@ -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<string, { busy: boolean }>,
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: <method>" 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