From 606f3c0dc24b44406ecec0f3be27bbd3b5aa51bd 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 03:28:48 +0800 Subject: [PATCH] Simplify goal command into an immediate set-clear loop The previous /goal surface exposed management subcommands that made the CLI and desktop behavior hard to reason about. This keeps the user-facing command close to Claude Code's set/clear interaction while preserving historical transcript hydration and the existing evaluator loop. Constraint: Desktop must see a live goal event before the model reaches the next tool call.\nRejected: Keep status/pause/resume/complete slash subcommands | requested UX is only set and clear.\nConfidence: high\nScope-risk: moderate\nDirective: Keep /goal user-facing syntax compact; extend internals without expanding the slash picker unless product explicitly asks.\nTested: 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 src/server/__tests__/conversations.test.ts\nTested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx src/pages/ActiveSession.test.tsx src/components/chat/composerUtils.test.ts\nTested: bun run check:server\nTested: cd desktop && bun run lint\nTested: cd desktop && bun run build\nTested: ./bin/claude-haha -p '/goal smoke verify immediate goal output' --output-format stream-json --include-partial-messages --max-turns 1 --no-session-persistence --verbose emitted immediate system.local_command_output before model stream\nTested: ./bin/claude-haha -p '/goal clear' --output-format stream-json --include-partial-messages --max-turns 1 --no-session-persistence --verbose exited with duration_api_ms=0 and No active goal\nNot-tested: Browser click smoke was blocked by Chrome DevTools transport closing and Computer Use cgWindowNotFound on this machine. --- .../src/components/chat/MessageList.test.tsx | 4 +- .../src/components/chat/composerUtils.test.ts | 4 +- desktop/src/components/chat/composerUtils.ts | 4 +- desktop/src/i18n/locales/en.ts | 4 +- desktop/src/i18n/locales/zh.ts | 4 +- desktop/src/pages/ActiveSession.tsx | 4 +- desktop/src/stores/chatStore.test.ts | 17 +--- desktop/src/stores/chatStore.ts | 13 ++- src/QueryEngine.ts | 62 +++++++++++++- src/commands/goal/goal.test.tsx | 57 +++---------- src/commands/goal/goal.tsx | 43 ++-------- src/commands/goal/index.ts | 4 +- src/goals/goalState.test.ts | 7 +- src/goals/goalState.ts | 68 ++++++---------- src/server/__tests__/conversations.test.ts | 37 +++++++++ src/server/__tests__/ws-memory-events.test.ts | 80 ++++--------------- src/server/ws/handler.ts | 61 ++++++++++++-- 17 files changed, 239 insertions(+), 234 deletions(-) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index c27f84f4..35842c06 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -161,7 +161,7 @@ describe('MessageList nested tool calls', () => { render() - expect(screen.getByText('Goal created')).toBeTruthy() + expect(screen.getByText('Goal set')).toBeTruthy() expect(screen.getByText('Objective: ship the smoke test')).toBeTruthy() expect(screen.getByText('Status: active')).toBeTruthy() expect(screen.getByText('Budget: 0 / 2,000 tokens')).toBeTruthy() @@ -186,7 +186,7 @@ describe('MessageList nested tool calls', () => { render() - expect(screen.getByText('Goal replaced')).toBeTruthy() + expect(screen.getByText('Goal set')).toBeTruthy() expect(screen.getByText('Objective: ship the replacement target')).toBeTruthy() expect(screen.getByText('Budget: 0 / unlimited tokens')).toBeTruthy() }) diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 763b2f4b..51d417e6 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -68,8 +68,8 @@ describe('composerUtils', () => { expect.arrayContaining([ { name: 'goal', - description: 'Create or manage an autonomous completion goal', - argumentHint: '|status|pause|resume|clear|complete', + description: 'Set a completion goal', + argumentHint: '[ | clear]', }, ]), ) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 94ba1f0f..86503ac2 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|clear|complete', + description: 'Set a completion goal', + argumentHint: '[ | clear]', }, { name: 'review', description: 'Review code changes' }, { name: 'commit', description: 'Create a git commit' }, diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 11395f30..65c5d97a 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -888,8 +888,8 @@ export const en = { 'chat.userMessageReference': 'User message', 'chat.assistantMessageReference': 'Assistant message', 'chat.slashCommands': 'Slash commands', - 'chat.goalEvent.created': 'Goal created', - 'chat.goalEvent.replaced': 'Goal replaced', + 'chat.goalEvent.created': 'Goal set', + 'chat.goalEvent.replaced': 'Goal set', 'chat.goalEvent.statusTitle': 'Goal status', 'chat.goalEvent.paused': 'Goal paused', 'chat.goalEvent.resumed': 'Goal resumed', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 675d700d..85f673d8 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -890,8 +890,8 @@ export const zh: Record = { 'chat.userMessageReference': '用户消息', 'chat.assistantMessageReference': 'AI 回复', 'chat.slashCommands': '斜杠命令', - 'chat.goalEvent.created': '目标已创建', - 'chat.goalEvent.replaced': '目标已替换', + 'chat.goalEvent.created': '目标已设置', + 'chat.goalEvent.replaced': '目标已设置', 'chat.goalEvent.statusTitle': '目标状态', 'chat.goalEvent.paused': '目标已暂停', 'chat.goalEvent.resumed': '目标已恢复', diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 5f35156a..0efcc3c1 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -223,8 +223,8 @@ function GoalStatusPanel({ data-testid="active-goal-panel" className={ compact - ? 'border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-2' - : 'mx-auto w-full max-w-[900px] px-8 py-2.5' + ? 'mx-auto w-full max-w-[860px] px-4 py-2' + : 'mx-auto w-full max-w-[860px] px-8 py-2.5' } >
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index c0ad9a48..d5ade279 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -256,22 +256,13 @@ describe('chatStore history mapping', () => { id: 'goal-command', type: 'system', timestamp: '2026-04-06T00:00:00.000Z', - content: '/goal\n--tokens 2k ship the smoke test', + content: '/goal\nship the smoke test', }, { id: 'goal-output', type: 'system', timestamp: '2026-04-06T00:00:01.000Z', - content: [ - '', - 'Goal created.', - 'Goal: active', - 'Objective: ship the smoke test', - 'Budget: 0 / 2,000 tokens', - 'Elapsed: 0s', - 'Continuations: 0', - '', - ].join('\n'), + content: 'Goal set: ship the smoke test', }, ] @@ -279,7 +270,7 @@ describe('chatStore history mapping', () => { { id: 'goal-command', type: 'user_text', - content: '/goal --tokens 2k ship the smoke test', + content: '/goal ship the smoke test', }, { id: 'goal-output', @@ -287,8 +278,6 @@ describe('chatStore history mapping', () => { action: 'created', status: 'active', objective: 'ship the smoke test', - budget: '0 / 2,000 tokens', - continuations: '0', }, ]) }) diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 596f8d19..51df7293 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -1048,7 +1048,7 @@ type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; n type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string } const TASK_NOTIFICATION_RE = /^\s*[\s\S]*<\/task-notification>$/i -const GOAL_CLEAR_ALIASES = new Set(['clear', 'stop', 'off', 'reset', 'none', 'cancel']) +const GOAL_CLEAR_ALIASES = new Set(['clear']) const GOAL_EVENT_ACTIONS = new Set([ 'created', 'replaced', @@ -1209,9 +1209,18 @@ function parseGoalEventFromLocalCommandOutput( const trimmed = output.trim() if (!trimmed) return null - if (trimmed === 'Goal cleared.') return { action: 'cleared', message: trimmed } + if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) return { action: 'cleared', message: trimmed } if (trimmed === 'Goal marked complete.') return { action: 'completed', message: trimmed } if (trimmed === 'No active goal.' || trimmed === 'No goal to resume.') return { action: 'message', message: trimmed } + if (trimmed.startsWith('Goal set:')) { + const objective = trimmed.slice('Goal set:'.length).trim() + return { + action: 'created', + status: 'active', + objective: objective || undefined, + message: trimmed, + } + } const actionPrefix = trimmed.startsWith('Goal replaced.\n') ? 'replaced' diff --git a/src/QueryEngine.ts b/src/QueryEngine.ts index 0a80c613..ad1213c2 100644 --- a/src/QueryEngine.ts +++ b/src/QueryEngine.ts @@ -21,6 +21,7 @@ import stripAnsi from 'strip-ansi' import type { Command } from './commands.js' import { getSlashCommandToolSkills } from './commands.js' import { + COMMAND_NAME_TAG, LOCAL_COMMAND_STDERR_TAG, LOCAL_COMMAND_STDOUT_TAG, } from './constants/xml.js' @@ -553,11 +554,25 @@ export class QueryEngine { // Record when system message is yielded for headless latency tracking headlessProfilerCheckpoint('system_message_yielded') + if (shouldQuery) { + for (const goalOutput of localGoalCommandOutputMessages(messagesFromUserInput)) { + yield goalOutput + } + } + if (!shouldQuery) { // Return the results of local slash commands. // Use messagesFromUserInput (not replayableMessages) for command output // because selectableUserMessagesFilter excludes local-command-stdout tags. + let latestLocalCommandName: string | null = null for (const msg of messagesFromUserInput) { + if (msg.type === 'system' && msg.subtype === 'local_command') { + const commandName = readXmlTag(msg.content, COMMAND_NAME_TAG) + if (commandName) { + latestLocalCommandName = commandName.replace(/^\//, '') + } + } + if ( msg.type === 'user' && typeof msg.message.content === 'string' && @@ -591,7 +606,12 @@ export class QueryEngine { (msg.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) || msg.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`)) ) { - yield localCommandOutputToSDKAssistantMessage(msg.content, msg.uuid) + if (latestLocalCommandName === 'goal') { + yield toSDKLocalCommandOutputMessage(msg) + } else { + yield localCommandOutputToSDKAssistantMessage(msg.content, msg.uuid) + } + latestLocalCommandName = null } if (msg.type === 'system' && msg.subtype === 'compact_boundary') { @@ -1176,6 +1196,46 @@ export class QueryEngine { } } +function* localGoalCommandOutputMessages( + messages: Message[], +): Generator { + let latestLocalCommandName: string | null = null + for (const msg of messages) { + if (msg.type !== 'system' || msg.subtype !== 'local_command') continue + + const commandName = readXmlTag(msg.content, COMMAND_NAME_TAG) + if (commandName) { + latestLocalCommandName = commandName.replace(/^\//, '') + continue + } + + if ( + latestLocalCommandName === 'goal' && + (msg.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) || + msg.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`)) + ) { + yield toSDKLocalCommandOutputMessage(msg) + latestLocalCommandName = null + } + } +} + +function toSDKLocalCommandOutputMessage(message: Message): SDKMessage { + return { + type: 'system', + subtype: 'local_command_output', + content: message.type === 'system' ? stripAnsi(message.content) : '', + uuid: message.uuid, + session_id: getSessionId(), + } as SDKMessage +} + +function readXmlTag(text: string, tag: string): string | null { + const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = text.match(new RegExp(`<${escaped}>([\\s\\S]*?)`, 'i')) + return match?.[1]?.trim() ?? null +} + /** * Sends a single prompt to the Claude API and returns the response. * Assumes that claude is being used non-interactively -- will not diff --git a/src/commands/goal/goal.test.tsx b/src/commands/goal/goal.test.tsx index c584ae27..c2f7c971 100644 --- a/src/commands/goal/goal.test.tsx +++ b/src/commands/goal/goal.test.tsx @@ -31,14 +31,11 @@ async function runGoal(args: string, context: Partial = } describe('/goal command', () => { - test('creates a goal and manages every subcommand in one CLI session', async () => { + test('sets and clears a goal in one CLI session', async () => { switchSession(`goal-command-${crypto.randomUUID()}` as SessionId) - const created = await runGoal('--tokens 2k ship the smoke test') - expect(created.result).toContain('Goal created.') - expect(created.result).toContain('Goal: active') - expect(created.result).toContain('Objective: ship the smoke test') - expect(created.result).toContain('Budget: 0 / 2,000 tokens') + const created = await runGoal('ship the smoke test') + expect(created.result).toBe('Goal set: ship the smoke test') expect(created.options).toMatchObject({ display: 'system', shouldQuery: true, @@ -47,17 +44,8 @@ describe('/goal command', () => { 'ship the smoke test', ) - const status = await runGoal('status') - expect(status.result).toContain('Goal: active') - expect(status.result).toContain('Objective: ship the smoke test') - expect(status.options).toMatchObject({ - display: 'system', - }) - const replaced = await runGoal('ship the replacement target') - expect(replaced.result).toContain('Goal replaced.') - expect(replaced.result).toContain('Objective: ship the replacement target') - expect(replaced.result).toContain('Budget: 0 / unlimited tokens') + expect(replaced.result).toBe('Goal set: ship the replacement target') expect(replaced.options).toMatchObject({ display: 'system', shouldQuery: true, @@ -66,36 +54,14 @@ describe('/goal command', () => { 'ship the replacement target', ) - const paused = await runGoal('pause') - expect(paused.result).toContain('Goal: paused') - expect(paused.options).toMatchObject({ - display: 'system', - }) - - const resumed = await runGoal('resume') - expect(resumed.result).toContain('Goal: active') - expect(resumed.options).toMatchObject({ - display: 'system', - shouldQuery: true, - }) - expect(resumed.options?.metaMessages?.[0]).toContain( - 'ship the replacement target', - ) - - const completed = await runGoal('complete') - expect(completed.result).toBe('Goal marked complete.') - expect(completed.options).toMatchObject({ - display: 'system', - }) - const cleared = await runGoal('clear') - expect(cleared.result).toBe('Goal cleared.') + expect(cleared.result).toBe('Goal cleared: ship the replacement target') expect(cleared.options).toMatchObject({ display: 'system', }) const empty = await runGoal('') - expect(empty.result).toBe('No active goal.') + expect(empty.result).toBe('Usage: /goal | clear') expect(empty.options).toMatchObject({ display: 'system', }) @@ -104,9 +70,9 @@ describe('/goal command', () => { test('reports usage errors without querying the model', async () => { switchSession(`goal-command-${crypto.randomUUID()}` as SessionId) - const result = await runGoal('--tokens 2k') + const result = await runGoal('') - expect(result.result).toBe('Usage: /goal [--tokens ] ') + expect(result.result).toBe('Usage: /goal | clear') expect(result.options).toMatchObject({ display: 'system', }) @@ -116,7 +82,7 @@ describe('/goal command', () => { test('hydrates completed goal state from persisted slash command history', async () => { switchSession(`goal-command-${crypto.randomUUID()}` as SessionId) - const result = await runGoal('status', { + const result = await runGoal('clear', { messages: [ createCommandInputMessage([ '/goal', @@ -149,9 +115,6 @@ describe('/goal command', () => { ], }) - expect(result.result).toContain('Goal: complete') - expect(result.result).toContain('Objective: ship persisted goal') - expect(result.result).toContain('Budget: 1,234 / 2,000 tokens') - expect(result.result).toContain('Continuations: 2') + expect(result.result).toBe('Goal cleared: ship persisted goal') }) }) diff --git a/src/commands/goal/goal.tsx b/src/commands/goal/goal.tsx index 82a52cdc..189c1590 100644 --- a/src/commands/goal/goal.tsx +++ b/src/commands/goal/goal.tsx @@ -5,12 +5,10 @@ import type { LocalJSXCommandOnDone } from '../../types/command.js' import { buildGoalStartPrompt, clearThreadGoal, - formatGoalStatus, getThreadGoal, hydrateThreadGoalFromMessages, parseGoalCommand, setThreadGoal, - updateThreadGoalStatus, } from '../../goals/goalState.js' export async function call( @@ -24,49 +22,20 @@ export async function call( try { const parsed = parseGoalCommand(args) - if (parsed.type === 'status') { - onDone(formatGoalStatus(getCurrentGoal()), { display: 'system' }) - return null - } if (parsed.type === 'clear') { - getCurrentGoal() + const existing = getCurrentGoal() const cleared = clearThreadGoal(threadId) - onDone(cleared ? 'Goal cleared.' : 'No active goal.', { display: 'system' }) - return null - } - if (parsed.type === 'pause') { - getCurrentGoal() - const goal = updateThreadGoalStatus(threadId, 'paused') - onDone(goal ? formatGoalStatus(goal) : 'No active goal.', { - display: 'system', - }) - return null - } - if (parsed.type === 'resume') { - getCurrentGoal() - const goal = updateThreadGoalStatus(threadId, 'active') - onDone(goal ? formatGoalStatus(goal) : 'No goal to resume.', { - display: 'system', - shouldQuery: Boolean(goal), - metaMessages: goal ? [buildGoalStartPrompt(goal)] : [], - }) - return null - } - if (parsed.type === 'complete') { - getCurrentGoal() - const goal = updateThreadGoalStatus(threadId, 'complete') - onDone(goal ? 'Goal marked complete.' : 'No active goal.', { - display: 'system', - }) + onDone( + cleared && existing ? `Goal cleared: ${existing.objective}` : 'No active goal.', + { display: 'system' }, + ) return null } - const replaced = Boolean(getCurrentGoal()) const goal = setThreadGoal(threadId, { objective: parsed.objective, - tokenBudget: parsed.tokenBudget, }) - onDone(`${replaced ? 'Goal replaced.' : 'Goal created.'}\n${formatGoalStatus(goal)}`, { + onDone(`Goal set: ${goal.objective}`, { display: 'system', shouldQuery: true, metaMessages: [buildGoalStartPrompt(goal)], diff --git a/src/commands/goal/index.ts b/src/commands/goal/index.ts index 534df503..fbef31f0 100644 --- a/src/commands/goal/index.ts +++ b/src/commands/goal/index.ts @@ -4,8 +4,8 @@ const goal = { type: 'local-jsx', supportsNonInteractive: true, name: 'goal', - description: 'Set or manage a completion goal that keeps the session working until it is met', - argumentHint: '[status|clear|pause|resume|--tokens |]', + description: 'Set a completion goal that keeps the session working until it is met', + argumentHint: '[ | clear]', load: () => import('./goal.js'), } satisfies Command diff --git a/src/goals/goalState.test.ts b/src/goals/goalState.test.ts index 61b29015..4b09aef3 100644 --- a/src/goals/goalState.test.ts +++ b/src/goals/goalState.test.ts @@ -12,16 +12,17 @@ import { } from './goalState.js' describe('goalState', () => { - test('parses token budget before the goal objective', () => { + test('parses set and clear goal commands', () => { const parsed = parseGoalCommand( - '--tokens 250K migrate auth to the new API until tests pass', + 'migrate auth to the new API until tests pass', ) expect(parsed).toEqual({ type: 'set', objective: 'migrate auth to the new API until tests pass', - tokenBudget: 250_000, }) + expect(parseGoalCommand('clear')).toEqual({ type: 'clear' }) + expect(() => parseGoalCommand('')).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 e54dcd67..5a09209b 100644 --- a/src/goals/goalState.ts +++ b/src/goals/goalState.ts @@ -21,43 +21,16 @@ export type ThreadGoal = { } export type ParsedGoalCommand = - | { type: 'status' } | { type: 'clear' } - | { type: 'pause' } - | { type: 'resume' } - | { type: 'complete' } - | { type: 'set'; objective: string; tokenBudget?: number | null } + | { type: 'set'; objective: string } const goalsByThread = new Map() export function parseGoalCommand(args: string): ParsedGoalCommand { const trimmed = args.trim() - if (!trimmed || trimmed === 'status') return { type: 'status' } - if (['clear', 'stop', 'off', 'reset', 'none', 'cancel'].includes(trimmed)) { - return { type: 'clear' } - } - if (trimmed === 'pause') return { type: 'pause' } - if (trimmed === 'resume') return { type: 'resume' } - if (trimmed === 'complete') return { type: 'complete' } - - const parts = trimmed.split(/\s+/) - let tokenBudget: number | null | undefined - let objectiveStart = 0 - if (parts[0] === '--tokens') { - const rawBudget = parts[1] - if (!rawBudget) { - throw new Error('Usage: /goal --tokens ') - } - tokenBudget = parseTokenBudget(rawBudget) - objectiveStart = 2 - } - - const objective = parts.slice(objectiveStart).join(' ').trim() - if (!objective) { - throw new Error('Usage: /goal [--tokens ] ') - } - - return { type: 'set', objective, tokenBudget } + if (!trimmed) throw new Error('Usage: /goal | clear') + if (trimmed === 'clear') return { type: 'clear' } + return { type: 'set', objective: trimmed } } export function setThreadGoal( @@ -228,19 +201,6 @@ export function buildGoalContinuationPrompt( ].join('\n') } -function parseTokenBudget(raw: string): number { - const match = raw.trim().match(/^(\d+(?:\.\d+)?)([kKmM])?$/) - if (!match) throw new Error(`Invalid token budget: ${raw}`) - const value = Number(match[1]) - const suffix = match[2]?.toLowerCase() - const multiplier = suffix === 'm' ? 1_000_000 : suffix === 'k' ? 1_000 : 1 - const budget = Math.floor(value * multiplier) - if (!Number.isFinite(budget) || budget <= 0) { - throw new Error(`Invalid token budget: ${raw}`) - } - return budget -} - function formatElapsed(ms: number): string { const seconds = Math.floor(ms / 1000) const minutes = Math.floor(seconds / 60) @@ -257,13 +217,29 @@ function goalFromLocalCommandOutput( now: number, ): ThreadGoal | null { const trimmed = output.trim() - if (trimmed === 'Goal cleared.') { + if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) { return null } if (trimmed === 'No active goal.' || trimmed === 'No goal to resume.') return current if (trimmed === 'Goal marked complete.') { return current ? { ...current, status: 'complete', updatedAt: now } : null } + if (trimmed.startsWith('Goal set:')) { + const objective = trimmed.slice('Goal set:'.length).trim() + if (!objective) return current + return { + goalId: randomUUID(), + threadId, + objective, + status: 'active', + tokenBudget: null, + tokensUsed: 0, + continuationCount: 0, + lastReason: null, + createdAt: now, + updatedAt: now, + } + } const body = trimmed.startsWith('Goal created.\n') || trimmed.startsWith('Goal replaced.\n') ? trimmed.split(/\r?\n/).slice(1).join('\n').trim() @@ -317,8 +293,10 @@ function looksLikeGoalStatusOutput(output: string): boolean { return ( trimmed.startsWith('Goal created.\n') || trimmed.startsWith('Goal replaced.\n') || + trimmed.startsWith('Goal set:') || trimmed.startsWith('Goal: ') || trimmed === 'Goal cleared.' || + trimmed.startsWith('Goal cleared:') || trimmed === 'Goal marked complete.' ) } diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 431d0186..db685408 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -973,6 +973,43 @@ describe('WebSocket Chat Integration', () => { expect(titleIndex).toBeLessThan(completionIndex) }) + it('uses the /goal objective for the derived session title', async () => { + const sessionId = `title-goal-${crypto.randomUUID()}` + const messages: any[] = [] + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close() + reject(new Error('Timed out waiting for goal title')) + }, 5000) + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + messages.push(msg) + if (msg.type === 'connected') { + ws.send(JSON.stringify({ + type: 'user_message', + content: '/goal ship the desktop goal card', + })) + return + } + if (msg.type === 'session_title_updated') { + clearTimeout(timeout) + ws.close() + resolve() + } + } + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error(`WebSocket error for goal title session ${sessionId}`)) + } + }) + + const title = messages.find((msg) => msg.type === 'session_title_updated')?.title + expect(title).toBe('ship the desktop goal card') + }) + it('should start desktop sessions with disabled thinking when configured', async () => { const sessionId = `chat-thinking-disabled-${crypto.randomUUID()}` const originalStartSession = conversationService.startSession.bind(conversationService) diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index bc289ebc..10b4e18a 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -40,14 +40,7 @@ describe('WebSocket memory events', () => { }) describe('WebSocket goal command events', () => { - const goalStatusOutput = [ - 'Goal created.', - 'Goal: active', - 'Objective: ship the smoke test', - 'Budget: 0 / 2,000 tokens', - 'Elapsed: 0s', - 'Continuations: 0', - ].join('\n') + const goalStatusOutput = 'Goal set: ship the smoke test' const runGoalCommand = (sessionId: string, args: string, output: string, type: 'system' | 'user' = 'system') => { expect(translateCliMessage({ @@ -84,7 +77,7 @@ describe('WebSocket goal command events', () => { expect(translateCliMessage({ type: 'system', subtype: 'local_command', - content: '/goal\n--tokens 2k ship the smoke test', + content: '/goal\nship the smoke test', }, sessionId)).toEqual([]) expect(translateCliMessage({ @@ -104,42 +97,13 @@ describe('WebSocket goal command events', () => { action: 'created', status: 'active', objective: 'ship the smoke test', - budget: '0 / 2,000 tokens', - elapsed: '0s', - continuations: '0', message: goalStatusOutput, }, }, ]) }) - it('classifies /goal lifecycle subcommand output for the desktop client', () => { - const statusOutput = goalStatusOutput.split('\n').slice(1).join('\n') - - expect(runGoalCommand(`goal-status-${crypto.randomUUID()}`, 'status', statusOutput)).toEqual([ - expect.objectContaining({ - type: 'system_notification', - subtype: 'goal_event', - data: expect.objectContaining({ action: 'status', status: 'active' }), - }), - ]) - - expect(runGoalCommand(`goal-pause-${crypto.randomUUID()}`, 'pause', 'Goal: paused\nObjective: ship docs')).toEqual([ - expect.objectContaining({ - type: 'system_notification', - subtype: 'goal_event', - data: expect.objectContaining({ action: 'paused', status: 'paused' }), - }), - ]) - - expect(runGoalCommand(`goal-resume-${crypto.randomUUID()}`, 'resume', statusOutput)).toEqual([ - expect.objectContaining({ - type: 'system_notification', - subtype: 'goal_event', - data: expect.objectContaining({ action: 'resumed', status: 'active' }), - }), - ]) - + it('classifies /goal clear and completion output for the desktop client', () => { expect(runGoalCommand(`goal-complete-${crypto.randomUUID()}`, 'complete', 'Goal marked complete.')).toEqual([ expect.objectContaining({ type: 'system_notification', @@ -148,39 +112,25 @@ describe('WebSocket goal command events', () => { }), ]) - expect(runGoalCommand(`goal-clear-${crypto.randomUUID()}`, 'clear', 'Goal cleared.')).toEqual([ + expect(runGoalCommand(`goal-clear-${crypto.randomUUID()}`, 'clear', 'Goal cleared: ship docs')).toEqual([ expect.objectContaining({ type: 'system_notification', subtype: 'goal_event', - data: { action: 'cleared', message: 'Goal cleared.' }, + data: { action: 'cleared', message: 'Goal cleared: ship docs' }, }), ]) }) - it('marks replacement output distinctly for the desktop client', () => { - const output = [ - 'Goal replaced.', - 'Goal: active', - 'Objective: ship the replacement target', - 'Budget: 0 / unlimited tokens', - 'Elapsed: 0s', - 'Continuations: 0', - ].join('\n') + it('allows direct /goal local command output through the pre-turn mute gate', () => { + const shouldForward = createCurrentTurnLocalCommandForwarder( + parseSlashCommand('/goal ship the smoke test'), + ) - expect(runGoalCommand(`goal-replaced-${crypto.randomUUID()}`, 'ship the replacement target', output)).toEqual([ - expect.objectContaining({ - type: 'system_notification', - subtype: 'goal_event', - message: output, - data: expect.objectContaining({ - action: 'replaced', - status: 'active', - objective: 'ship the replacement target', - budget: '0 / unlimited tokens', - continuations: '0', - }), - }), - ]) + expect(shouldForward({ + type: 'system', + subtype: 'local_command_output', + content: 'Goal set: ship the smoke test', + })).toBe(true) }) it('keeps negative /goal command output visible as a goal message event', () => { @@ -235,7 +185,7 @@ describe('WebSocket goal command events', () => { expect(shouldForward({ type: 'system', subtype: 'local_command', - content: 'Goal created.\nGoal: active\nObjective: ship the smoke test', + content: 'Goal set: ship the smoke test', })).toBe(true) expect(shouldForward({ type: 'system', diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 33b06dd6..6989ea99 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -294,12 +294,15 @@ async function handleUserMessage( } sessionTitleState.set(sessionId, titleState) } - titleState.userMessageCount++ - titleState.allUserMessages.push(message.content) - if (titleState.userMessageCount === 1) { - titleState.firstUserMessage = message.content + const titleInput = getTitleInputForUserMessage(message.content, desktopSlashCommand) + if (titleInput) { + titleState.userMessageCount++ + titleState.allUserMessages.push(titleInput) + if (titleState.userMessageCount === 1) { + titleState.firstUserMessage = titleInput + } + triggerTitleGeneration(ws, sessionId) } - triggerTitleGeneration(ws, sessionId) // 启动 CLI 子进程(如果还没有) try { @@ -1331,6 +1334,17 @@ function getDesktopSlashCommand(content: string): ReturnType, +): string | null { + if (command?.commandName !== 'goal') return content + + const args = command.args.trim() + if (!args || args === 'clear') return null + return args +} + export function createCurrentTurnLocalCommandForwarder( command: ReturnType, ): (cliMsg: any) => boolean { @@ -1341,6 +1355,16 @@ export function createCurrentTurnLocalCommandForwarder( awaitingCurrentTurnLocalCommandOutput = true return true } + if (command?.commandName === 'goal' && isLocalCommandOutputMessage(cliMsg)) { + const output = extractLocalCommandOutput( + cliMsg.content ?? cliMsg.message, + { allowUntagged: cliMsg.subtype === 'local_command_output' }, + ) + if (output && looksLikeGoalCommandOutput(output)) { + awaitingCurrentTurnLocalCommandOutput = false + return true + } + } if ( awaitingCurrentTurnLocalCommandOutput && isLocalCommandOutputMessage(cliMsg) @@ -1458,7 +1482,7 @@ function extractGoalEvent( const trimmed = output.trim() if (!trimmed) return null - if (trimmed === 'Goal cleared.') { + if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) { return { action: 'cleared', message: trimmed } } if (trimmed === 'Goal marked complete.') { @@ -1468,6 +1492,16 @@ function extractGoalEvent( return { action: 'message', message: trimmed } } + if (trimmed.startsWith('Goal set:')) { + const objective = trimmed.slice('Goal set:'.length).trim() + return { + action: 'created', + status: 'active', + objective: objective || undefined, + message: trimmed, + } + } + const actionPrefix = trimmed.startsWith('Goal replaced.\n') ? 'replaced' : trimmed.startsWith('Goal created.\n') @@ -1501,6 +1535,21 @@ function extractGoalEvent( } } +function looksLikeGoalCommandOutput(output: string): boolean { + const trimmed = output.trim() + return ( + trimmed.startsWith('Goal set:') || + trimmed.startsWith('Goal cleared:') || + trimmed === 'Goal cleared.' || + trimmed === 'Goal marked complete.' || + trimmed === 'No active goal.' || + trimmed === 'No goal to resume.' || + trimmed.startsWith('Goal created.\n') || + trimmed.startsWith('Goal replaced.\n') || + trimmed.startsWith('Goal: ') + ) +} + function resolveGoalEventAction( command: { name: string; args: string } | undefined, status: string,