= {
'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]*?)${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
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,