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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 03:28:48 +08:00
parent 291dd3e07d
commit 606f3c0dc2
17 changed files with 239 additions and 234 deletions

View File

@ -161,7 +161,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
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(<MessageList />)
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()
})

View File

@ -68,8 +68,8 @@ describe('composerUtils', () => {
expect.arrayContaining([
{
name: 'goal',
description: 'Create or manage an autonomous completion goal',
argumentHint: '<objective>|status|pause|resume|clear|complete',
description: 'Set a completion goal',
argumentHint: '[<condition> | clear]',
},
]),
)

View File

@ -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: '<objective>|status|pause|resume|clear|complete',
description: 'Set a completion goal',
argumentHint: '[<condition> | clear]',
},
{ name: 'review', description: 'Review code changes' },
{ name: 'commit', description: 'Create a git commit' },

View File

@ -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',

View File

@ -890,8 +890,8 @@ export const zh: Record<TranslationKey, string> = {
'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': '目标已恢复',

View File

@ -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'
}
>
<div className="flex min-w-0 items-center gap-3 rounded-lg border border-[var(--color-goal-border)] bg-[var(--color-goal-surface)] px-3 py-2.5">

View File

@ -256,22 +256,13 @@ describe('chatStore history mapping', () => {
id: 'goal-command',
type: 'system',
timestamp: '2026-04-06T00:00:00.000Z',
content: '<command-name>/goal</command-name>\n<command-args>--tokens 2k ship the smoke test</command-args>',
content: '<command-name>/goal</command-name>\n<command-args>ship the smoke test</command-args>',
},
{
id: 'goal-output',
type: 'system',
timestamp: '2026-04-06T00:00:01.000Z',
content: [
'<local-command-stdout>',
'Goal created.',
'Goal: active',
'Objective: ship the smoke test',
'Budget: 0 / 2,000 tokens',
'Elapsed: 0s',
'Continuations: 0',
'</local-command-stdout>',
].join('\n'),
content: '<local-command-stdout>Goal set: ship the smoke test</local-command-stdout>',
},
]
@ -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',
},
])
})

View File

@ -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 = /^<task-notification>\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<GoalEventAction>([
'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'

View File

@ -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<SDKMessage, void, unknown> {
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]*?)</${escaped}>`, '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

View File

@ -31,14 +31,11 @@ async function runGoal(args: string, context: Partial<LocalJSXCommandContext> =
}
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', () => {
'<objective>ship the smoke test</objective>',
)
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', () => {
'<objective>ship the replacement target</objective>',
)
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(
'<objective>ship the replacement target</objective>',
)
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 <condition> | 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 <budget>] <objective>')
expect(result.result).toBe('Usage: /goal <condition> | 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([
'<command-name>/goal</command-name>',
@ -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')
})
})

View File

@ -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)],

View File

@ -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 <budget> <objective>|<objective>]',
description: 'Set a completion goal that keeps the session working until it is met',
argumentHint: '[<condition> | clear]',
load: () => import('./goal.js'),
} satisfies Command

View File

@ -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 <condition> | clear')
})
test('stores and formats the current thread goal', () => {

View File

@ -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<string, ThreadGoal>()
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 <budget> <objective>')
}
tokenBudget = parseTokenBudget(rawBudget)
objectiveStart = 2
}
const objective = parts.slice(objectiveStart).join(' ').trim()
if (!objective) {
throw new Error('Usage: /goal [--tokens <budget>] <objective>')
}
return { type: 'set', objective, tokenBudget }
if (!trimmed) throw new Error('Usage: /goal <condition> | 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.'
)
}

View File

@ -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<void>((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)

View File

@ -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: '<command-name>/goal</command-name>\n<command-args>--tokens 2k ship the smoke test</command-args>',
content: '<command-name>/goal</command-name>\n<command-args>ship the smoke test</command-args>',
}, 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: '<local-command-stdout>Goal set: ship the smoke test</local-command-stdout>',
})).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: '<local-command-stdout>Goal created.\nGoal: active\nObjective: ship the smoke test</local-command-stdout>',
content: '<local-command-stdout>Goal set: ship the smoke test</local-command-stdout>',
})).toBe(true)
expect(shouldForward({
type: 'system',

View File

@ -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<typeof parseSlashCo
return parsed
}
function getTitleInputForUserMessage(
content: string,
command: ReturnType<typeof parseSlashCommand>,
): 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<typeof parseSlashCommand>,
): (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,