From 6b26b409cf53e9cbfc9b0c2a7f6593635dcd2428 Mon Sep 17 00:00:00 2001 From: SHL0MS Date: Tue, 21 Jul 2026 12:16:25 -0400 Subject: [PATCH] fix(desktop): /goal arg stays editable, kickoff queues when busy, slash header stops echoing long args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four symptoms from the same /goal flow on desktop: - Typing '/goal ' 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 ') 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. --- .../hooks/use-prompt-actions/index.test.tsx | 108 ++++++++++++++++++ .../session/hooks/use-prompt-actions/slash.ts | 43 ++++++- .../src/lib/desktop-slash-commands.test.ts | 8 ++ .../desktop/src/lib/desktop-slash-commands.ts | 2 +- 4 files changed, 154 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index c9a5e5db9db1..783bc4e75fc8 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -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 }[] = [] + const states: Record[] = [] + const busyRef = { current: true } + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + 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( + (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 ` 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[] = [] + + 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( + (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 }[] = [] const states: Record[] = [] diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 39e2fd668595..58ac07021f9b 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -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 ` 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 ` + // 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 } diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index cbca758e7e65..efb16453479a 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -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' }) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 22ae4e590e42..399479242f62 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -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',