From 7f74f1bde7de5e71bbcd78254f32405c095d328e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 16 May 2026 14:00:48 +0800 Subject: [PATCH] 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 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 --- desktop/src/__tests__/pages.test.tsx | 4 ++-- desktop/src/components/chat/composerUtils.test.ts | 8 ++++---- desktop/src/components/chat/composerUtils.ts | 4 ++-- src/commands/goal/goal.test.tsx | 14 ++++++++++++++ src/commands/goal/index.ts | 2 +- src/goals/goalState.test.ts | 5 +++++ src/goals/goalState.ts | 4 ++++ 7 files changed, 32 insertions(+), 9 deletions(-) diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 570fd481..0c60ed13 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -169,8 +169,8 @@ describe('Content-only pages render without errors', () => { }) expect(await screen.findAllByText('/goal')).toHaveLength(2) - expect(screen.getByText('[status|pause|resume|complete|clear|--tokens |]')).toBeInTheDocument() - expect(screen.getByText('Create or manage an autonomous completion goal')).toBeInTheDocument() + expect(screen.getByText('[ | clear]')).toBeInTheDocument() + expect(screen.getByText('Set a completion goal')).toBeInTheDocument() expect(screen.queryByText('/goal status')).not.toBeInTheDocument() expect(screen.queryByText('/goal --tokens')).not.toBeInTheDocument() }) diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index acc14e5b..e74f4448 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -75,8 +75,8 @@ describe('composerUtils', () => { expect.arrayContaining([ { name: 'goal', - description: 'Create or manage an autonomous completion goal', - argumentHint: '[status|pause|resume|complete|clear|--tokens |]', + description: 'Set a completion goal', + argumentHint: '[ | clear]', }, ]), ) @@ -89,8 +89,8 @@ describe('composerUtils', () => { commands.map((command) => command.name), ).toEqual(['goal']) expect(commands[0]).toMatchObject({ - description: 'Create or manage an autonomous completion goal', - argumentHint: '[status|pause|resume|complete|clear|--tokens |]', + description: 'Set a completion goal', + argumentHint: '[ | clear]', }) expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal status') expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal --tokens') diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index b35c36a4..884903d9 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -26,8 +26,8 @@ export const FALLBACK_SLASH_COMMANDS = [ { name: 'clear', description: 'Clear conversation history' }, { name: 'goal', - description: 'Create or manage an autonomous completion goal', - argumentHint: '[status|pause|resume|complete|clear|--tokens |]', + description: 'Set a completion goal', + argumentHint: '[ | clear]', }, { name: 'review', description: 'Review code changes' }, { name: 'commit', description: 'Create a git commit' }, diff --git a/src/commands/goal/goal.test.tsx b/src/commands/goal/goal.test.tsx index eea39602..5227b575 100644 --- a/src/commands/goal/goal.test.tsx +++ b/src/commands/goal/goal.test.tsx @@ -79,6 +79,20 @@ describe('/goal command', () => { 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 | 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 () => { switchSession(`goal-command-${crypto.randomUUID()}` as SessionId) diff --git a/src/commands/goal/index.ts b/src/commands/goal/index.ts index fbef31f0..55421e42 100644 --- a/src/commands/goal/index.ts +++ b/src/commands/goal/index.ts @@ -4,7 +4,7 @@ const goal = { type: 'local-jsx', supportsNonInteractive: true, name: 'goal', - description: 'Set a completion goal that keeps the session working until it is met', + description: 'Set a completion goal', argumentHint: '[ | clear]', load: () => import('./goal.js'), } satisfies Command diff --git a/src/goals/goalState.test.ts b/src/goals/goalState.test.ts index 4b09aef3..3a7d2657 100644 --- a/src/goals/goalState.test.ts +++ b/src/goals/goalState.test.ts @@ -23,6 +23,11 @@ describe('goalState', () => { }) expect(parseGoalCommand('clear')).toEqual({ type: 'clear' }) expect(() => parseGoalCommand('')).toThrow('Usage: /goal | clear') + expect(() => parseGoalCommand('status')).toThrow('Usage: /goal | clear') + expect(() => parseGoalCommand('pause')).toThrow('Usage: /goal | clear') + expect(() => parseGoalCommand('resume')).toThrow('Usage: /goal | clear') + expect(() => parseGoalCommand('complete')).toThrow('Usage: /goal | clear') + expect(() => parseGoalCommand('--tokens 100 ship it')).toThrow('Usage: /goal | clear') }) test('stores and formats the current thread goal', () => { diff --git a/src/goals/goalState.ts b/src/goals/goalState.ts index 029164f8..e05bb7bb 100644 --- a/src/goals/goalState.ts +++ b/src/goals/goalState.ts @@ -25,11 +25,15 @@ export type ParsedGoalCommand = | { type: 'set'; objective: string } const goalsByThread = new Map() +const RESERVED_GOAL_ARGS = new Set(['status', 'pause', 'resume', 'complete']) export function parseGoalCommand(args: string): ParsedGoalCommand { const trimmed = args.trim() if (!trimmed) throw new Error('Usage: /goal | clear') if (trimmed === 'clear') return { type: 'clear' } + if (RESERVED_GOAL_ARGS.has(trimmed) || trimmed.startsWith('--tokens')) { + throw new Error('Usage: /goal | clear') + } return { type: 'set', objective: trimmed } }