diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 05bf4b22..0c60ed13 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -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() @@ -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('[ | clear]')).not.toBeInTheDocument() - expect(screen.queryByText('Set a completion goal')).not.toBeInTheDocument() + expect(await screen.findAllByText('/goal')).toHaveLength(2) + expect(screen.getByText('[ | 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() }) diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 53424677..9a1ad7e4 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -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: '[ | clear]', + }) expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal status') expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal --tokens') }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index bda162bf..884903d9 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -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: '[ | clear]', + }, { name: 'review', description: 'Review code changes' }, { name: 'commit', description: 'Create a git commit' }, { name: 'pr', description: 'Create a pull request' }, diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index ca5428cc..1b0ebd4a 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -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() 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() + + expect(screen.queryByTestId('active-goal-strip')).not.toBeInTheDocument() expect(screen.getByTestId('message-list')).toBeInTheDocument() }) diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index a4a7a0fb..4987221f 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -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 ( +
+
+ ) +} + 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() { )} + )} diff --git a/src/QueryEngine.ts b/src/QueryEngine.ts index ad1213c2..2e6e7660 100644 --- a/src/QueryEngine.ts +++ b/src/QueryEngine.ts @@ -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' && diff --git a/src/commands.ts b/src/commands.ts index 0d8ae711..55c82fa7 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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, diff --git a/src/commands/goal/goal.test.tsx b/src/commands/goal/goal.test.tsx index 5227b575..cbe31ebe 100644 --- a/src/commands/goal/goal.test.tsx +++ b/src/commands/goal/goal.test.tsx @@ -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 = {}) { const calls: Array<{ result?: string @@ -21,6 +25,7 @@ async function runGoal(args: string, context: Partial = }, { messages: [], + setAppState: updater => updater({ sessionHooks: new Map() } as any), ...context, } as LocalJSXCommandContext, args, @@ -32,6 +37,7 @@ async function runGoal(args: string, context: Partial = 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( - 'ship the smoke test', - ) + 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( - 'ship the replacement target', - ) + 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', '', ].join('\n')), - createCommandInputMessage([ - '', - 'Goal marked complete.', - '', - ].join('\n')), ], }) diff --git a/src/commands/goal/goal.tsx b/src/commands/goal/goal.tsx index 189c1590..cd946b51 100644 --- a/src/commands/goal/goal.tsx +++ b/src/commands/goal/goal.tsx @@ -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 { 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) { diff --git a/src/commands/headless.test.ts b/src/commands/headless.test.ts index 115f214a..535656d7 100644 --- a/src/commands/headless.test.ts +++ b/src/commands/headless.test.ts @@ -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', ]) }) diff --git a/src/goals/goalEvaluator.test.ts b/src/goals/goalEvaluator.test.ts deleted file mode 100644 index 15008382..00000000 --- a/src/goals/goalEvaluator.test.ts +++ /dev/null @@ -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: [ - '/goal', - 'ship persisted goal', - ].join('\n'), - }), - createUserMessage({ - content: [ - '', - 'Goal set: ship persisted goal', - '', - ].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) - }) -}) diff --git a/src/goals/goalEvaluator.ts b/src/goals/goalEvaluator.ts deleted file mode 100644 index 22b4ef5f..00000000 --- a/src/goals/goalEvaluator.ts +++ /dev/null @@ -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 - -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 { - 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 { - 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: [ - `${input.goal.objective}`, - '', - '', - input.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: [ - `${input.goal.objective}`, - '', - '', - input.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() - - 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 { - 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, - ) - ) -} diff --git a/src/goals/goalState.test.ts b/src/goals/goalState.test.ts index 3a7d2657..49fcbd1b 100644 --- a/src/goals/goalState.test.ts +++ b/src/goals/goalState.test.ts @@ -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 | 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([ + '/goal', + 'ship persisted goal', + ].join('\n')), + createCommandInputMessage([ + '', + 'Goal set: ship persisted goal', + '', + ].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('Goal set: ship persisted goal'), + createCommandInputMessage('Goal marked complete.'), + ], + ) + const cleared = ensureThreadGoalHookFromTranscript( + context, + 'thread-cleared', + [ + createCommandInputMessage('Goal set: ship persisted goal'), + createCommandInputMessage('Goal cleared: ship persisted goal'), + ], + ) + + 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('PR is ready and all tests pass') + isGoalLocalCommandOutputContent( + 'Goal marked complete.', + ), + ).toBe(true) expect( - buildGoalContinuationPrompt(goal, 'Tests have not been run yet.'), - ).toContain('Tests have not been run yet.') + isGoalLocalCommandOutputContent( + 'Goal set: ship it', + ), + ).toBe(true) + expect( + isGoalLocalCommandOutputContent( + 'ordinary command output', + ), + ).toBe(false) }) }) diff --git a/src/goals/goalState.ts b/src/goals/goalState.ts index e05bb7bb..07066640 100644 --- a/src/goals/goalState.ts +++ b/src/goals/goalState.ts @@ -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() +const GOAL_HOOK_MARKER = '' +const GOAL_HOOK_TIMEOUT_SECONDS = 45 const RESERVED_GOAL_ARGS = new Set(['status', 'pause', 'resume', 'complete']) +const goalsByThread = new Map() 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, + 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, + 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, 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.', + '', + '', + trimmedObjective, + '', + '', + '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.', - '', - `${goal.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.', - '', - `${goal.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]*?)`, '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.' - ) -} diff --git a/src/query.ts b/src/query.ts index 646af94e..ccd17a0f 100644 --- a/src/query.ts +++ b/src/query.ts @@ -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( - 'Goal marked complete.', - ) - } - - 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!, diff --git a/src/query/stopHooks.test.ts b/src/query/stopHooks.test.ts new file mode 100644 index 00000000..d06b918e --- /dev/null +++ b/src/query/stopHooks.test.ts @@ -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: '\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: '\nship the feature', + }, + }), + ).toBe(false) + }) +}) diff --git a/src/query/stopHooks.ts b/src/query/stopHooks.ts index 6105485d..8f102aa4 100644 --- a/src/query/stopHooks.ts +++ b/src/query/stopHooks.ts @@ -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, +): 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( + 'Goal marked complete.', + ) + } + if (preventedContinuation) { return { blockingErrors: [], preventContinuation: true } }