Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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,
Expand Down Expand Up @@ -190,7 +191,7 @@
submitText: (...args: Parameters<typeof actions.submitText>) =>
act(async () => actions.submitText(...args)) as Promise<boolean>
})
}, [

Check warning on line 194 in apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

React Hook useEffect has a missing dependency: 'actions'. Either include it or remove the dependency array
actions.cancelRun,
actions.editMessage,
actions.reloadFromMessage,
Expand Down Expand Up @@ -980,6 +981,113 @@
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>[] = []
Expand Down
43 changes: 37 additions & 6 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 })
Expand All @@ -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
)

Expand All @@ -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.`)
Expand Down Expand Up @@ -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
}
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/lib/desktop-slash-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/desktop-slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading