mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
parent
723fd67ac7
commit
7ef3f1407b
4 changed files with 128 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue