mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
291dd3e07d
commit
606f3c0dc2
@ -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()
|
||||
})
|
||||
|
||||
@ -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]',
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '目标已恢复',
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)],
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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.'
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user