fix: make goal completion durable through stop hooks

The goal command now uses a session Stop hook as the durable completion mechanism and removes the separate evaluator path. Completion output is persisted as local command output so CLI streams, transcripts, and desktop history all see the same completed state.

Desktop keeps active goals in a compact header strip and lets completed goals render in the conversation flow, which avoids the old duplicate pinned card behavior while preserving visibility for running goals.

Constraint: /goal state has to survive transcript replay and desktop session restore.
Rejected: Keep the legacy evaluator alongside Stop hooks | duplicate completion paths made desktop state drift and hid the final card.
Confidence: high
Scope-risk: moderate
Directive: Keep goal completion events on the local_command transcript path unless desktop history restore is changed at the same time.
Tested: bun test src/goals/goalState.test.ts src/query/stopHooks.test.ts src/commands/goal/goal.test.tsx src/commands/headless.test.ts src/server/__tests__/ws-memory-events.test.ts src/server/__tests__/sessions.test.ts
Tested: bun run check:server
Tested: bun run check:desktop
Tested: real /tmp /goal CLI run 98fb4bb2-52da-4850-bc8e-e5e1cc0e4c51 with deepseek-v4-pro and WebUI screenshot verification
Not-tested: Remote release workflow
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 13:06:15 +08:00
parent 8ee4bf682a
commit 71ce980f1c
17 changed files with 527 additions and 1172 deletions

View File

@ -159,7 +159,7 @@ describe('Content-only pages render without errors', () => {
expect(screen.queryByText('/internal-only')).not.toBeInTheDocument()
})
it('EmptySession does not expose the paused /goal command', async () => {
it('EmptySession shows /goal as one command with argument hints, not pseudo subcommands', async () => {
vi.mocked(skillsApi.list).mockResolvedValueOnce({ skills: [] })
render(<EmptySession />)
@ -168,12 +168,9 @@ describe('Content-only pages render without errors', () => {
target: { value: '/goal', selectionStart: 5 },
})
await waitFor(() => {
expect(skillsApi.list).toHaveBeenCalled()
})
expect(screen.queryByText('/goal', { selector: 'span' })).not.toBeInTheDocument()
expect(screen.queryByText('[<condition> | clear]')).not.toBeInTheDocument()
expect(screen.queryByText('Set a completion goal')).not.toBeInTheDocument()
expect(await screen.findAllByText('/goal')).toHaveLength(2)
expect(screen.getByText('[<condition> | clear]')).toBeInTheDocument()
expect(screen.getByText('Set a completion goal')).toBeInTheDocument()
expect(screen.queryByText('/goal status')).not.toBeInTheDocument()
expect(screen.queryByText('/goal --tokens')).not.toBeInTheDocument()
})
@ -628,7 +625,7 @@ describe('Content-only pages render without errors', () => {
expect(screen.getByText('Slash commands')).toBeInTheDocument()
expect(screen.getByText('/clear')).toBeInTheDocument()
expect(screen.getByText('/cost')).toBeInTheDocument()
expect(screen.getByText('13 more commands available. Type / to search the full command list.')).toBeInTheDocument()
expect(screen.getByText('14 more commands available. Type / to search the full command list.')).toBeInTheDocument()
resetPageStores()
})

View File

@ -81,11 +81,14 @@ describe('composerUtils', () => {
)
})
it('does not expose paused /goal fallback commands', () => {
it('keeps /goal as a single command with argument hints instead of pseudo subcommands', () => {
const commands = filterSlashCommands(mergeSlashCommands([]), 'goal')
expect(commands.map((command) => command.name)).toEqual([])
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal')
expect(commands.map((command) => command.name)).toEqual(['goal'])
expect(commands[0]).toMatchObject({
description: 'Set a completion goal',
argumentHint: '[<condition> | clear]',
})
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal status')
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal --tokens')
})

View File

@ -24,6 +24,11 @@ export const FALLBACK_SLASH_COMMANDS = [
...SETTINGS_SLASH_COMMANDS.map(({ name, description }) => ({ name, description })),
{ name: 'compact', description: 'Compact conversation context' },
{ name: 'clear', description: 'Clear conversation history' },
{
name: 'goal',
description: 'Set a completion goal',
argumentHint: '[<condition> | clear]',
},
{ name: 'review', description: 'Review code changes' },
{ name: 'commit', description: 'Create a git commit' },
{ name: 'pr', description: 'Create a pull request' },

View File

@ -135,7 +135,7 @@ describe('ActiveSession task polling', () => {
expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default')
})
it('does not duplicate the current goal as a page-level status panel', () => {
it('renders the current goal as a lightweight header strip without a page-level panel', () => {
const sessionId = 'goal-visible-session'
useSessionStore.setState({
@ -200,6 +200,72 @@ describe('ActiveSession task polling', () => {
render(<ActiveSession />)
expect(screen.queryByTestId('active-goal-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('active-goal-strip')).toBeInTheDocument()
expect(screen.getByTestId('active-goal-strip')).toHaveTextContent('ship the smoke test')
expect(screen.getByTestId('message-list')).toBeInTheDocument()
})
it('does not keep a completed goal pinned in the header', () => {
const sessionId = 'goal-completed-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Goal Completed Session',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: '2026-05-07T00:00:00.000Z',
messageCount: 3,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Goal Completed Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{
id: 'goal-completed-event',
type: 'goal_event',
action: 'completed',
status: 'complete',
message: 'Goal marked complete.',
timestamp: 3,
}],
activeGoal: {
action: 'completed',
status: 'complete',
message: 'Goal marked complete.',
updatedAt: 3,
},
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
expect(screen.queryByTestId('active-goal-strip')).not.toBeInTheDocument()
expect(screen.getByTestId('message-list')).toBeInTheDocument()
})

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Target } from 'lucide-react'
import {
SCHEDULED_TAB_ID,
SETTINGS_TAB_ID,
@ -26,6 +27,7 @@ import { WorkspacePanel } from '../components/workspace/WorkspacePanel'
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
import { TerminalSettings } from './TerminalSettings'
import type { SessionListItem } from '../types/session'
import type { ActiveGoalState } from '../types/chat'
import { useMobileViewport } from '../hooks/useMobileViewport'
import { isTauriRuntime } from '../lib/desktopRuntime'
@ -50,6 +52,60 @@ function getSessionTerminalCwd(session: SessionListItem | undefined) {
return session.projectPath || undefined
}
function ActiveGoalStrip({
goal,
isRunning,
compact,
}: {
goal: ActiveGoalState | null | undefined
isRunning: boolean
compact: boolean
}) {
const t = useTranslation()
if (!goal || goal.action === 'completed') return null
const objective = goal.objective ?? goal.message
if (!objective) return null
const statusLabel = isRunning
? t('chat.activeGoal.running')
: goal.status === 'paused'
? t('chat.activeGoal.paused')
: t('chat.activeGoal.active')
const meta = [
goal.budget ? t('chat.activeGoal.budget', { value: goal.budget }) : null,
goal.elapsed ? t('chat.activeGoal.elapsed', { value: goal.elapsed }) : null,
goal.continuations ? t('chat.activeGoal.continuations', { value: goal.continuations }) : null,
].filter((value): value is string => value !== null)
return (
<div
data-testid="active-goal-strip"
className={[
'mt-2 flex max-w-full items-center gap-2 rounded-[8px] border border-[var(--color-memory-border)] bg-[var(--color-memory-surface)] px-2.5 py-1.5',
compact ? 'text-[11px]' : 'text-[12px]',
].join(' ')}
>
<Target size={compact ? 13 : 14} className="shrink-0 text-[var(--color-memory-accent)]" strokeWidth={2.25} aria-hidden="true" />
<span className="shrink-0 font-semibold text-[var(--color-text-primary)]">
{t('chat.activeGoal.title')}
</span>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-memory-accent)]" aria-hidden="true" />
<span className="shrink-0 text-[var(--color-text-tertiary)]">{statusLabel}</span>
<span className="min-w-0 flex-1 truncate font-medium text-[var(--color-text-primary)]" title={objective}>
{objective}
</span>
{meta.length > 0 ? (
<span className="hidden shrink-0 items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)] lg:flex">
{meta.map((item) => (
<span key={item} className="max-w-[140px] truncate">{item}</span>
))}
</span>
) : null}
</div>
)
}
function WorkspaceResizeHandle() {
const t = useTranslation()
const width = useWorkspacePanelStore((state) => state.width)
@ -266,6 +322,7 @@ export function ActiveSession() {
const t = useTranslation()
const messages = sessionState?.messages ?? []
const streamingText = sessionState?.streamingText ?? ''
const activeGoal = sessionState?.activeGoal ?? null
const isEmpty = messages.length === 0 && !streamingText && (session?.messageCount ?? 0) === 0
const isActive = chatState !== 'idle' ||
@ -421,6 +478,11 @@ export function ActiveSession() {
</span>
</div>
)}
<ActiveGoalStrip
goal={activeGoal}
isRunning={isActive}
compact={showWorkspacePanel}
/>
</div>
</div>
)}

View File

@ -6,6 +6,7 @@ import {
getSessionId,
isSessionPersistenceDisabled,
} from 'src/bootstrap/state.js'
import { isGoalLocalCommandOutputContent } from './goals/goalState.js'
import type {
PermissionMode,
SDKCompactBoundaryMessage,
@ -934,6 +935,16 @@ export class QueryEngine {
break
}
this.mutableMessages.push(message)
if (
message.subtype === 'local_command' &&
isGoalLocalCommandOutputContent(message.content)
) {
if (persistSession) {
messages.push(message)
await recordTranscript(messages)
}
yield toSDKLocalCommandOutputMessage(message)
}
// Yield compact boundary messages to SDK
if (
message.subtype === 'compact_boundary' &&

View File

@ -6,6 +6,7 @@ import btw from './commands/btw/index.js'
import goodClaude from './commands/good-claude/index.js'
import issue from './commands/issue/index.js'
import feedback from './commands/feedback/index.js'
import goal from './commands/goal/index.js'
import clear from './commands/clear/index.js'
import color from './commands/color/index.js'
import commit from './commands/commit.js'
@ -303,6 +304,7 @@ const COMMANDS = memoize((): Command[] => [
tag,
theme,
feedback,
goal,
review,
ultrareview,
rewind,

View File

@ -1,10 +1,14 @@
import { describe, expect, test } from 'bun:test'
import { switchSession } from '../../bootstrap/state.js'
import { afterEach, describe, expect, test } from 'bun:test'
import { setIsInteractive, switchSession } from '../../bootstrap/state.js'
import type { SessionId } from '../../types/ids.js'
import type { LocalJSXCommandContext } from '../../types/command.js'
import { createCommandInputMessage } from '../../utils/messages.js'
import { call } from './goal.js'
afterEach(() => {
setIsInteractive(true)
})
async function runGoal(args: string, context: Partial<LocalJSXCommandContext> = {}) {
const calls: Array<{
result?: string
@ -21,6 +25,7 @@ async function runGoal(args: string, context: Partial<LocalJSXCommandContext> =
},
{
messages: [],
setAppState: updater => updater({ sessionHooks: new Map() } as any),
...context,
} as LocalJSXCommandContext,
args,
@ -32,6 +37,7 @@ async function runGoal(args: string, context: Partial<LocalJSXCommandContext> =
describe('/goal command', () => {
test('sets and clears a goal in one CLI session', async () => {
setIsInteractive(false)
switchSession(`goal-command-${crypto.randomUUID()}` as SessionId)
const created = await runGoal('ship the smoke test')
@ -40,9 +46,7 @@ describe('/goal command', () => {
display: 'system',
shouldQuery: true,
})
expect(created.options?.metaMessages?.[0]).toContain(
'<objective>ship the smoke test</objective>',
)
expect(created.options?.metaMessages).toBeUndefined()
const replaced = await runGoal('ship the replacement target')
expect(replaced.result).toBe('Goal set: ship the replacement target')
@ -50,9 +54,7 @@ describe('/goal command', () => {
display: 'system',
shouldQuery: true,
})
expect(replaced.options?.metaMessages?.[0]).toContain(
'<objective>ship the replacement target</objective>',
)
expect(replaced.options?.metaMessages).toBeUndefined()
const cleared = await runGoal('clear')
expect(cleared.result).toBe('Goal cleared: ship the replacement target')
@ -68,6 +70,7 @@ describe('/goal command', () => {
})
test('reports usage errors without querying the model', async () => {
setIsInteractive(false)
switchSession(`goal-command-${crypto.randomUUID()}` as SessionId)
const result = await runGoal('')
@ -80,6 +83,7 @@ describe('/goal command', () => {
})
test('does not treat removed subcommands as replacement goals', async () => {
setIsInteractive(false)
switchSession(`goal-command-${crypto.randomUUID()}` as SessionId)
const created = await runGoal('ship the smoke test')
@ -93,7 +97,8 @@ describe('/goal command', () => {
expect(cleared.result).toBe('Goal cleared: ship the smoke test')
})
test('hydrates completed goal state from persisted slash command history', async () => {
test('clears active goal state restored from persisted slash command history', async () => {
setIsInteractive(false)
switchSession(`goal-command-${crypto.randomUUID()}` as SessionId)
const result = await runGoal('clear', {
@ -107,11 +112,6 @@ describe('/goal command', () => {
'Goal set: ship persisted goal',
'</local-command-stdout>',
].join('\n')),
createCommandInputMessage([
'<local-command-stdout>',
'Goal marked complete.',
'</local-command-stdout>',
].join('\n')),
],
})

View File

@ -3,12 +3,12 @@ import { getSessionId } from '../../bootstrap/state.js'
import type { LocalJSXCommandContext } from '../../commands.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import {
buildGoalStartPrompt,
clearThreadGoal,
getThreadGoal,
hydrateThreadGoalFromMessages,
clearThreadGoalHook,
ensureThreadGoalHookFromTranscript,
getGoalHookUnavailableReason,
parseGoalCommand,
setThreadGoal,
setThreadGoalHook,
} from '../../goals/goalState.js'
export async function call(
@ -17,28 +17,31 @@ export async function call(
args: string,
): Promise<React.ReactNode> {
const threadId = getSessionId()
const getCurrentGoal = () =>
getThreadGoal(threadId) ?? hydrateThreadGoalFromMessages(threadId, _context.messages)
try {
const parsed = parseGoalCommand(args)
if (parsed.type === 'clear') {
const existing = getCurrentGoal()
const cleared = clearThreadGoal(threadId)
const existing =
getThreadGoal(threadId) ??
ensureThreadGoalHookFromTranscript(_context, threadId, _context.messages)
const cleared = clearThreadGoalHook(_context, threadId)
onDone(
cleared && existing ? `Goal cleared: ${existing.objective}` : 'No active goal.',
cleared || existing ? `Goal cleared: ${(cleared ?? existing)!.objective}` : 'No active goal.',
{ display: 'system' },
)
return null
}
const goal = setThreadGoal(threadId, {
objective: parsed.objective,
})
const unavailableReason = getGoalHookUnavailableReason()
if (unavailableReason) {
onDone(unavailableReason, { display: 'system' })
return null
}
const goal = setThreadGoalHook(_context, threadId, parsed.objective)
onDone(`Goal set: ${goal.objective}`, {
display: 'system',
shouldQuery: true,
metaMessages: [buildGoalStartPrompt(goal)],
})
return null
} catch (error) {

View File

@ -3,13 +3,13 @@ import type { Command } from '../types/command.js'
import { filterCommandsForHeadlessMode } from './headless.js'
describe('filterCommandsForHeadlessMode', () => {
test('keeps non-interactive commands without exposing local-jsx UI commands', () => {
test('keeps /goal without exposing other local-jsx UI commands', () => {
const commands = [
{
type: 'local-jsx',
supportsNonInteractive: true,
name: 'cost',
description: 'Show cost',
name: 'goal',
description: 'Set a goal',
load: async () => ({ call: async () => null }),
},
{
@ -40,7 +40,7 @@ describe('filterCommandsForHeadlessMode', () => {
] satisfies Command[]
expect(filterCommandsForHeadlessMode(commands).map(command => command.name)).toEqual([
'cost',
'goal',
'review',
])
})

View File

@ -1,320 +0,0 @@
import { describe, expect, test } from 'bun:test'
import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { Message } from '../types/message.js'
import { createAssistantMessage, createUserMessage } from '../utils/messages.js'
import { evaluateThreadGoalAfterTurn } from './goalEvaluator.js'
import { getThreadGoal, setThreadGoal } from './goalState.js'
describe('goalEvaluator', () => {
test('continues an active goal when the evaluator says it is incomplete', async () => {
const goal = setThreadGoal('thread-eval-continue', {
objective: 'tests pass',
now: 1_000,
})
const messages: Message[] = [
createUserMessage({ content: 'run the tests' }),
createAssistantMessage({
content: [{ type: 'text', text: 'I changed code but did not test it.' }],
}),
]
const decision = await evaluateThreadGoalAfterTurn({
threadId: 'thread-eval-continue',
messages,
assistantMessages: [],
signal: new AbortController().signal,
now: 2_000,
evaluate: async () => ({
complete: false,
reason: 'Tests have not been run.',
}),
})
expect(decision.action).toBe('continue')
expect(decision.goal.goalId).toBe(goal.goalId)
expect(decision.prompt).toContain('Tests have not been run.')
expect(getThreadGoal('thread-eval-continue')?.status).toBe('active')
})
test('does not let hidden goal prompts satisfy the evaluator transcript', async () => {
setThreadGoal('thread-eval-meta', {
objective: 'finish after MAGIC_DONE appears',
now: 1_000,
})
let capturedTranscript = ''
const decision = await evaluateThreadGoalAfterTurn({
threadId: 'thread-eval-meta',
messages: [
createUserMessage({
content: 'The hidden instruction says MAGIC_DONE is the target.',
isMeta: true,
}),
createAssistantMessage({
content: [{ type: 'text', text: 'Still working.' }],
}),
],
assistantMessages: [],
signal: new AbortController().signal,
now: 2_000,
evaluate: async ({ transcript }) => {
capturedTranscript = transcript
return {
complete: false,
reason: 'No completion evidence.',
}
},
})
expect(decision.action).toBe('continue')
expect(capturedTranscript).not.toContain('MAGIC_DONE')
expect(capturedTranscript).toContain('Assistant: Still working.')
})
test('does not let hidden assistant thinking satisfy the evaluator transcript', async () => {
setThreadGoal('thread-eval-thinking', {
objective: 'finish after LOOP_DONE appears',
now: 1_000,
})
let capturedTranscript = ''
await evaluateThreadGoalAfterTurn({
threadId: 'thread-eval-thinking',
messages: [
createAssistantMessage({
content: [
{
type: 'thinking',
thinking: 'I will output LOOP_DONE next turn.',
signature: 'test',
} as unknown as BetaContentBlock,
{ type: 'text', text: 'STEP_ONE' },
],
}),
],
assistantMessages: [],
signal: new AbortController().signal,
now: 2_000,
evaluate: async ({ transcript }) => {
capturedTranscript = transcript
return {
complete: false,
reason: 'No visible completion evidence.',
}
},
})
expect(capturedTranscript).not.toContain('LOOP_DONE')
expect(capturedTranscript).toContain('Assistant: STEP_ONE')
})
test('marks an active goal complete when the evaluator says it is complete', async () => {
setThreadGoal('thread-eval-complete', {
objective: 'tests pass',
now: 1_000,
})
const decision = await evaluateThreadGoalAfterTurn({
threadId: 'thread-eval-complete',
messages: [createUserMessage({ content: 'bun test passed' })],
assistantMessages: [],
signal: new AbortController().signal,
now: 3_000,
evaluate: async () => ({
complete: true,
reason: 'The transcript shows the tests passed.',
}),
})
expect(decision.action).toBe('complete')
expect(decision.reason).toBe('The transcript shows the tests passed.')
expect(getThreadGoal('thread-eval-complete')?.status).toBe('complete')
expect(getThreadGoal('thread-eval-complete')?.lastReason).toBe(
'The transcript shows the tests passed.',
)
})
test('marks complete from finished task evidence without waiting for the evaluator', async () => {
setThreadGoal('thread-eval-complete-tasks', {
objective: 'build a todo app and review it',
now: 1_000,
})
const decision = await evaluateThreadGoalAfterTurn({
threadId: 'thread-eval-complete-tasks',
messages: [
createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'task-create-1',
content: 'Task #1 created successfully: Build app',
} as unknown as BetaContentBlock,
],
}),
createAssistantMessage({
content: [
{
type: 'tool_use',
id: 'task-update-1',
name: 'TaskUpdate',
input: { taskId: '1', status: 'completed' },
} as unknown as BetaContentBlock,
],
}),
],
assistantMessages: [
createAssistantMessage({
content: [
{
type: 'text',
text: '## 完成总结\n\n已成功创建 Todo 应用。TypeScript 编译、ESLint PASS (0 errors)、Vite 生产构建均通过。',
},
],
}),
],
signal: new AbortController().signal,
now: 3_000,
evaluate: async () => {
throw new Error('completion evidence should not call evaluator')
},
})
expect(decision.action).toBe('complete')
expect(getThreadGoal('thread-eval-complete-tasks')?.status).toBe('complete')
})
test('continues instead of hanging when the evaluator times out', async () => {
setThreadGoal('thread-eval-timeout', {
objective: 'finish after external proof',
now: 1_000,
})
const previous = process.env.CLAUDE_CODE_GOAL_EVALUATOR_TIMEOUT_MS
process.env.CLAUDE_CODE_GOAL_EVALUATOR_TIMEOUT_MS = '5'
try {
const decision = await evaluateThreadGoalAfterTurn({
threadId: 'thread-eval-timeout',
messages: [
createAssistantMessage({
content: [{ type: 'text', text: 'Still checking.' }],
}),
],
assistantMessages: [],
signal: new AbortController().signal,
now: 3_000,
evaluate: async ({ signal }) =>
new Promise((resolve, reject) => {
signal.addEventListener('abort', () =>
reject(new Error('aborted by timeout')),
)
}),
})
expect(decision.action).toBe('continue')
if (decision.action === 'continue') {
expect(decision.reason).toContain('timed out')
}
} finally {
if (previous === undefined) {
delete process.env.CLAUDE_CODE_GOAL_EVALUATOR_TIMEOUT_MS
} else {
process.env.CLAUDE_CODE_GOAL_EVALUATOR_TIMEOUT_MS = previous
}
}
})
test('continues an active goal before evaluating when tasks are still incomplete', async () => {
setThreadGoal('thread-eval-open-task', {
objective: 'finish all task-list work',
now: 1_000,
})
let evaluatorCalled = false
const decision = await evaluateThreadGoalAfterTurn({
threadId: 'thread-eval-open-task',
messages: [
createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'task-create-3',
content: 'Task #3 created successfully: Perform code review',
} as unknown as BetaContentBlock,
],
}),
createAssistantMessage({
content: [
{
type: 'tool_use',
id: 'task-update-3',
name: 'TaskUpdate',
input: { taskId: '3', status: 'in_progress' },
} as unknown as BetaContentBlock,
],
}),
],
assistantMessages: [
createAssistantMessage({
content: [{ type: 'text', text: 'The implementation is complete.' }],
}),
],
signal: new AbortController().signal,
now: 3_000,
evaluate: async () => {
evaluatorCalled = true
return {
complete: true,
reason: 'The final answer claims the work is done.',
}
},
})
expect(decision.action).toBe('continue')
expect(evaluatorCalled).toBe(false)
if (decision.action === 'continue') {
expect(decision.reason).toContain('Task #3 (Perform code review) is in_progress')
expect(decision.prompt).toContain('The task list is not complete yet')
}
expect(getThreadGoal('thread-eval-open-task')?.status).toBe('active')
})
test('hydrates an active goal from persisted slash command history before continuing', async () => {
const threadId = 'thread-eval-hydrate'
const decision = await evaluateThreadGoalAfterTurn({
threadId,
messages: [
createUserMessage({
content: [
'<command-name>/goal</command-name>',
'<command-args>ship persisted goal</command-args>',
].join('\n'),
}),
createUserMessage({
content: [
'<local-command-stdout>',
'Goal set: ship persisted goal',
'</local-command-stdout>',
].join('\n'),
}),
createAssistantMessage({
content: [{ type: 'text', text: 'Still need to run verification.' }],
}),
],
assistantMessages: [],
signal: new AbortController().signal,
now: 120_000,
evaluate: async ({ goal }) => ({
complete: false,
reason: `${goal.objective} is not verified.`,
}),
})
expect(decision.action).toBe('continue')
const restored = getThreadGoal(threadId)
expect(restored?.objective).toBe('ship persisted goal')
expect(restored?.tokensUsed).toBe(0)
expect(restored?.tokenBudget).toBeNull()
expect(restored?.continuationCount).toBe(1)
})
})

View File

@ -1,485 +0,0 @@
import type {
BetaMessage,
BetaContentBlock,
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { QuerySource } from '../constants/querySource.js'
import type { AssistantMessage, Message } from '../types/message.js'
import { extractTextContent } from '../utils/messages.js'
import { createCombinedAbortSignal } from '../utils/combinedAbortSignal.js'
import { getSmallFastModel } from '../utils/model/model.js'
import { safeParseJSON } from '../utils/json.js'
import { sideQuery } from '../utils/sideQuery.js'
import {
accountThreadGoalUsage,
buildGoalContinuationPrompt,
getThreadGoal,
hydrateThreadGoalFromMessages,
incrementThreadGoalContinuation,
markThreadGoalComplete,
updateThreadGoalStatus,
type ThreadGoal,
} from './goalState.js'
export type GoalEvaluation = {
complete: boolean
reason: string
}
export type GoalTurnDecision =
| { action: 'none' }
| { action: 'continue'; goal: ThreadGoal; prompt: string; reason: string }
| { action: 'complete'; goal: ThreadGoal; reason: string }
| { action: 'budget_limited'; goal: ThreadGoal }
type EvaluateFn = (input: {
goal: ThreadGoal
transcript: string
signal: AbortSignal
querySource?: QuerySource
}) => Promise<GoalEvaluation>
const DEFAULT_MAX_CONTINUATIONS = 500
const DEFAULT_EVALUATOR_TIMEOUT_MS = 45_000
export async function evaluateThreadGoalAfterTurn(input: {
threadId: string
messages: Message[]
assistantMessages: AssistantMessage[]
signal: AbortSignal
now?: number
querySource?: QuerySource
evaluate?: EvaluateFn
}): Promise<GoalTurnDecision> {
const now = input.now ?? Date.now()
const current =
getThreadGoal(input.threadId) ??
hydrateThreadGoalFromMessages(input.threadId, input.messages, now)
if (!current || current.status !== 'active') return { action: 'none' }
const tokens = input.assistantMessages.reduce(
(sum, msg) =>
sum +
(msg.message.usage?.input_tokens ?? 0) +
(msg.message.usage?.output_tokens ?? 0),
0,
)
const accounted = accountThreadGoalUsage(input.threadId, tokens, now) ?? current
if (
accounted.tokenBudget !== null &&
accounted.tokensUsed >= accounted.tokenBudget
) {
const limited =
updateThreadGoalStatus(input.threadId, 'budget_limited', now) ?? accounted
return { action: 'budget_limited', goal: limited }
}
if (accounted.continuationCount >= getMaxContinuations()) {
const limited =
updateThreadGoalStatus(input.threadId, 'budget_limited', now) ?? accounted
return { action: 'budget_limited', goal: limited }
}
const taskState = summarizeTaskState([
...input.messages,
...input.assistantMessages,
])
if (taskState.incomplete.length > 0) {
const reason = formatIncompleteTaskReason(taskState.incomplete)
const continued =
incrementThreadGoalContinuation(input.threadId, {
reason,
now,
}) ?? accounted
return {
action: 'continue',
goal: continued,
reason,
prompt: buildGoalContinuationPrompt(continued, reason),
}
}
const localCompletionReason = inferCompletionFromTaskEvidence(
[...input.messages, ...input.assistantMessages],
taskState,
)
if (localCompletionReason) {
const completed =
markThreadGoalComplete(input.threadId, {
reason: localCompletionReason,
now,
}) ?? accounted
return {
action: 'complete',
goal: completed,
reason: localCompletionReason,
}
}
const transcript = formatTranscript([
...input.messages,
...input.assistantMessages,
])
const evaluator = input.evaluate ?? evaluateGoalCompletion
const evaluation = await evaluateWithTimeout(evaluator, {
goal: accounted,
transcript,
signal: input.signal,
querySource: input.querySource,
})
if (evaluation.complete) {
const completed =
markThreadGoalComplete(input.threadId, {
reason: evaluation.reason,
now,
}) ?? accounted
return {
action: 'complete',
goal: completed,
reason: evaluation.reason,
}
}
const continued =
incrementThreadGoalContinuation(input.threadId, {
reason: evaluation.reason,
now,
}) ?? accounted
return {
action: 'continue',
goal: continued,
reason: evaluation.reason,
prompt: buildGoalContinuationPrompt(continued, evaluation.reason),
}
}
async function evaluateGoalCompletion(input: {
goal: ThreadGoal
transcript: string
signal: AbortSignal
querySource?: QuerySource
}): Promise<GoalEvaluation> {
const baseRequest = {
querySource: input.querySource ?? 'hook_prompt',
model: getSmallFastModel(),
skipSystemPromptPrefix: true,
thinking: false,
temperature: 0,
max_tokens: 512,
signal: input.signal,
system:
'You evaluate whether a coding-agent goal is complete. ' +
'Return JSON only. Say complete=true only when the transcript contains concrete visible evidence that the objective is satisfied.',
messages: [
{
role: 'user' as const,
content: [
{
type: 'text' as const,
text: [
`<objective>${input.goal.objective}</objective>`,
'',
'<transcript>',
input.transcript,
'</transcript>',
].join('\n'),
},
],
},
],
}
try {
const response = await sideQuery({
...baseRequest,
output_format: {
type: 'json_schema',
schema: {
type: 'object',
properties: {
complete: { type: 'boolean' },
reason: { type: 'string' },
},
required: ['complete', 'reason'],
additionalProperties: false,
},
},
})
return parseEvaluationResponse(response)
} catch (error) {
if (input.signal.aborted) throw error
}
const response = await sideQuery({
...baseRequest,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: [
`<objective>${input.goal.objective}</objective>`,
'',
'<transcript>',
input.transcript,
'</transcript>',
'',
'Return exactly one JSON object with this shape and no markdown:',
'{"complete": false, "reason": "short evidence-based reason"}',
].join('\n'),
},
],
},
],
})
return parseEvaluationResponse(response)
}
function parseEvaluationResponse(response: BetaMessage): GoalEvaluation {
const text = extractTextContent(response.content, '').trim()
const parsed = safeParseJSON(text) ?? safeParseJSON(extractJsonObject(text))
if (
parsed &&
typeof parsed === 'object' &&
'complete' in parsed &&
typeof parsed.complete === 'boolean'
) {
return {
complete: parsed.complete,
reason:
'reason' in parsed && typeof parsed.reason === 'string'
? parsed.reason
: '',
}
}
return {
complete: false,
reason: 'The evaluator did not return a valid completion decision.',
}
}
function extractJsonObject(text: string): string {
const start = text.indexOf('{')
const end = text.lastIndexOf('}')
if (start === -1 || end <= start) return text
return text.slice(start, end + 1)
}
function formatTranscript(messages: Message[]): string {
const lines: string[] = []
const recent = messages.slice(-40)
for (const message of recent) {
if (message.type === 'user') {
if (message.isMeta) continue
lines.push(`User: ${contentToText(message.message.content)}`)
} else if (message.type === 'assistant') {
lines.push(`Assistant: ${assistantVisibleText(message.message.content)}`)
} else if (message.type === 'system' && typeof message.content === 'string') {
lines.push(`System: ${message.content}`)
}
}
return lines.join('\n\n').slice(-24_000)
}
function contentToText(content: string | readonly BetaContentBlock[]): string {
if (typeof content === 'string') return content
return extractTextContent(content, '\n')
}
function assistantVisibleText(content: readonly BetaContentBlock[]): string {
return content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
}
type TaskSummary = {
id: string
subject: string | null
status: string
}
function summarizeTaskState(messages: Message[]): {
tasks: TaskSummary[]
incomplete: TaskSummary[]
} {
const tasks = new Map<string, TaskSummary>()
for (const message of messages) {
if (message.type === 'assistant') {
for (const block of message.message.content) {
if (block.type !== 'tool_use') continue
if (block.name !== 'TaskUpdate') continue
const input = block.input
if (!input || typeof input !== 'object') continue
const taskId = (input as { taskId?: unknown }).taskId
const status = (input as { status?: unknown }).status
if (typeof taskId !== 'string' || typeof status !== 'string') continue
const existing = tasks.get(taskId)
tasks.set(taskId, {
id: taskId,
subject: existing?.subject ?? null,
status,
})
}
continue
}
if (message.type !== 'user') continue
const content = message.message.content
if (!Array.isArray(content)) continue
for (const block of content) {
if (block.type !== 'tool_result') continue
const text = toolResultText(block.content)
const created = text.match(/Task #(\S+) created successfully:\s*(.+)/)
if (!created) continue
const [, id, subject] = created
const existing = tasks.get(id)
tasks.set(id, {
id,
subject: subject.trim(),
status: existing?.status ?? 'pending',
})
}
}
const allTasks = [...tasks.values()]
return {
tasks: allTasks,
incomplete: allTasks.filter(task =>
task.status === 'pending' || task.status === 'in_progress',
),
}
}
function toolResultText(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return content
.map(item =>
item &&
typeof item === 'object' &&
'text' in item &&
typeof item.text === 'string'
? item.text
: '',
)
.filter(Boolean)
.join('\n')
}
function formatIncompleteTaskReason(tasks: TaskSummary[]): string {
const taskList = tasks
.slice(0, 3)
.map(task => {
const label = task.subject ? `Task #${task.id} (${task.subject})` : `Task #${task.id}`
return `${label} is ${task.status}`
})
.join('; ')
const suffix = tasks.length > 3 ? `; ${tasks.length - 3} more task(s) are incomplete` : ''
return `The task list is not complete yet: ${taskList}${suffix}.`
}
function getMaxContinuations(): number {
const raw = process.env.CLAUDE_CODE_GOAL_MAX_CONTINUES
if (!raw) return DEFAULT_MAX_CONTINUATIONS
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0
? parsed
: DEFAULT_MAX_CONTINUATIONS
}
async function evaluateWithTimeout(
evaluator: EvaluateFn,
input: {
goal: ThreadGoal
transcript: string
signal: AbortSignal
querySource?: QuerySource
},
): Promise<GoalEvaluation> {
const timeoutMs = getEvaluatorTimeoutMs()
const { signal, cleanup } = createCombinedAbortSignal(input.signal, {
timeoutMs,
})
try {
return await evaluator({
...input,
signal,
})
} catch (error) {
if (input.signal.aborted) throw error
if (signal.aborted) {
return {
complete: false,
reason: `The goal completion evaluator timed out after ${Math.round(
timeoutMs / 1000,
)}s.`,
}
}
throw error
} finally {
cleanup()
}
}
function getEvaluatorTimeoutMs(): number {
const raw = process.env.CLAUDE_CODE_GOAL_EVALUATOR_TIMEOUT_MS
if (!raw) return DEFAULT_EVALUATOR_TIMEOUT_MS
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0
? parsed
: DEFAULT_EVALUATOR_TIMEOUT_MS
}
function inferCompletionFromTaskEvidence(
messages: Message[],
taskState: { tasks: TaskSummary[]; incomplete: TaskSummary[] },
): string | null {
if (taskState.tasks.length === 0 || taskState.incomplete.length > 0) {
return null
}
const text = latestAssistantVisibleText(messages)
if (!text) return null
if (looksLikeFailureOrIncomplete(text)) return null
if (!looksLikeCompletionSummary(text)) return null
return `All ${taskState.tasks.length} tracked task(s) are complete and the final assistant response reports completion.`
}
function latestAssistantVisibleText(messages: Message[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message?.type !== 'assistant') continue
const text = assistantVisibleText(message.message.content).trim()
if (text) return text
}
return ''
}
function looksLikeCompletionSummary(text: string): boolean {
return (
/完成总结|目标已完成|已成功|全部验[证證]通过|均通过|构建成功|检查结果|代码审查结果/.test(
text,
) ||
/\b(completion summary|completed|complete|successfully|all tests passed|build passed|review complete|ready)\b/i.test(
text,
)
)
}
function looksLikeFailureOrIncomplete(text: string): boolean {
return (
/未完成|尚未完成|没有完成|失败|未通过|阻塞|(?:存在|有|出现|发现).{0,6}错误/.test(
text,
) ||
/\b(incomplete|not complete|not completed|not all tests passed|tests? did not pass|failed|failing|failure|blocked|errors? found|has errors?)\b/i.test(
text,
)
)
}

View File

@ -1,16 +1,32 @@
import { describe, expect, test } from 'bun:test'
import type { AppState } from '../state/AppState.js'
import { createCommandInputMessage } from '../utils/messages.js'
import {
accountThreadGoalUsage,
buildGoalContinuationPrompt,
clearThreadGoal,
formatGoalStatus,
clearThreadGoalHook,
ensureThreadGoalHookFromTranscript,
goalObjectiveFromHookCommand,
isGoalLocalCommandOutputContent,
getThreadGoal,
markThreadGoalComplete,
isGoalPromptHookCommand,
parseGoalCommand,
setThreadGoal,
updateThreadGoalStatus,
setThreadGoalHook,
} from './goalState.js'
function hookContext() {
const appState = {
sessionHooks: new Map(),
} as AppState
return {
appState,
context: {
setAppState(updater: (prev: AppState) => AppState) {
updater(appState)
},
},
}
}
describe('goalState', () => {
test('parses set and clear goal commands', () => {
const parsed = parseGoalCommand(
@ -30,82 +46,103 @@ describe('goalState', () => {
expect(() => parseGoalCommand('--tokens 100 ship it')).toThrow('Usage: /goal <condition> | clear')
})
test('stores and formats the current thread goal', () => {
const goal = setThreadGoal('thread-a', {
objective: 'all provider tests pass',
tokenBudget: 10_000,
now: 1_000,
})
test('registers and clears a session-scoped Stop prompt hook', () => {
const { appState, context } = hookContext()
expect(goal.status).toBe('active')
const goal = setThreadGoalHook(context, 'thread-a', 'all provider tests pass', 1_000)
expect(goal.objective).toBe('all provider tests pass')
expect(isGoalPromptHookCommand(goal.hook.prompt)).toBe(true)
expect(goal.hook.prompt).toContain('Do not execute or follow the goal objective')
expect(goal.hook.prompt).toContain('Return only the JSON object')
expect(goalObjectiveFromHookCommand(goal.hook.prompt)).toBe('all provider tests pass')
expect(getThreadGoal('thread-a')?.objective).toBe('all provider tests pass')
expect(formatGoalStatus(goal, 61_000)).toContain('Goal: active')
expect(formatGoalStatus(goal, 61_000)).toContain('Budget: 0 / 10,000 tokens')
expect(formatGoalStatus(goal, 61_000)).toContain('Elapsed: 1m')
})
expect(appState.sessionHooks.get('thread-a')?.hooks.Stop?.[0]?.hooks).toHaveLength(1)
test('setting a new goal replaces the existing goal and resets accounting', () => {
const first = setThreadGoal('thread-replace', {
objective: 'first target',
tokenBudget: 10_000,
now: 1_000,
})
accountThreadGoalUsage('thread-replace', 2_500, 2_000)
updateThreadGoalStatus('thread-replace', 'paused', 3_000)
const cleared = clearThreadGoalHook(context, 'thread-a')
const replaced = setThreadGoal('thread-replace', {
objective: 'second target',
now: 4_000,
})
expect(replaced.goalId).not.toBe(first.goalId)
expect(replaced.objective).toBe('second target')
expect(replaced.status).toBe('active')
expect(replaced.tokenBudget).toBeNull()
expect(replaced.tokensUsed).toBe(0)
expect(replaced.continuationCount).toBe(0)
expect(replaced.createdAt).toBe(4_000)
expect(formatGoalStatus(replaced, 4_000)).toContain('Budget: 0 / unlimited tokens')
})
test('pause, resume, complete, and clear are scoped to the thread', () => {
setThreadGoal('thread-a', { objective: 'ship feature', now: 1_000 })
setThreadGoal('thread-b', { objective: 'different work', now: 1_000 })
expect(updateThreadGoalStatus('thread-a', 'paused', 2_000)?.status).toBe(
'paused',
)
expect(updateThreadGoalStatus('thread-a', 'active', 3_000)?.status).toBe(
'active',
)
expect(
markThreadGoalComplete('thread-a', {
reason: 'Done according to the transcript.',
now: 4_000,
})?.status,
).toBe('complete')
expect(formatGoalStatus(getThreadGoal('thread-a'), 4_000)).toContain(
'Latest reason: Done according to the transcript.',
)
expect(getThreadGoal('thread-b')?.status).toBe('active')
expect(clearThreadGoal('thread-a')).toBe(true)
expect(cleared?.objective).toBe('all provider tests pass')
expect(getThreadGoal('thread-a')).toBeNull()
expect(appState.sessionHooks.get('thread-a')?.hooks.Stop).toBeUndefined()
})
test('builds a native-style continuation prompt', () => {
const goal = setThreadGoal('thread-c', {
objective: 'PR is ready and all tests pass',
now: 1_000,
})
test('replaces the current goal hook for a thread', () => {
const { appState, context } = hookContext()
setThreadGoalHook(context, 'thread-replace', 'first target', 1_000)
const replaced = setThreadGoalHook(context, 'thread-replace', 'second target', 2_000)
expect(replaced.objective).toBe('second target')
expect(getThreadGoal('thread-replace')?.objective).toBe('second target')
expect(appState.sessionHooks.get('thread-replace')?.hooks.Stop?.[0]?.hooks).toHaveLength(1)
expect(
buildGoalContinuationPrompt(goal, 'Tests have not been run yet.'),
).toContain('Continue working toward the active /goal')
appState.sessionHooks.get('thread-replace')?.hooks.Stop?.[0]?.hooks[0]?.hook,
).toBe(replaced.hook)
})
test('restores an active goal hook from transcript anchors', () => {
const { appState, context } = hookContext()
const restored = ensureThreadGoalHookFromTranscript(
context,
'thread-restored',
[
createCommandInputMessage([
'<command-name>/goal</command-name>',
'<command-args>ship persisted goal</command-args>',
].join('\n')),
createCommandInputMessage([
'<local-command-stdout>',
'Goal set: ship persisted goal',
'</local-command-stdout>',
].join('\n')),
],
2_000,
)
expect(restored?.objective).toBe('ship persisted goal')
expect(appState.sessionHooks.get('thread-restored')?.hooks.Stop?.[0]?.hooks).toHaveLength(1)
})
test('does not restore a goal after completion or clear anchors', () => {
const { context } = hookContext()
const completed = ensureThreadGoalHookFromTranscript(
context,
'thread-complete',
[
createCommandInputMessage('<local-command-stdout>Goal set: ship persisted goal</local-command-stdout>'),
createCommandInputMessage('<local-command-stdout>Goal marked complete.</local-command-stdout>'),
],
)
const cleared = ensureThreadGoalHookFromTranscript(
context,
'thread-cleared',
[
createCommandInputMessage('<local-command-stdout>Goal set: ship persisted goal</local-command-stdout>'),
createCommandInputMessage('<local-command-stdout>Goal cleared: ship persisted goal</local-command-stdout>'),
],
)
expect(completed).toBeNull()
expect(cleared).toBeNull()
})
test('identifies standalone goal local command output for SDK forwarding', () => {
expect(
buildGoalContinuationPrompt(goal, 'Tests have not been run yet.'),
).toContain('<objective>PR is ready and all tests pass</objective>')
isGoalLocalCommandOutputContent(
'<local-command-stdout>Goal marked complete.</local-command-stdout>',
),
).toBe(true)
expect(
buildGoalContinuationPrompt(goal, 'Tests have not been run yet.'),
).toContain('Tests have not been run yet.')
isGoalLocalCommandOutputContent(
'<local-command-stdout>Goal set: ship it</local-command-stdout>',
),
).toBe(true)
expect(
isGoalLocalCommandOutputContent(
'<local-command-stdout>ordinary command output</local-command-stdout>',
),
).toBe(false)
})
})

View File

@ -1,31 +1,36 @@
import { randomUUID } from 'crypto'
import {
COMMAND_NAME_TAG,
LOCAL_COMMAND_STDERR_TAG,
LOCAL_COMMAND_STDOUT_TAG,
} from '../constants/xml.js'
import type { ToolUseContext } from '../Tool.js'
import type { Message } from '../types/message.js'
export type ThreadGoalStatus = 'active' | 'paused' | 'complete' | 'budget_limited'
import { shouldSkipHookDueToTrust } from '../utils/hooks.js'
import {
addSessionHook,
removeSessionHook,
} from '../utils/hooks/sessionHooks.js'
import {
shouldAllowManagedHooksOnly,
shouldDisableAllHooksIncludingManaged,
} from '../utils/hooks/hooksConfigSnapshot.js'
import type { PromptHook } from '../utils/settings/types.js'
export type ThreadGoal = {
goalId: string
threadId: string
objective: string
status: ThreadGoalStatus
tokenBudget: number | null
tokensUsed: number
continuationCount: number
lastReason: string | null
hook: PromptHook
createdAt: number
updatedAt: number
}
export type ParsedGoalCommand =
| { type: 'clear' }
| { type: 'set'; objective: string }
const goalsByThread = new Map<string, ThreadGoal>()
const GOAL_HOOK_MARKER = '<cc-haha-goal-hook>'
const GOAL_HOOK_TIMEOUT_SECONDS = 45
const RESERVED_GOAL_ARGS = new Set(['status', 'pause', 'resume', 'complete'])
const goalsByThread = new Map<string, ThreadGoal>()
export function parseGoalCommand(args: string): ParsedGoalCommand {
const trimmed = args.trim()
@ -37,27 +42,49 @@ export function parseGoalCommand(args: string): ParsedGoalCommand {
return { type: 'set', objective: trimmed }
}
export function setThreadGoal(
threadId: string,
input: {
objective: string
tokenBudget?: number | null
now?: number
},
): ThreadGoal {
const now = input.now ?? Date.now()
const goal: ThreadGoal = {
goalId: randomUUID(),
threadId,
objective: input.objective.trim(),
status: 'active',
tokenBudget: input.tokenBudget ?? null,
tokensUsed: 0,
continuationCount: 0,
lastReason: null,
createdAt: now,
updatedAt: now,
export function getGoalHookUnavailableReason(): string | null {
if (shouldDisableAllHooksIncludingManaged()) {
return 'Cannot set /goal because hooks are disabled by policy settings.'
}
if (shouldAllowManagedHooksOnly()) {
return 'Cannot set /goal because only managed hooks are allowed.'
}
if (shouldSkipHookDueToTrust()) {
return 'Cannot set /goal until this workspace is trusted.'
}
return null
}
export function setThreadGoalHook(
context: Pick<ToolUseContext, 'setAppState'>,
threadId: string,
objective: string,
now = Date.now(),
): ThreadGoal {
clearThreadGoalHook(context, threadId)
const hook = createGoalPromptHook(objective)
const goal: ThreadGoal = {
threadId,
objective: objective.trim(),
hook,
createdAt: now,
}
addSessionHook(
context.setAppState,
threadId,
'Stop',
'',
hook,
() => {
removeSessionHook(context.setAppState, threadId, 'Stop', hook)
const current = goalsByThread.get(threadId)
if (current?.hook === hook) {
goalsByThread.delete(threadId)
}
},
)
goalsByThread.set(threadId, goal)
return goal
}
@ -66,15 +93,74 @@ export function getThreadGoal(threadId: string): ThreadGoal | null {
return goalsByThread.get(threadId) ?? null
}
export function hydrateThreadGoalFromMessages(
export function clearThreadGoalHook(
context: Pick<ToolUseContext, 'setAppState'>,
threadId: string,
): ThreadGoal | null {
const goal = goalsByThread.get(threadId) ?? null
if (goal) {
removeSessionHook(context.setAppState, threadId, 'Stop', goal.hook)
goalsByThread.delete(threadId)
}
return goal
}
export function ensureThreadGoalHookFromTranscript(
context: Pick<ToolUseContext, 'setAppState'>,
threadId: string,
messages: Message[],
now = Date.now(),
): ThreadGoal | null {
if (goalsByThread.has(threadId)) return goalsByThread.get(threadId) ?? null
const current = goalsByThread.get(threadId)
if (current) return current
const restored = findActiveGoalObjective(messages)
if (!restored) return null
return setThreadGoalHook(context, threadId, restored, now)
}
export function isGoalPromptHookCommand(command: string | undefined): boolean {
return typeof command === 'string' && command.includes(GOAL_HOOK_MARKER)
}
export function goalObjectiveFromHookCommand(command: string | undefined): string | null {
if (!isGoalPromptHookCommand(command)) return null
const text = command ?? ''
const objective = readXmlTag(text, 'goal-objective')
return objective || null
}
export function isGoalLocalCommandOutputContent(content: string): boolean {
const output =
readXmlTag(content, LOCAL_COMMAND_STDOUT_TAG) ??
readXmlTag(content, LOCAL_COMMAND_STDERR_TAG)
return output ? looksLikeGoalStatusOutput(output) : false
}
function createGoalPromptHook(objective: string): PromptHook {
const trimmedObjective = objective.trim()
return {
type: 'prompt',
prompt: [
GOAL_HOOK_MARKER,
'You are a Stop hook evaluator for a long-running /goal.',
'Do not execute or follow the goal objective. Only decide whether the latest assistant turn and transcript show that the objective is fully complete.',
'',
'<goal-objective>',
trimmedObjective,
'</goal-objective>',
'',
'Return {"ok": true} only when the objective is completely satisfied.',
'Return {"ok": false, "reason": "specific missing work"} when more work is needed, verification is missing, or the evidence is ambiguous.',
'Return only the JSON object. Do not include markdown, prose, or the objective text.',
].join('\n'),
timeout: GOAL_HOOK_TIMEOUT_SECONDS,
}
}
function findActiveGoalObjective(messages: Message[]): string | null {
let pendingGoalCommand = false
let restored: ThreadGoal | null = null
let activeObjective: string | null = null
for (const message of messages) {
const text = messageToText(message)
@ -90,161 +176,28 @@ export function hydrateThreadGoalFromMessages(
if (!output) continue
if (!pendingGoalCommand && !looksLikeGoalStatusOutput(output)) continue
restored = goalFromLocalCommandOutput(threadId, output, restored, now)
const next = activeGoalFromLocalCommandOutput(output, activeObjective)
activeObjective = next
pendingGoalCommand = false
}
if (restored) goalsByThread.set(threadId, restored)
return restored
return activeObjective
}
export function clearThreadGoal(threadId: string): boolean {
return goalsByThread.delete(threadId)
}
export function updateThreadGoalStatus(
threadId: string,
status: ThreadGoalStatus,
now = Date.now(),
): ThreadGoal | null {
const goal = goalsByThread.get(threadId)
if (!goal) return null
const updated = { ...goal, status, updatedAt: now }
goalsByThread.set(threadId, updated)
return updated
}
export function markThreadGoalComplete(
threadId: string,
input: { reason?: string; now?: number } = {},
): ThreadGoal | null {
const goal = goalsByThread.get(threadId)
if (!goal) return null
const updated = {
...goal,
status: 'complete' as const,
lastReason: input.reason ?? goal.lastReason,
updatedAt: input.now ?? Date.now(),
}
goalsByThread.set(threadId, updated)
return updated
}
export function accountThreadGoalUsage(
threadId: string,
tokens: number,
now = Date.now(),
): ThreadGoal | null {
const goal = goalsByThread.get(threadId)
if (!goal || tokens <= 0) return goal ?? null
const updated = {
...goal,
tokensUsed: goal.tokensUsed + tokens,
updatedAt: now,
}
goalsByThread.set(threadId, updated)
return updated
}
export function incrementThreadGoalContinuation(
threadId: string,
input: { reason?: string; now?: number } = {},
): ThreadGoal | null {
const goal = goalsByThread.get(threadId)
if (!goal) return null
const updated = {
...goal,
continuationCount: goal.continuationCount + 1,
lastReason: input.reason ?? goal.lastReason,
updatedAt: input.now ?? Date.now(),
}
goalsByThread.set(threadId, updated)
return updated
}
export function formatGoalStatus(goal: ThreadGoal | null, now = Date.now()): string {
if (!goal) return 'No active goal.'
return [
`Goal: ${goal.status}`,
`Objective: ${goal.objective}`,
`Budget: ${goal.tokensUsed.toLocaleString()} / ${
goal.tokenBudget === null ? 'unlimited' : goal.tokenBudget.toLocaleString()
} tokens`,
`Elapsed: ${formatElapsed(Math.max(0, now - goal.createdAt))}`,
`Continuations: ${goal.continuationCount.toLocaleString()}`,
goal.lastReason ? `Latest reason: ${goal.lastReason}` : null,
]
.filter((line): line is string => line !== null)
.join('\n')
}
export function buildGoalStartPrompt(goal: ThreadGoal): string {
return [
'You are now pursuing this /goal until the completion condition is met.',
'',
`<objective>${goal.objective}</objective>`,
'',
'Work autonomously. Research, implement, test, and review as needed.',
'Before claiming completion, perform a concrete completion audit against the objective.',
].join('\n')
}
export function buildGoalContinuationPrompt(
goal: ThreadGoal,
reason: string,
): string {
return [
'Continue working toward the active /goal.',
'',
`<objective>${goal.objective}</objective>`,
'',
'The goal evaluator says the objective is not complete yet.',
`Reason: ${reason || 'No reason provided.'}`,
'',
'Resume directly from the current state. Do not ask the user to continue. Do the next concrete step, then test or review before stopping.',
].join('\n')
}
function formatElapsed(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) return `${hours}h ${minutes % 60}m`
if (minutes > 0) return `${minutes}m`
return `${seconds}s`
}
function goalFromLocalCommandOutput(
threadId: string,
function activeGoalFromLocalCommandOutput(
output: string,
current: ThreadGoal | null,
now: number,
): ThreadGoal | null {
current: string | null,
): string | null {
const trimmed = output.trim()
if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) {
return null
}
if (trimmed === 'Goal marked complete.') return null
if (trimmed === 'No active goal.') 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,
}
return objective || current
}
return current
}
@ -259,26 +212,26 @@ function messageToText(message: Message): string {
return content
.map((block) => {
if (!block || typeof block !== 'object') return ''
const text = (block as { text?: unknown }).text
return typeof text === 'string' ? text : ''
if ('text' in block && typeof block.text === 'string') return block.text
return ''
})
.filter(Boolean)
.join('\n')
}
function looksLikeGoalStatusOutput(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.'
)
}
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
}
function looksLikeGoalStatusOutput(output: string): boolean {
const trimmed = output.trim()
return (
trimmed.startsWith('Goal set:') ||
trimmed === 'Goal cleared.' ||
trimmed.startsWith('Goal cleared:') ||
trimmed === 'Goal marked complete.' ||
trimmed === 'No active goal.'
)
}

View File

@ -48,7 +48,6 @@ import {
createUserInterruptionMessage,
normalizeMessagesForAPI,
createSystemMessage,
createCommandInputMessage,
createAssistantAPIErrorMessage,
getMessagesAfterCompactBoundary,
createToolUseSummaryMessage,
@ -100,7 +99,6 @@ import { runTools } from './services/tools/toolOrchestration.js'
import { applyToolResultBudget } from './utils/toolResultStorage.js'
import { recordContentReplacement } from './utils/sessionStorage.js'
import { handleStopHooks } from './query/stopHooks.js'
import { evaluateThreadGoalAfterTurn } from './goals/goalEvaluator.js'
import { buildQueryConfig } from './query/config.js'
import { productionDeps, type QueryDeps } from './query/deps.js'
import type { Terminal, Continue } from './query/transitions.js'
@ -1308,57 +1306,6 @@ async function* queryLoop(
continue
}
if (!toolUseContext.agentId) {
try {
const goalDecision = await evaluateThreadGoalAfterTurn({
threadId: getSessionId(),
messages: messagesForQuery,
assistantMessages,
signal: toolUseContext.abortController.signal,
querySource,
})
if (goalDecision.action === 'continue') {
logForDebugging(
`Goal continuation #${goalDecision.goal.continuationCount}: ${goalDecision.reason}`,
)
state = {
messages: [
...messagesForQuery,
...assistantMessages,
createUserMessage({
content: goalDecision.prompt,
isMeta: true,
}),
],
toolUseContext,
autoCompactTracking: tracking,
maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false,
maxOutputTokensOverride: undefined,
pendingToolUseSummary: undefined,
stopHookActive: undefined,
turnCount,
transition: { reason: 'goal_continuation' } as Continue,
}
continue
}
if (goalDecision.action === 'complete') {
yield createCommandInputMessage(
'<local-command-stdout>Goal marked complete.</local-command-stdout>',
)
}
if (goalDecision.action === 'budget_limited') {
logForDebugging('Goal stopped because its budget was reached')
}
} catch (error) {
logError(error)
logForDebugging('Goal evaluator failed; returning control to user')
}
}
if (feature('TOKEN_BUDGET')) {
const decision = checkTokenBudget(
budgetTracker!,

View File

@ -0,0 +1,38 @@
import { describe, expect, test } from 'bun:test'
import { shouldLetGoalPromptHookContinue } from './stopHooks.js'
describe('stop hook goal continuation', () => {
test('converts unmet managed /goal prompt hooks into normal blocking continuation', () => {
expect(
shouldLetGoalPromptHookContinue({
preventContinuation: true,
blockingError: {
blockingError: 'Prompt hook condition was not met: keep working',
command: '<cc-haha-goal-hook>\nship the feature',
},
}),
).toBe(true)
})
test('preserves prevent-continuation semantics for non-goal hooks', () => {
expect(
shouldLetGoalPromptHookContinue({
preventContinuation: true,
blockingError: {
blockingError: 'Prompt hook condition was not met: stop',
command: 'ordinary prompt hook',
},
}),
).toBe(false)
expect(
shouldLetGoalPromptHookContinue({
preventContinuation: false,
blockingError: {
blockingError: 'Prompt hook condition was not met: keep working',
command: '<cc-haha-goal-hook>\nship the feature',
},
}),
).toBe(false)
})
})

View File

@ -1,11 +1,17 @@
import { feature } from 'bun:bundle'
import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'
import { getSessionId } from '../bootstrap/state.js'
import { isExtractModeActive } from '../memdir/paths.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../services/analytics/index.js'
import type { ToolUseContext } from '../Tool.js'
import {
ensureThreadGoalHookFromTranscript,
isGoalPromptHookCommand,
} from '../goals/goalState.js'
import type { HookResult } from '../types/hooks.js'
import type { HookProgress } from '../types/hooks.js'
import type {
AssistantMessage,
@ -30,6 +36,7 @@ import {
} from '../utils/hooks.js'
import {
createStopHookSummaryMessage,
createCommandInputMessage,
createSystemMessage,
createUserInterruptionMessage,
createUserMessage,
@ -62,6 +69,15 @@ type StopHookResult = {
preventContinuation: boolean
}
export function shouldLetGoalPromptHookContinue(
result: Pick<HookResult, 'blockingError' | 'preventContinuation'>,
): boolean {
return Boolean(
result.preventContinuation &&
isGoalPromptHookCommand(result.blockingError?.command),
)
}
export async function* handleStopHooks(
messagesForQuery: Message[],
assistantMessages: AssistantMessage[],
@ -177,6 +193,14 @@ export async function* handleStopHooks(
const appState = toolUseContext.getAppState()
const permissionMode = appState.toolPermissionContext.mode
if (!toolUseContext.agentId) {
ensureThreadGoalHookFromTranscript(
toolUseContext,
getSessionId(),
[...messagesForQuery, ...assistantMessages],
)
}
const generator = executeStopHooks(
permissionMode,
toolUseContext.abortController.signal,
@ -196,6 +220,7 @@ export async function* handleStopHooks(
let hasOutput = false
const hookErrors: string[] = []
const hookInfos: StopHookInfo[] = []
let goalCompleted = false
for await (const result of generator) {
if (result.message) {
@ -231,6 +256,9 @@ export async function* handleStopHooks(
hookErrors.push(attachment.content)
hasOutput = true
} else if (attachment.type === 'hook_success') {
if (isGoalPromptHookCommand(attachment.command)) {
goalCompleted = true
}
// Check if successful hook produced any stdout/stderr
if (
(attachment.stdout && attachment.stdout.trim()) ||
@ -267,16 +295,18 @@ export async function* handleStopHooks(
}
// Check if hook wants to prevent continuation
if (result.preventContinuation) {
preventedContinuation = true
stopReason = result.stopReason || 'Stop hook prevented continuation'
// Create attachment to track the stopped continuation (for structured data)
yield createAttachmentMessage({
type: 'hook_stopped_continuation',
message: stopReason,
hookName: 'Stop',
toolUseID: stopHookToolUseID,
hookEvent: 'Stop',
})
if (!shouldLetGoalPromptHookContinue(result)) {
preventedContinuation = true
stopReason = result.stopReason || 'Stop hook prevented continuation'
// Create attachment to track the stopped continuation (for structured data)
yield createAttachmentMessage({
type: 'hook_stopped_continuation',
message: stopReason,
hookName: 'Stop',
toolUseID: stopHookToolUseID,
hookEvent: 'Stop',
})
}
}
// Check if we were aborted during hook execution
@ -322,6 +352,12 @@ export async function* handleStopHooks(
}
}
if (goalCompleted) {
yield createCommandInputMessage(
'<local-command-stdout>Goal marked complete.</local-command-stdout>',
)
}
if (preventedContinuation) {
return { blockingErrors: [], preventContinuation: true }
}