Prevent removed goal subcommands from replacing goals

The simplified /goal surface only supports setting a condition and clearing it. Removed subcommand names such as status were still valid free-form objectives, so a user trying the old query flow could overwrite the real goal with a goal named status and make the desktop state look stuck in progress.

Constraint: /goal should stay as /goal <condition> and /goal clear for the prelaunch simplified UX

Rejected: Reintroduce /goal status | it expands the command surface the product direction intentionally removed

Confidence: high

Scope-risk: narrow

Directive: Do not add pseudo subcommands back to the desktop picker unless the CLI command surface is deliberately expanded again

Tested: bun test src/commands/goal/goal.test.tsx src/goals/goalState.test.ts src/goals/goalEvaluator.test.ts src/server/__tests__/ws-memory-events.test.ts

Tested: cd desktop && bun run test -- --run src/components/chat/composerUtils.test.ts src/__tests__/pages.test.tsx src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 14:00:48 +08:00
parent 5c5c5933f0
commit 7f74f1bde7
7 changed files with 32 additions and 9 deletions

View File

@ -169,8 +169,8 @@ describe('Content-only pages render without errors', () => {
}) })
expect(await screen.findAllByText('/goal')).toHaveLength(2) expect(await screen.findAllByText('/goal')).toHaveLength(2)
expect(screen.getByText('[status|pause|resume|complete|clear|--tokens <budget>|<objective>]')).toBeInTheDocument() expect(screen.getByText('[<condition> | clear]')).toBeInTheDocument()
expect(screen.getByText('Create or manage an autonomous completion goal')).toBeInTheDocument() expect(screen.getByText('Set a completion goal')).toBeInTheDocument()
expect(screen.queryByText('/goal status')).not.toBeInTheDocument() expect(screen.queryByText('/goal status')).not.toBeInTheDocument()
expect(screen.queryByText('/goal --tokens')).not.toBeInTheDocument() expect(screen.queryByText('/goal --tokens')).not.toBeInTheDocument()
}) })

View File

@ -75,8 +75,8 @@ describe('composerUtils', () => {
expect.arrayContaining([ expect.arrayContaining([
{ {
name: 'goal', name: 'goal',
description: 'Create or manage an autonomous completion goal', description: 'Set a completion goal',
argumentHint: '[status|pause|resume|complete|clear|--tokens <budget>|<objective>]', argumentHint: '[<condition> | clear]',
}, },
]), ]),
) )
@ -89,8 +89,8 @@ describe('composerUtils', () => {
commands.map((command) => command.name), commands.map((command) => command.name),
).toEqual(['goal']) ).toEqual(['goal'])
expect(commands[0]).toMatchObject({ expect(commands[0]).toMatchObject({
description: 'Create or manage an autonomous completion goal', description: 'Set a completion goal',
argumentHint: '[status|pause|resume|complete|clear|--tokens <budget>|<objective>]', argumentHint: '[<condition> | clear]',
}) })
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal status') expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal status')
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal --tokens') expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal --tokens')

View File

@ -26,8 +26,8 @@ export const FALLBACK_SLASH_COMMANDS = [
{ name: 'clear', description: 'Clear conversation history' }, { name: 'clear', description: 'Clear conversation history' },
{ {
name: 'goal', name: 'goal',
description: 'Create or manage an autonomous completion goal', description: 'Set a completion goal',
argumentHint: '[status|pause|resume|complete|clear|--tokens <budget>|<objective>]', argumentHint: '[<condition> | clear]',
}, },
{ name: 'review', description: 'Review code changes' }, { name: 'review', description: 'Review code changes' },
{ name: 'commit', description: 'Create a git commit' }, { name: 'commit', description: 'Create a git commit' },

View File

@ -79,6 +79,20 @@ describe('/goal command', () => {
expect(result.options?.shouldQuery).toBeUndefined() expect(result.options?.shouldQuery).toBeUndefined()
}) })
test('does not treat removed subcommands as replacement goals', async () => {
switchSession(`goal-command-${crypto.randomUUID()}` as SessionId)
const created = await runGoal('ship the smoke test')
expect(created.result).toBe('Goal set: ship the smoke test')
const status = await runGoal('status')
expect(status.result).toBe('Usage: /goal <condition> | clear')
expect(status.options?.shouldQuery).toBeUndefined()
const cleared = await runGoal('clear')
expect(cleared.result).toBe('Goal cleared: ship the smoke test')
})
test('hydrates completed goal state from persisted slash command history', async () => { test('hydrates completed goal state from persisted slash command history', async () => {
switchSession(`goal-command-${crypto.randomUUID()}` as SessionId) switchSession(`goal-command-${crypto.randomUUID()}` as SessionId)

View File

@ -4,7 +4,7 @@ const goal = {
type: 'local-jsx', type: 'local-jsx',
supportsNonInteractive: true, supportsNonInteractive: true,
name: 'goal', name: 'goal',
description: 'Set a completion goal that keeps the session working until it is met', description: 'Set a completion goal',
argumentHint: '[<condition> | clear]', argumentHint: '[<condition> | clear]',
load: () => import('./goal.js'), load: () => import('./goal.js'),
} satisfies Command } satisfies Command

View File

@ -23,6 +23,11 @@ describe('goalState', () => {
}) })
expect(parseGoalCommand('clear')).toEqual({ type: 'clear' }) expect(parseGoalCommand('clear')).toEqual({ type: 'clear' })
expect(() => parseGoalCommand('')).toThrow('Usage: /goal <condition> | clear') expect(() => parseGoalCommand('')).toThrow('Usage: /goal <condition> | clear')
expect(() => parseGoalCommand('status')).toThrow('Usage: /goal <condition> | clear')
expect(() => parseGoalCommand('pause')).toThrow('Usage: /goal <condition> | clear')
expect(() => parseGoalCommand('resume')).toThrow('Usage: /goal <condition> | clear')
expect(() => parseGoalCommand('complete')).toThrow('Usage: /goal <condition> | clear')
expect(() => parseGoalCommand('--tokens 100 ship it')).toThrow('Usage: /goal <condition> | clear')
}) })
test('stores and formats the current thread goal', () => { test('stores and formats the current thread goal', () => {

View File

@ -25,11 +25,15 @@ export type ParsedGoalCommand =
| { type: 'set'; objective: string } | { type: 'set'; objective: string }
const goalsByThread = new Map<string, ThreadGoal>() const goalsByThread = new Map<string, ThreadGoal>()
const RESERVED_GOAL_ARGS = new Set(['status', 'pause', 'resume', 'complete'])
export function parseGoalCommand(args: string): ParsedGoalCommand { export function parseGoalCommand(args: string): ParsedGoalCommand {
const trimmed = args.trim() const trimmed = args.trim()
if (!trimmed) throw new Error('Usage: /goal <condition> | clear') if (!trimmed) throw new Error('Usage: /goal <condition> | clear')
if (trimmed === 'clear') return { type: 'clear' } if (trimmed === 'clear') return { type: 'clear' }
if (RESERVED_GOAL_ARGS.has(trimmed) || trimmed.startsWith('--tokens')) {
throw new Error('Usage: /goal <condition> | clear')
}
return { type: 'set', objective: trimmed } return { type: 'set', objective: trimmed }
} }