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:
SHL0MS 2026-07-21 12:16:25 -04:00 committed by Teknium
parent fe3dd9106a
commit 6b26b409cf
4 changed files with 154 additions and 7 deletions

View file

@ -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>[] = []

View file

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

View file

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

View file

@ -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',