mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
fix(desktop): /goal arg stays editable, kickoff queues when busy, slash header stops echoing long args
Four symptoms from the same /goal flow on desktop:
- Typing '/goal <text>' sealed the command into a directive chip on
Space because /goal was registered without args:true, so the goal
prose rendered awkwardly after a pill. The registry row now matches
/personality and /tools: the arg stays editable text.
- The slash status header echoed the ENTIRE invocation ('slash:/goal
<whole goal prose>') in mono, immediately above the backend notice
that repeats the goal text again, and the kickoff user bubble that
repeats it a third time. The header now carries just the command
token (slash:/goal).
- When the session was busy, handleDispatch rendered 'session busy'
and dropped the dispatch message. For /goal that message is the
kickoff prompt, and the backend has ALREADY set the goal by then —
the goal existed but the agent never heard about it, and later turns
looked goal-unaware (#63352). The busy path now queues the kickoff
on the composer queue: it sends on settle and is visible/editable in
the queue panel meanwhile. Falls back to the old message if the
queue rejects the entry.
- A slash command issued on a fresh draft created the backend session
with no preview, so the sidebar row sat as 'Untitled session' —
and when the kickoff was dropped, auto-title never fired either
(it needs a completed user->assistant exchange). ensureSessionId now
seeds the preview with the typed command.
Tests: registry row contract, busy-path queueing (kickoff neither
sends mid-turn nor vanishes), and the header-token assertion.
This commit is contained in:
parent
fe3dd9106a
commit
6b26b409cf
4 changed files with 154 additions and 7 deletions
|
|
@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import { getSession } from '@/hermes'
|
||||
import { textPart } from '@/lib/chat-messages'
|
||||
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
|
||||
import { $queuedPromptsBySession, getQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $notifications, clearNotifications } from '@/store/notifications'
|
||||
import {
|
||||
$busy,
|
||||
|
|
@ -980,6 +981,113 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
|
|||
expect(renderedText).not.toContain('/goal: no output')
|
||||
})
|
||||
|
||||
it('queues the /goal kickoff instead of dropping it when the session is busy (#63352)', async () => {
|
||||
// The backend sets the goal the moment slash.exec runs — dropping the
|
||||
// returned kickoff message because busyRef was true left a goal the agent
|
||||
// never heard about. The busy path must park the kickoff on the composer
|
||||
// queue so the settle drain sends it.
|
||||
$queuedPromptsBySession.set({})
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const states: Record<string, unknown>[] = []
|
||||
const busyRef = { current: true }
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'slash.exec') {
|
||||
return {
|
||||
type: 'send',
|
||||
notice: '⊙ Goal set (20-turn budget): ship the release notes',
|
||||
message: 'ship the release notes'
|
||||
} as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
busyRef={busyRef}
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => states.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/goal ship the release notes')
|
||||
|
||||
// The kickoff must NOT submit mid-turn — and must NOT vanish either.
|
||||
expect(calls.map(c => c.method)).toEqual(['slash.exec'])
|
||||
|
||||
const queued = getQueuedPrompts(RUNTIME_SESSION_ID)
|
||||
expect(queued.map(entry => entry.text)).toEqual(['ship the release notes'])
|
||||
|
||||
const renderedText = states
|
||||
.flatMap(state => {
|
||||
const messages = Array.isArray(state.messages)
|
||||
? (state.messages as Array<{ parts?: Array<{ text?: string }> }>)
|
||||
: []
|
||||
|
||||
return messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
// The notice still renders, and the busy line reports a queue, not a demand
|
||||
// to /interrupt.
|
||||
expect(renderedText).toContain('⊙ Goal set (20-turn budget): ship the release notes')
|
||||
expect(renderedText).toContain('queued')
|
||||
|
||||
$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.
|
||||
const states: Record<string, unknown>[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'slash.exec') {
|
||||
return {
|
||||
type: 'send',
|
||||
notice: '⊙ Goal set: build the whole thing',
|
||||
message: 'build the whole thing end to end with tests'
|
||||
} as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => states.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/goal build the whole thing end to end with tests')
|
||||
|
||||
const systemTexts = states
|
||||
.flatMap(state => {
|
||||
const messages = Array.isArray(state.messages)
|
||||
? (state.messages as Array<{ role?: string; parts?: Array<{ text?: string }> }>)
|
||||
: []
|
||||
|
||||
return messages
|
||||
.filter(message => message.role === 'system')
|
||||
.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
expect(systemTexts).toContain('slash:/goal\n')
|
||||
expect(systemTexts).not.toContain('slash:/goal build the whole thing')
|
||||
})
|
||||
|
||||
it('dispatches a slash command with a multiline arg instead of "empty slash command" (#41323, #55510)', async () => {
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const states: Record<string, unknown>[] = []
|
||||
|
|
|
|||
|
|
@ -17,20 +17,24 @@ import { isMissingRpcMethod } from '@/lib/gateway-rpc'
|
|||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { openCommandPalettePage } from '@/store/command-palette'
|
||||
import { setComposerDraft } from '@/store/composer'
|
||||
import { enqueueQueuedPrompt } from '@/store/composer-queue'
|
||||
import { dismissNotification, notify, notifyError } from '@/store/notifications'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$connection,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
resolveComposerSessionKey,
|
||||
setCurrentUsage,
|
||||
setModelPickerOpen,
|
||||
setSessionPickerOpen,
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { $sessionStates } from '@/store/session-states'
|
||||
|
||||
import type {
|
||||
BrowserManageResponse,
|
||||
|
|
@ -133,10 +137,10 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
|||
// binding momentarily absent (profile swap, reconnect, orphan-reap,
|
||||
// timeout) it minted a NEW session, so `/goal status` reported "No active
|
||||
// goal" for a goal that was live on the real chat.
|
||||
const ensureSessionId = async (sessionHint?: string) =>
|
||||
const ensureSessionId = async (sessionHint?: string, preview?: null | string) =>
|
||||
resolveTargetSessionId({
|
||||
activeRuntimeId: activeSessionIdRef.current,
|
||||
createSession: () => createBackendSessionForSend(),
|
||||
createSession: () => createBackendSessionForSend(preview),
|
||||
explicitRuntimeId: sessionHint,
|
||||
getRuntimeIdForStoredSession,
|
||||
requestGateway,
|
||||
|
|
@ -150,7 +154,11 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
|||
const withSlashOutput = async (
|
||||
ctx: SlashActionCtx
|
||||
): Promise<{ render: (text: string) => void; sessionId: string; storedSessionId: string | null } | null> => {
|
||||
const sessionId = await ensureSessionId(ctx.sessionHint)
|
||||
// A slash on a fresh draft creates the backend session; seed the
|
||||
// sidebar preview with the typed command so the row doesn't sit as
|
||||
// "Untitled session" (auto-title only fires after a full exchange,
|
||||
// which a bare exec command never produces).
|
||||
const sessionId = await ensureSessionId(ctx.sessionHint, ctx.command)
|
||||
|
||||
if (!sessionId) {
|
||||
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
|
||||
|
|
@ -162,11 +170,14 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
|||
// output bound to the stored session selected at invocation time.
|
||||
const storedSessionId = selectedStoredSessionIdRef.current
|
||||
|
||||
// Header carries the command token only. The full invocation would
|
||||
// duplicate long args — `/goal <prose>` echoed the whole goal in the
|
||||
// mono header, then again in the backend notice right under it.
|
||||
const render = (text: string) =>
|
||||
appendSessionTextMessage(
|
||||
sessionId,
|
||||
'system',
|
||||
ctx.recordInput ? slashStatusText(ctx.command, text) : text,
|
||||
ctx.recordInput ? slashStatusText(`/${ctx.name}`, text) : text,
|
||||
storedSessionId
|
||||
)
|
||||
|
||||
|
|
@ -184,7 +195,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
|||
return
|
||||
}
|
||||
|
||||
const { render: renderSlashOutput, sessionId } = resolved
|
||||
const { render: renderSlashOutput, sessionId, storedSessionId } = resolved
|
||||
|
||||
if (!isDesktopSlashCommand(name)) {
|
||||
renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
|
||||
|
|
@ -242,7 +253,27 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
|||
}
|
||||
|
||||
if (busyRef.current) {
|
||||
renderSlashOutput('session busy — /interrupt the current turn before sending this command')
|
||||
// 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
|
||||
// agent never hears about it, #63352). Queue it on the composer
|
||||
// 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 || $sessionStates.get()[sessionId]?.storedSessionId || $selectedStoredSessionId.get()
|
||||
const queueKey = resolveComposerSessionKey(storedId, $sessions.get()) || storedId || sessionId
|
||||
|
||||
if (enqueueQueuedPrompt(queueKey, { attachments: [], text: message })) {
|
||||
renderSlashOutput('session busy — message queued to send when the current turn finishes')
|
||||
} else {
|
||||
renderSlashOutput('session busy — /interrupt the current turn before sending this command')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,14 @@ describe('desktop slash command curation', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('keeps /goal arg text editable instead of sealing it into a chip', () => {
|
||||
// /goal takes free prose (the goal itself) plus subcommands. Without
|
||||
// args:true, Space after the command name committed a sealed directive
|
||||
// chip and the goal text rendered awkwardly after a pill.
|
||||
expect(resolveDesktopCommand('/goal')?.surface).toEqual({ kind: 'exec' })
|
||||
expect(resolveDesktopCommand('/goal')?.args).toBe(true)
|
||||
})
|
||||
|
||||
it('routes /journey (and aliases) to the memory graph overlay action', () => {
|
||||
expect(resolveDesktopCommand('/journey')?.surface).toEqual({ kind: 'action', action: 'journey' })
|
||||
expect(resolveDesktopCommand('/memory-graph')?.surface).toEqual({ kind: 'action', action: 'journey' })
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
|||
args: true
|
||||
},
|
||||
{ name: '/debug', description: 'Create a debug report', surface: exec() },
|
||||
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
|
||||
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec(), args: true },
|
||||
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
|
||||
{
|
||||
name: '/pet',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue