mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
fix(desktop): bind a tab's slash command to its own session
A slash command in a ⌘T tab or split pane routes through the primary chat's dispatcher, which read the FOREGROUND view's identity for two decisions it had no business asking the foreground about. Busy: the gate read `busyRef`, a mirror of whatever chat is on screen. A brand-new tab with zero turns was told "session busy — message queued" because an unrelated chat was mid-stream, and the converse let a background send fire into a live turn. Identity: the output writer bound to the foreground's stored session, so a tab's transcript writes re-keyed its cache entry onto the primary's stored id and its queued payload landed on the primary's queue — the kickoff would then drain into the wrong conversation. `submitText` also dropped an explicit target when it routed to a slash command, running a queue drain's command against whatever was in front. Read the target session's own published state for both. One shared resolver so submit and slash cannot drift apart again.
This commit is contained in:
parent
7ef3f1407b
commit
428c909d28
3 changed files with 63 additions and 18 deletions
|
|
@ -1122,6 +1122,51 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
|
|||
$queuedPromptsBySession.set({})
|
||||
})
|
||||
|
||||
it('binds slash output and the busy queue to the TARGET session, not the foreground selection', async () => {
|
||||
// A tile (⌘T tab, split pane) routes its slash commands through this hook
|
||||
// with an explicit runtime id while the foreground selection names a
|
||||
// different conversation. Binding the output writer to the foreground
|
||||
// selection re-keyed the tile's cache entry onto the primary's stored
|
||||
// session and parked its queued payload on the primary's queue.
|
||||
const tileRuntimeId = 'tile-runtime'
|
||||
const tileStoredId = 'tile-stored'
|
||||
|
||||
$queuedPromptsBySession.set({})
|
||||
publishSessionState(tileRuntimeId, {
|
||||
...createClientSessionState(tileStoredId),
|
||||
busy: true
|
||||
})
|
||||
|
||||
const boundStoredIds: (null | string | undefined)[] = []
|
||||
|
||||
const requestGateway = vi.fn(
|
||||
async (method: string) => (method === 'slash.exec' ? { type: 'send', message: 'run it in the tab' } : {}) as never
|
||||
)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onUpdateState={(_sessionId, storedSessionId) => boundStoredIds.push(storedSessionId)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId="primary-stored"
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/audit-only run it in the tab', { sessionId: tileRuntimeId })
|
||||
|
||||
// Every transcript write lands on the tile's own stored session.
|
||||
expect(boundStoredIds).not.toHaveLength(0)
|
||||
expect(new Set(boundStoredIds)).toEqual(new Set([tileStoredId]))
|
||||
// …and the kickoff queues against the tile, never the foreground chat.
|
||||
expect(getQueuedPrompts(tileStoredId).map(entry => entry.text)).toEqual(['run it in the tab'])
|
||||
expect(getQueuedPrompts('primary-stored')).toEqual([])
|
||||
|
||||
dropSessionState(tileRuntimeId)
|
||||
$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.
|
||||
|
|
|
|||
|
|
@ -519,7 +519,9 @@ export function usePromptActions({
|
|||
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
|
||||
triggerHaptic('selection')
|
||||
await executeSlashCommand(visibleText)
|
||||
// Forward the explicit target (background queue drain, tile) — dropping
|
||||
// it ran the command against whatever chat happened to be in front.
|
||||
await executeSlashCommand(visibleText, options?.sessionId ? { sessionId: options.sessionId } : undefined)
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
|
|||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$connection,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
resolveComposerSessionKey,
|
||||
|
|
@ -167,9 +166,15 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
|||
return null
|
||||
}
|
||||
|
||||
// A long-running command can finish after a session switch. Keep its
|
||||
// output bound to the stored session selected at invocation time.
|
||||
const storedSessionId = selectedStoredSessionIdRef.current
|
||||
// Bind output to the TARGET session's own stored id, snapshotted now so
|
||||
// a command that outlives a session switch still lands on the right
|
||||
// chat. NOT the foreground selection: a tile (⌘T tab, split) runs its
|
||||
// slash commands through this hook with an explicit runtime id while
|
||||
// the selection names a different conversation, and passing that down
|
||||
// to updateSessionState re-keyed the tile's cache entry onto the
|
||||
// primary's stored session. Fall back to the selection only for a
|
||||
// session with no published state yet (a draft this call just created).
|
||||
const storedSessionId = $sessionStates.get()[sessionId]?.storedSessionId ?? selectedStoredSessionIdRef.current
|
||||
|
||||
// Header carries the command token only. The full invocation would
|
||||
// duplicate long args — `/goal <prose>` echoed the whole goal in the
|
||||
|
|
@ -257,10 +262,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
|||
// 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)) {
|
||||
if (isTargetSessionBusy($sessionStates.get(), 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
|
||||
|
|
@ -268,15 +270,11 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
|||
// queue instead: it fires when the running turn settles, and the
|
||||
// queue panel above the composer shows it in the meantime.
|
||||
//
|
||||
// Key off the storedSessionId resolved at invocation time (same
|
||||
// value the output writer is bound to) rather than re-reading the
|
||||
// globals here — a session switch between dispatch and this branch
|
||||
// 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 || targetState?.storedSessionId || $selectedStoredSessionId.get()
|
||||
|
||||
const queueKey = resolveComposerSessionKey(storedId, $sessions.get()) || storedId || sessionId
|
||||
// Park it on the same stored session the output writer is bound to
|
||||
// rather than re-reading the globals here — a session switch between
|
||||
// dispatch and this branch would otherwise queue the kickoff on
|
||||
// whichever chat is now in front.
|
||||
const queueKey = resolveComposerSessionKey(storedSessionId, $sessions.get()) || storedSessionId || sessionId
|
||||
|
||||
if (enqueueQueuedPrompt(queueKey, { attachments: [], text: message })) {
|
||||
renderSlashOutput('session busy — message queued to send when the current turn finishes')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue