feat: /goal命令能力支持,参考codex实现 (#1261)

* feat: /goal命令能力支持,参考codex实现

* fix: 修复promp和提示词不一致的问题

* fix: 修复 goal 功能多项 AI 审查问题

- prompt 中 update 行为描述与运行时不一致(no-op → error)
- src/commands/goal/ 使用相对路径导入,改为 src/* 别名
- /goal 命令标记 bridgeSafe 但含交互式对话框,改为 false
- useGoalContinuation 中 origin 使用 as unknown as string 强转,改为直接传字符串
- ResumeConversation 路径缺少 goal hydration,补齐恢复逻辑
- onCancel 在非查询状态下误暂停 goal,加 queryGuard 守卫
- resumeGoal 允许从终态恢复,收紧为仅允许 paused 状态
- buildGoalContextBlock 生成畸形 XML 属性,改为合法 budget 属性

* fix: 修复剩余AI审查的问题

* fix: 防止goal状态丢失

* fix: 修复Biome规范错误问题

* fix: 修复部分情况下goal无法启动的问题

* fix: 增加断网后状态默认设置为PAUSE机制、完成暂停-恢复状态切换,且正常进行前端渲染。设置达到max turn后处理逻辑。

* fix: 修复终端异常断开情况,resume续跑;修复用户消息排队信息被goal输出信息覆盖的问题。

* fix: apply biome formatting to pass CI lint check

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: skip slash command echo in setUserInputOnProcessing to prevent UI flash

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: moyu <moyu@kingsoft.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
moy16
2026-06-14 10:44:10 +08:00
committed by GitHub
parent 5bfe6fa590
commit 3e3e1de81b
28 changed files with 2248 additions and 30 deletions

View File

@@ -20,6 +20,7 @@ export { FileEditTool } from './tools/FileEditTool/FileEditTool.js'
export { FileReadTool } from './tools/FileReadTool/FileReadTool.js' export { FileReadTool } from './tools/FileReadTool/FileReadTool.js'
export { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js' export { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js'
export { GlobTool } from './tools/GlobTool/GlobTool.js' export { GlobTool } from './tools/GlobTool/GlobTool.js'
export { GoalTool } from './tools/GoalTool/GoalTool.js'
export { GrepTool } from './tools/GrepTool/GrepTool.js' export { GrepTool } from './tools/GrepTool/GrepTool.js'
export { LSPTool } from './tools/LSPTool/LSPTool.js' export { LSPTool } from './tools/LSPTool/LSPTool.js'
export { ListMcpResourcesTool } from './tools/ListMcpResourcesTool/ListMcpResourcesTool.js' export { ListMcpResourcesTool } from './tools/ListMcpResourcesTool/ListMcpResourcesTool.js'

View File

@@ -0,0 +1,253 @@
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from 'src/Tool.js'
import { jsonStringify } from 'src/utils/slowOperations.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import {
completeGoal,
formatGoalElapsed,
formatGoalStatusLabel,
getGoal,
recordBlockedAttempt,
} from 'src/services/goal/goalState.js'
import { persistCurrentGoal } from 'src/services/goal/goalStorage.js'
import { GOAL_TOOL_NAME } from './constants.js'
import { DESCRIPTION, generatePrompt } from './prompt.js'
function toolLog(msg: string): void {
try {
const { logForDebugging } =
require('src/utils/debug.js') as typeof import('src/utils/debug.js')
logForDebugging(`[goal] tool: ${msg}`)
} catch {
/* debug not available */
}
}
const inputSchema = lazySchema(() =>
z.strictObject({
action: z
.enum(['get', 'update'])
.optional()
.describe(
'Action to perform: "get" to read status, "update" to mark complete or blocked. Defaults to "update" if status is provided, otherwise "get".',
),
status: z
.enum(['complete', 'blocked'])
.optional()
.describe(
'Required for "update". Only "complete" or "blocked" are accepted.',
),
reason: z
.string()
.optional()
.describe('Explanation for the status change. Required for "update".'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
success: z.boolean(),
goal: z
.object({
objective: z.string(),
status: z.string(),
tokensUsed: z.number(),
tokenBudget: z.number().nullable(),
elapsed: z.string(),
turnsExecuted: z.number(),
})
.optional(),
message: z.string().optional(),
report: z.string().optional(),
error: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Input = z.infer<InputSchema>
export type Output = z.infer<OutputSchema>
function buildGoalSnapshot() {
const goal = getGoal()
if (!goal) return undefined
return {
objective: goal.objective,
status: formatGoalStatusLabel(goal.status),
tokensUsed: goal.tokensUsed,
tokenBudget: goal.tokenBudget,
elapsed: formatGoalElapsed(goal),
turnsExecuted: goal.turnsExecuted,
}
}
function buildCompletionReport(): string {
const goal = getGoal()
if (!goal) return ''
const budget =
goal.tokenBudget !== null
? `Token usage: ${goal.tokensUsed} / ${goal.tokenBudget}`
: `Token usage: ${goal.tokensUsed}`
return [
'Goal achieved — usage report:',
` ${budget}`,
` Active time: ${formatGoalElapsed(goal)}`,
` Continuation turns: ${goal.turnsExecuted}`,
].join('\n')
}
export const GoalTool = buildTool({
name: GOAL_TOOL_NAME,
searchHint: 'get or update the active goal (complete/blocked)',
maxResultSizeChars: 10_000,
async description() {
return DESCRIPTION
},
async prompt() {
return generatePrompt()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'Goal'
},
shouldDefer: true,
isConcurrencySafe() {
return true
},
isReadOnly(input: Input) {
const action = input.action ?? (input.status ? 'update' : 'get')
return action === 'get'
},
toAutoClassifierInput(input: Input) {
const action = input.action ?? (input.status ? 'update' : 'get')
if (action === 'get') return 'get goal status'
return `update goal: ${input.status}${input.reason ?? ''}`
},
async checkPermissions(input: Input) {
return { behavior: 'allow' as const, updatedInput: input }
},
renderToolUseMessage(input: Input) {
const action = input.action ?? (input.status ? 'update' : 'get')
if (action === 'get') return 'Checking goal status…'
return `Updating goal: ${input.status}${input.reason ? `${input.reason}` : ''}`
},
renderToolResultMessage(output: Output) {
if (output.error) return `Goal error: ${output.error}`
if (output.report) return output.report
if (output.goal) {
return `Goal "${output.goal.objective}" — ${output.goal.status}`
}
return output.message ?? 'Done'
},
renderToolUseRejectedMessage() {
return 'Goal operation rejected'
},
async call(input: Input): Promise<{ data: Output }> {
const action = input.action ?? (input.status ? 'update' : 'get')
toolLog(
`called: action=${action}${input.status ? ` status=${input.status}` : ''}${input.reason ? ` reason="${input.reason.slice(0, 60)}"` : ''}`,
)
if (action === 'get') {
const snapshot = buildGoalSnapshot()
if (!snapshot) {
return {
data: {
success: true,
message:
'No active goal. The user can set one with `/goal <objective>`.',
},
}
}
return { data: { success: true, goal: snapshot } }
}
// action === 'update'
if (!input.status) {
return {
data: {
success: false,
error:
'The "status" field is required for update. Use "complete" or "blocked".',
},
}
}
const goal = getGoal()
if (!goal) {
return {
data: {
success: false,
error: 'No active goal to update.',
},
}
}
if (input.status === 'complete') {
const report = buildCompletionReport()
completeGoal()
persistCurrentGoal()
return {
data: {
success: true,
goal: buildGoalSnapshot(),
report,
},
}
}
// status === 'blocked'
const reason = input.reason ?? 'unspecified blocker'
const result = recordBlockedAttempt(reason)
if (!result) {
return {
data: {
success: false,
error: 'Goal is not in a state that accepts blocked attempts.',
},
}
}
persistCurrentGoal()
if (result.status === 'blocked') {
return {
data: {
success: true,
goal: buildGoalSnapshot(),
message: `Goal marked as blocked after ${result.attempts} consecutive attempts. Reason: ${reason}`,
},
}
}
return {
data: {
success: true,
goal: buildGoalSnapshot(),
message: `Blocked attempt ${result.attempts} recorded. The goal remains active — the same condition must persist for 3 consecutive turns before it is marked blocked.`,
},
}
},
mapToolResultToToolResultBlockParam(content: Output, toolUseID: string) {
if (content.error) {
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: `Error: ${content.error}`,
is_error: true,
}
}
const parts: string[] = []
if (content.message) parts.push(content.message)
if (content.report) parts.push(content.report)
if (content.goal) parts.push(jsonStringify(content.goal))
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: parts.join('\n') || 'Done',
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1 @@
export const GOAL_TOOL_NAME = 'GoalTool'

View File

@@ -0,0 +1,38 @@
export const DESCRIPTION =
'Get or update the active goal status. The model may only mark a goal as "complete" or "blocked".'
export function generatePrompt(): string {
return `Use this tool to interact with the active thread goal.
## Actions
### get
Returns the current goal state (objective, status, token usage, elapsed time, turns executed).
No input required beyond \`action: "get"\`.
### update
Transition the goal to a terminal status. Only two values are accepted:
- **complete** — All requirements are verified (see Completion Audit below).
- **blocked** — An insurmountable obstacle has persisted for 3+ consecutive turns (see Blocked Audit below).
When marking complete, provide a brief \`reason\` summarising what was achieved.
When marking blocked, provide a \`reason\` describing the specific blocker.
## Completion Audit (required before marking complete)
1. Derive concrete requirements from the objective.
2. Preserve the original scope — do not redefine success around existing work.
3. For every requirement, identify authoritative evidence (test output, file content, command result).
4. Treat tests and manifests as evidence only after confirming they cover the requirement.
5. Treat uncertain or indirect evidence as "not achieved".
6. The audit must PROVE completion, not merely fail to find remaining work.
## Blocked Audit (required before marking blocked)
1. The same blocking condition must persist across at least 3 consecutive continuation turns.
2. "Difficult", "slow", or "partially incomplete" is NOT blocked.
3. Only genuinely insurmountable obstacles qualify (missing credentials, external service down, etc.).
## Important
- You cannot pause, resume, or clear a goal — only the user can do that via \`/goal\`.
- If no goal is active, \`get\` returns a message saying so; \`update\` returns an error.
- On completion, the tool result includes a usage report (tokens, time, turns).`
}

View File

@@ -95,4 +95,7 @@ export const DEFAULT_BUILD_FEATURES = [
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行 'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
// Autofix PR // Autofix PR
'AUTOFIX_PR', // /autofix-pr 命令fork 引入docs/jira/AUTOFIX-PR-001.md 承诺默认开启) 'AUTOFIX_PR', // /autofix-pr 命令fork 引入docs/jira/AUTOFIX-PR-001.md 承诺默认开启)
// Persistent thread goal command — auto-continuation, JSONL persistence,
// strict completion/blocked audit. See src/services/goal.
'GOAL',
] as const ] as const

View File

@@ -162,6 +162,11 @@ const poor = feature('POOR')
require('./commands/poor/index.js') as typeof import('./commands/poor/index.js') require('./commands/poor/index.js') as typeof import('./commands/poor/index.js')
).default ).default
: null : null
const goalCmd = feature('GOAL')
? (
require('./commands/goal/index.js') as typeof import('./commands/goal/index.js')
).default
: null
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */
import thinkback from './commands/thinkback/index.js' import thinkback from './commands/thinkback/index.js'
import thinkbackPlay from './commands/thinkback-play/index.js' import thinkbackPlay from './commands/thinkback-play/index.js'
@@ -362,6 +367,7 @@ const COMMANDS = memoize((): Command[] => [
...(forkCmd ? [forkCmd] : []), ...(forkCmd ? [forkCmd] : []),
...(buddy ? [buddy] : []), ...(buddy ? [buddy] : []),
...(poor ? [poor] : []), ...(poor ? [poor] : []),
...(goalCmd ? [goalCmd] : []),
...(proactive ? [proactive] : []), ...(proactive ? [proactive] : []),
...(monitorCmd ? [monitorCmd] : []), ...(monitorCmd ? [monitorCmd] : []),
...(coordinatorCmd ? [coordinatorCmd] : []), ...(coordinatorCmd ? [coordinatorCmd] : []),

View File

@@ -0,0 +1,75 @@
/**
* Confirmation dialog shown when the user runs `/goal <objective>`
* while a non-complete goal is already active.
*/
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { GoalState } from 'src/types/logs.js';
import { Select } from 'src/components/CustomSelect/index.js';
import { PermissionDialog } from 'src/components/permissions/PermissionDialog.js';
import { formatGoalElapsed, formatGoalStatusLabel } from 'src/services/goal/goalState.js';
type Props = {
currentGoal: GoalState;
newObjective: string;
onConfirm: () => void;
onCancel: () => void;
};
export function GoalReplaceConfirmDialog({ currentGoal, newObjective, onConfirm, onCancel }: Props): React.ReactNode {
function handleResponse(value: 'yes' | 'no'): void {
if (value === 'yes') onConfirm();
else onCancel();
}
const tokensDisplay =
currentGoal.tokenBudget !== null
? `${currentGoal.tokensUsed} / ${currentGoal.tokenBudget}`
: `${currentGoal.tokensUsed}`;
return (
<PermissionDialog color="warning" title="Replace active goal?">
<Box flexDirection="column" marginTop={1} paddingX={1}>
<Text>A goal is already in progress. Replacing it will reset all progress and counters.</Text>
<Box marginTop={1} flexDirection="column">
<Text dimColor>Current goal:</Text>
<Text>
<Text dimColor>· Objective: </Text>
{currentGoal.objective}
</Text>
<Text>
<Text dimColor>· Status: </Text>
{formatGoalStatusLabel(currentGoal.status)}
</Text>
<Text>
<Text dimColor>· Time: </Text>
{formatGoalElapsed(currentGoal)}
</Text>
<Text>
<Text dimColor>· Tokens: </Text>
{tokensDisplay}
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<Text dimColor>New objective:</Text>
<Text>{newObjective}</Text>
</Box>
<Box marginTop={1}>
<Select
options={[
{ label: 'Yes, replace the goal', value: 'yes' as const },
{ label: 'No, keep the current goal', value: 'no' as const },
]}
onChange={handleResponse}
onCancel={onCancel}
/>
</Box>
</Box>
</PermissionDialog>
);
}

207
src/commands/goal/goal.tsx Normal file
View File

@@ -0,0 +1,207 @@
/**
* `/goal` slash command — set, view, or control the persistent thread
* goal that drives auto-continuation across turns.
*
* Subcommands
* -----------
* `/goal` -> show current status
* `/goal status` -> alias of bare `/goal`
* `/goal clear` -> remove the active goal (persists tombstone)
* `/goal pause` -> pause auto-continuation
* `/goal resume` -> resume from paused state
* `/goal continue` -> reset turn counter after max-turns and continue
* `/goal complete` -> mark complete (manual override; tools usually do this)
* `/goal <objective>` -> set a new goal; if one is already active and not
* complete, a confirmation dialog appears first.
*/
import * as React from 'react';
import type { LocalJSXCommandContext } from 'src/commands.js';
import {
clearGoal,
completeGoal,
continueGoalFromMaxTurns,
formatGoalElapsed,
formatGoalStatusLabel,
getGoal,
incrementGoalTurns,
MAX_GOAL_TURNS,
pauseGoal,
resumeGoal,
setGoal,
} from 'src/services/goal/goalState.js';
import { persistCurrentGoal, persistGoalClear } from 'src/services/goal/goalStorage.js';
import type { LocalJSXCommandOnDone } from 'src/types/command.js';
import { removeByFilter } from 'src/utils/messageQueueManager.js';
import { GoalReplaceConfirmDialog } from './GoalReplaceConfirmDialog.js';
const MAX_OBJECTIVE_CHARS = 4000;
const MAX_DISPLAY_CHARS = 80;
function truncateForDisplay(objective: string): string {
const firstLine = objective.split('\n')[0] ?? objective;
if (firstLine.length <= MAX_DISPLAY_CHARS) return firstLine;
return firstLine.slice(0, MAX_DISPLAY_CHARS) + '…';
}
function drainGoalContinuationQueue(): void {
removeByFilter(cmd => cmd.origin === 'goal-continuation' || cmd.origin === 'goal-budget-limit');
}
function formatGoalStatus(): string {
const goal = getGoal();
if (!goal) {
return 'No active goal. Set one with `/goal <objective>`.';
}
const tokens = goal.tokenBudget !== null ? `${goal.tokensUsed} / ${goal.tokenBudget}` : `${goal.tokensUsed}`;
const lines = [
`Goal: ${goal.objective}`,
`Status: ${formatGoalStatusLabel(goal.status)}`,
`Time: ${formatGoalElapsed(goal)}`,
`Tokens: ${tokens}`,
`Continuation turns: ${goal.turnsExecuted}`,
];
if (goal.status === 'max_turns') {
lines.push(
`Hint: Max continuation turns reached (${MAX_GOAL_TURNS}). Run \`/goal continue\` to reset and continue.`,
);
}
return lines.join('\n');
}
function applySetGoal(objective: string): string {
setGoal(objective);
incrementGoalTurns();
persistCurrentGoal();
return 'Goal set.';
}
export async function call(
onDone: LocalJSXCommandOnDone,
_context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const trimmed = args.trim();
if (!trimmed || trimmed.toLowerCase() === 'status') {
onDone(formatGoalStatus(), { display: 'system' });
return null;
}
const lower = trimmed.toLowerCase();
if (lower === 'clear') {
const cleared = clearGoal();
if (cleared) {
persistGoalClear();
drainGoalContinuationQueue();
}
onDone(cleared ? 'Goal cleared.' : 'No active goal to clear.', {
display: 'system',
});
return null;
}
if (lower === 'pause') {
const g = pauseGoal();
if (g) {
persistCurrentGoal();
drainGoalContinuationQueue();
}
onDone(g ? 'Goal paused.' : 'No active goal to pause.', {
display: 'system',
});
return null;
}
if (lower === 'resume') {
const current = getGoal();
if (current?.status === 'max_turns') {
onDone(
`Goal reached max continuation turns (${MAX_GOAL_TURNS}). Run \`/goal continue\` to reset turn counter and continue.`,
{ display: 'system' },
);
return null;
}
const g = resumeGoal();
if (g) persistCurrentGoal();
onDone(g ? 'Goal resumed.' : 'No paused goal to resume.', {
display: 'system',
shouldQuery: Boolean(g),
});
return null;
}
if (lower === 'continue') {
const g = continueGoalFromMaxTurns();
if (g) persistCurrentGoal();
onDone(
g
? `Goal continuation counter reset (0/${MAX_GOAL_TURNS}). Continuing...`
: 'Current goal is not in max-turns state.',
{
display: 'system',
shouldQuery: Boolean(g),
},
);
return null;
}
if (lower === 'complete') {
const g = completeGoal();
if (g) {
persistCurrentGoal();
drainGoalContinuationQueue();
}
onDone(g ? 'Goal marked complete.' : 'No active goal to complete.', {
display: 'system',
});
return null;
}
if (trimmed.length > MAX_OBJECTIVE_CHARS) {
onDone(
`Goal objective is too long (${trimmed.length} chars; limit ${MAX_OBJECTIVE_CHARS}). Save the detailed instructions to a file and reference it from a shorter objective.`,
{ display: 'system' },
);
return null;
}
const existing = getGoal();
const needsConfirmation = existing && existing.status !== 'complete';
if (!needsConfirmation) {
const summary = applySetGoal(trimmed);
onDone(summary, {
display: 'system',
shouldQuery: true,
displayArgs: truncateForDisplay(trimmed),
metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`],
});
return null;
}
return (
<GoalReplaceConfirmDialog
currentGoal={existing}
newObjective={trimmed}
onConfirm={() => {
drainGoalContinuationQueue();
const summary = applySetGoal(trimmed);
onDone(summary, {
display: 'system',
shouldQuery: true,
displayArgs: truncateForDisplay(trimmed),
metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`],
});
}}
onCancel={() => {
onDone('Kept the current goal. New objective discarded.', {
display: 'system',
});
}}
/>
);
}

View File

@@ -0,0 +1,13 @@
import type { Command } from 'src/commands.js'
const goal = {
type: 'local-jsx',
name: 'goal',
description:
'Set or view a persistent goal that drives auto-continuation across turns',
argumentHint: '[<objective> | status | clear | pause | resume | complete]',
bridgeSafe: false,
load: () => import('./goal.js'),
} satisfies Command
export default goal

View File

@@ -45,6 +45,7 @@ import { isVimModeEnabled } from './PromptInput/utils.js';
import { computeHitRate, tokenSignature } from '../utils/cacheStats.js'; import { computeHitRate, tokenSignature } from '../utils/cacheStats.js';
import { onResponse as cacheOnResponse, getCacheStatsState, initCacheStatsState } from '../utils/cacheStatsState.js'; import { onResponse as cacheOnResponse, getCacheStatsState, initCacheStatsState } from '../utils/cacheStatsState.js';
import { BuiltinStatusLine } from './BuiltinStatusLine.js'; import { BuiltinStatusLine } from './BuiltinStatusLine.js';
import { formatTokens } from 'src/utils/format.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CachePill — cache hit-rate + 1-hour TTL countdown pill // CachePill — cache hit-rate + 1-hour TTL countdown pill
@@ -156,6 +157,51 @@ function CachePill({ messages }: CachePillProps): React.ReactNode {
); );
} }
function GoalPill(): React.ReactNode {
if (!feature('GOAL')) return null;
const { getGoal, formatGoalStatusLabel } =
require('../services/goal/goalState.js') as typeof import('../services/goal/goalState.js');
const goal = getGoal();
if (!goal) return null;
const truncatedObj = goal.objective.length > 30 ? `${goal.objective.slice(0, 27)}` : goal.objective;
const budget =
goal.tokenBudget !== null
? `${formatTokens(goal.tokensUsed)}/${formatTokens(goal.tokenBudget)}`
: formatTokens(goal.tokensUsed);
const statusLabel = formatGoalStatusLabel(goal.status);
let statusNode: React.ReactNode;
switch (goal.status) {
case 'active':
statusNode = <Text color="ansi:green">{statusLabel}</Text>;
break;
case 'paused':
case 'budget_limited':
case 'usage_limited':
statusNode = <Text color="ansi:yellow">{statusLabel}</Text>;
break;
case 'blocked':
statusNode = <Text color="ansi:red">{statusLabel}</Text>;
break;
case 'complete':
statusNode = <Text color="ansi:cyan">{statusLabel}</Text>;
break;
default:
statusNode = <Text>{statusLabel}</Text>;
}
return (
<Text>
{statusNode}
<Text dimColor>{' · '}</Text>
<Text dimColor>{truncatedObj}</Text>
<Text dimColor>{' · '}</Text>
<Text>{budget}</Text>
</Text>
);
}
export function statusLineShouldDisplay(settings: ReadonlySettings): boolean { export function statusLineShouldDisplay(settings: ReadonlySettings): boolean {
// Assistant mode: statusline fields (model, permission mode, cwd) reflect the // Assistant mode: statusline fields (model, permission mode, cwd) reflect the
// REPL/daemon process, not what the agent child is actually running. Hide it. // REPL/daemon process, not what the agent child is actually running. Hide it.
@@ -519,6 +565,7 @@ function StatusLineInner({ messagesRef, lastAssistantMessageId, vimMode }: Props
totalCostUsd={getTotalCost()} totalCostUsd={getTotalCost()}
rateLimits={builtinRateLimits} rateLimits={builtinRateLimits}
/> />
<GoalPill />
<CachePill messages={messagesRef.current} /> <CachePill messages={messagesRef.current} />
</Box> </Box>
)} )}

View File

@@ -1,4 +1,5 @@
import type { BetaUsage as Usage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import type { BetaUsage as Usage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { feature } from 'bun:bundle'
import chalk from 'chalk' import chalk from 'chalk'
import { import {
addToTotalCostState, addToTotalCostState,
@@ -282,6 +283,24 @@ export function addToTotalSessionCost(
): number { ): number {
const modelUsage = addToTotalModelUsage(cost, usage, model) const modelUsage = addToTotalModelUsage(cost, usage, model)
addToTotalCostState(cost, modelUsage, model) addToTotalCostState(cost, modelUsage, model)
if (feature('GOAL')) {
const { getGoal, updateGoalTokens } =
require('./services/goal/goalState.js') as typeof import('./services/goal/goalState.js')
const totalDelta =
(usage.input_tokens ?? 0) +
(usage.output_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0)
const currentGoal = getGoal()
if (totalDelta > 0 && currentGoal?.status === 'active') {
const { logForDebugging: goalDbg } =
require('./utils/debug.js') as typeof import('./utils/debug.js')
goalDbg(
`[goal] cost: in=${usage.input_tokens ?? 0} out=${usage.output_tokens ?? 0} cache_r=${usage.cache_read_input_tokens ?? 0} cache_w=${usage.cache_creation_input_tokens ?? 0} delta=${totalDelta}`,
)
updateGoalTokens(totalDelta)
}
}
const attrs = const attrs =
isFastModeEnabled() && usage.speed === 'fast' isFastModeEnabled() && usage.speed === 'fast'

View File

@@ -0,0 +1,192 @@
/**
* useGoalContinuation — React hook that drives the auto-continuation
* loop for the `/goal` feature.
*
* Mounted inside REPL.tsx when feature('GOAL') is enabled. After each
* turn completes (queryGuard transitions to idle), checks whether the
* active goal should trigger another turn:
*
* 1. GOAL feature flag enabled
* 2. Goal exists and status === 'active'
* 3. Query just finished (isLoading transitioned false)
* 4. No active local-JSX UI (modal dialog)
* 5. Not in plan mode
* 6. turnsExecuted < MAX_GOAL_TURNS
* 7. No user messages in the queue (user input always takes priority)
*
* When user messages are queued during a goal turn, the hook always
* yields to let them process first. After the user messages are
* handled, the next idle will fire the hook again to continue.
* This ensures commands like `/goal pause` are never starved by
* auto-continuation.
*
* The hook is intentionally simple: a single useEffect that fires
* when `isLoading` flips to false. No timers, no intervals — the
* idle→enqueue→process→query→idle cycle is self-sustaining.
*/
import { useLayoutEffect, useRef } from 'react'
import { logForDebugging } from 'src/utils/debug.js'
import {
markGoalMaxTurnsReached,
getGoal,
incrementGoalTurns,
MAX_GOAL_TURNS,
} from 'src/services/goal/goalState.js'
import { persistCurrentGoal } from 'src/services/goal/goalStorage.js'
import {
buildBudgetLimitPrompt,
buildContinuationPrompt,
} from 'src/services/goal/prompts.js'
import {
enqueue,
getCommandQueueSnapshot,
} from 'src/utils/messageQueueManager.js'
function hookLog(msg: string): void {
logForDebugging(`[goal] hook: ${msg}`)
}
export type UseGoalContinuationOpts = {
isLoading: boolean
wasAborted: boolean
queuedCommandsLength: number
hasActiveLocalJsxUI: boolean
isInPlanMode: boolean
isQueryActiveNow?: () => boolean
onMaxTurnsReached?: () => void
onContinuationEnqueued?: (payload: {
turn: number
objective: string
}) => void
}
export function useGoalContinuation(opts: UseGoalContinuationOpts): void {
const optsRef = useRef(opts)
optsRef.current = opts
// Track whether we already enqueued for the current idle window.
// Reset to false every time isLoading becomes true (new turn starts).
const enqueuedRef = useRef(false)
// Fire budget_limit prompt exactly once per budget transition.
const budgetLimitFiredRef = useRef(false)
useLayoutEffect(() => {
if (opts.isLoading) {
enqueuedRef.current = false
return
}
// Avoid stale-render races: queue processing can reserve QueryGuard in an
// earlier effect during the same commit. Read live state before deciding.
if (opts.isQueryActiveNow?.()) {
hookLog('skip: queryActiveNow=true')
return
}
// Codex parity: continuation only after normal completion.
// Aborted turns (Ctrl+C / Escape) must not trigger a new turn.
if (opts.wasAborted) {
hookLog('skip: wasAborted=true')
return
}
// Already enqueued for this idle window
if (enqueuedRef.current) return
// User messages always take priority over auto-continuation.
// If the user typed something (e.g. `/goal pause`) while a turn was
// running, let their message process first. After it finishes, the
// next idle cycle will re-evaluate whether to continue.
const liveQueueLength = getCommandQueueSnapshot().length
if (liveQueueLength > 0) {
hookLog('skip: yielding to queued user messages')
return
}
if (opts.hasActiveLocalJsxUI) {
hookLog('skip: activeLocalJsxUI')
return
}
if (opts.isInPlanMode) {
hookLog('skip: planMode')
return
}
const goal = getGoal()
if (!goal) {
budgetLimitFiredRef.current = false
return
}
if (goal.status === 'active') {
budgetLimitFiredRef.current = false
}
// Budget-limited: inject one final steering prompt so the model
// knows to stop substantive work and summarise progress.
if (goal.status === 'budget_limited' && !budgetLimitFiredRef.current) {
budgetLimitFiredRef.current = true
enqueuedRef.current = true
const prompt = buildBudgetLimitPrompt(goal)
logForDebugging(
'[goal] hook: budget limit reached, injecting wrap-up prompt',
)
enqueue({
value: prompt,
mode: 'prompt',
priority: 'now',
isMeta: true,
origin: 'goal-budget-limit',
skipSlashCommands: true,
})
return
}
// Only continue for active goals
if (goal.status !== 'active') {
hookLog(`skip: status="${goal.status}" (not active)`)
return
}
if (goal.turnsExecuted >= MAX_GOAL_TURNS) {
const marked = markGoalMaxTurnsReached()
if (marked) {
persistCurrentGoal()
opts.onMaxTurnsReached?.()
}
logForDebugging(
`[goal] hook: MAX_GOAL_TURNS (${MAX_GOAL_TURNS}) reached, stopping`,
)
return
}
// All conditions met — enqueue a continuation turn
enqueuedRef.current = true
const turns = incrementGoalTurns()
persistCurrentGoal()
const prompt = buildContinuationPrompt(goal)
logForDebugging(
`[goal] hook: enqueuing turn ${turns} for "${goal.objective.slice(0, 60)}"`,
)
enqueue({
value: prompt,
mode: 'prompt',
priority: 'now',
isMeta: true,
origin: 'goal-continuation',
skipSlashCommands: true,
})
opts.onContinuationEnqueued?.({
turn: turns,
objective: goal.objective,
})
}, [
opts.isLoading,
opts.wasAborted,
opts.queuedCommandsLength,
opts.hasActiveLocalJsxUI,
opts.isInPlanMode,
])
}

View File

@@ -355,6 +355,9 @@ const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
const useProactive = const useProactive =
feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null; feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null; const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null;
const useGoalContinuation: typeof import('../hooks/useGoalContinuation.js').useGoalContinuation | null = feature('GOAL')
? require('../hooks/useGoalContinuation.js').useGoalContinuation
: null;
const useMasterMonitor = feature('UDS_INBOX') const useMasterMonitor = feature('UDS_INBOX')
? require('../hooks/useMasterMonitor.js').useMasterMonitor ? require('../hooks/useMasterMonitor.js').useMasterMonitor
: () => undefined; : () => undefined;
@@ -1133,6 +1136,12 @@ export function REPL({
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
// Track whether the last turn was user-aborted (Ctrl+C / Escape).
// When true, useGoalContinuation skips the continuation enqueue so
// interrupted turns don't spin into an unstoppable loop. Reset to
// false at the start of the next user-initiated turn.
const [wasAborted, setWasAborted] = useState(false);
// Ref for the bridge result callback — set after useReplBridge initializes, // Ref for the bridge result callback — set after useReplBridge initializes,
// read in the onQuery finally block to notify mobile clients that a turn ended. // read in the onQuery finally block to notify mobile clients that a turn ended.
const sendBridgeResultRef = useRef<() => void>(() => {}); const sendBridgeResultRef = useRef<() => void>(() => {});
@@ -2220,6 +2229,16 @@ export function REPL({
// cached name and write it to the wrong transcript on first message. // cached name and write it to the wrong transcript on first message.
clearSessionMetadata(); clearSessionMetadata();
restoreSessionMetadata(log); restoreSessionMetadata(log);
// Hydrate goal state from the resumed session's transcript
if (feature('GOAL') && log.goal) {
const { hydrateGoalFromTranscript } =
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js');
const goalsMap = new Map<UUID, import('../types/logs.js').GoalState>();
goalsMap.set(sessionId as UUID, log.goal);
hydrateGoalFromTranscript(goalsMap, sessionId as UUID);
}
// Resumed sessions shouldn't re-title from mid-conversation context // Resumed sessions shouldn't re-title from mid-conversation context
// (same reasoning as the useRef seed), and the previous session's // (same reasoning as the useRef seed), and the previous session's
// Haiku title shouldn't carry over. // Haiku title shouldn't carry over.
@@ -2523,6 +2542,24 @@ export function REPL({
proactiveModule?.pauseProactive(); proactiveModule?.pauseProactive();
} }
// Ctrl+C during an active goal turn pauses the goal so the
// continuation loop stops. The user can /goal resume to continue later.
// Guard: only pause when a query is actually in flight. onCancel() is
// also called from the restore/edit flow (idle), and pausing then would
// incorrectly stop the next continuation.
if (feature('GOAL') && queryGuard.getSnapshot()) {
const { getGoal, pauseGoal } =
require('../services/goal/goalState.js') as typeof import('../services/goal/goalState.js');
const { persistCurrentGoal } =
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js');
const currentGoal = getGoal();
if (currentGoal?.status === 'active') {
pauseGoal();
persistCurrentGoal();
}
}
setWasAborted(true);
queryGuard.forceEnd(); queryGuard.forceEnd();
skipIdleCheckRef.current = false; skipIdleCheckRef.current = false;
@@ -3162,6 +3199,43 @@ export function REPL({
proactiveModule?.setContextBlocked(false); proactiveModule?.setContextBlocked(false);
} }
} }
// Auto-pause active /goal when the turn failed due to connectivity.
// Continuing immediately after network failures usually burns turns
// without progress and can rapidly hit max-turn guards.
if (
feature('GOAL') &&
newMessage.type === 'assistant' &&
'isApiErrorMessage' in newMessage &&
newMessage.isApiErrorMessage
) {
const assistantText =
getContentText((newMessage.message?.content ?? '') as string | ContentBlockParam[]) ?? '';
const lowerText = assistantText.toLowerCase();
const isConnectivityFailure =
lowerText.includes('connection error') ||
lowerText.includes('fetch failed') ||
lowerText.includes('network error') ||
lowerText.includes('enotfound') ||
lowerText.includes('econnreset') ||
lowerText.includes('etimedout');
if (isConnectivityFailure) {
const { getGoal, pauseGoal } =
require('../services/goal/goalState.js') as typeof import('../services/goal/goalState.js');
const { persistCurrentGoal } =
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js');
const currentGoal = getGoal();
if (currentGoal?.status === 'active') {
pauseGoal();
persistCurrentGoal();
addNotification({
key: 'goal-auto-paused-connectivity-error',
text: 'Detected connection error. Active goal was auto-paused. Run /goal resume after network recovers.',
priority: 'immediate',
});
}
}
}
// Relay assistant response to master when in slave mode. // Relay assistant response to master when in slave mode.
if (feature('UDS_INBOX') && newMessage.type === 'assistant') { if (feature('UDS_INBOX') && newMessage.type === 'assistant') {
// Extract text from content blocks (API format) // Extract text from content blocks (API format)
@@ -3550,6 +3624,7 @@ export function REPL({
try { try {
pipeReturnHadErrorRef.current = false; pipeReturnHadErrorRef.current = false;
setWasAborted(false);
// isLoading is derived from queryGuard — tryStart() above already // isLoading is derived from queryGuard — tryStart() above already
// transitioned dispatching→running, so no setter call needed here. // transitioned dispatching→running, so no setter call needed here.
resetTimingRefs(); resetTimingRefs();
@@ -3607,6 +3682,7 @@ export function REPL({
// running→idle. Returns false if a newer query owns the guard // running→idle. Returns false if a newer query owns the guard
// (cancel+resubmit race where the stale finally fires as a microtask). // (cancel+resubmit race where the stale finally fires as a microtask).
if (queryGuard.end(thisGeneration)) { if (queryGuard.end(thisGeneration)) {
setWasAborted(abortController.signal.aborted);
setLastQueryCompletionTime(Date.now()); setLastQueryCompletionTime(Date.now());
skipIdleCheckRef.current = false; skipIdleCheckRef.current = false;
// Always reset loading state in finally - this ensures cleanup even // Always reset loading state in finally - this ensures cleanup even
@@ -3960,6 +4036,7 @@ export function REPL({
doneOptions?: { doneOptions?: {
display?: CommandResultDisplay; display?: CommandResultDisplay;
metaMessages?: string[]; metaMessages?: string[];
displayArgs?: string;
}, },
): void => { ): void => {
doneWasCalled = true; doneWasCalled = true;
@@ -3983,8 +4060,9 @@ export function REPL({
// doesn't change model context). Outside fullscreen the // doesn't change model context). Outside fullscreen the
// transcript entry stays so scrollback shows what ran. // transcript entry stays so scrollback shows what ran.
if (!isFullscreenEnvEnabled()) { if (!isFullscreenEnvEnabled()) {
const breadcrumbArgs = doneOptions?.displayArgs ?? commandArgs;
newMessages.push( newMessages.push(
createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), commandArgs)), createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), breadcrumbArgs)),
createCommandInputMessage( createCommandInputMessage(
`<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}</${LOCAL_COMMAND_STDOUT_TAG}>`, `<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}</${LOCAL_COMMAND_STDOUT_TAG}>`,
), ),
@@ -5015,6 +5093,34 @@ export function REPL({
onQueueTick: (command: QueuedCommand) => enqueue(command), onQueueTick: (command: QueuedCommand) => enqueue(command),
}); });
// Goal auto-continuation: enqueue a steering prompt when idle + active goal
// eslint-disable-next-line react-hooks/rules-of-hooks
useGoalContinuation?.({
isLoading: isLoading || initialMessage !== null,
wasAborted,
queuedCommandsLength: queuedCommands.length,
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
isInPlanMode: toolPermissionContext.mode === 'plan',
isQueryActiveNow: queryGuard.getSnapshot,
onContinuationEnqueued: ({ turn, objective }) => {
const visibleGoalTurnInput = `Goal auto-continue (${turn}/1): continue advancing "${objective}".`;
setMessages(oldMessages => [
...oldMessages,
createUserMessage({
content: visibleGoalTurnInput,
isVisibleInTranscriptOnly: true,
}),
]);
},
onMaxTurnsReached: () => {
addNotification({
key: 'goal-max-turns-reached',
text: 'Goal reached max continuation turns (1). Run /goal continue to reset turn counter and continue.',
priority: 'immediate',
});
},
});
useEffect(() => { useEffect(() => {
if (!proactiveActive) { if (!proactiveActive) {
notifyAutomationStateChanged(null); notifyAutomationStateChanged(null);
@@ -5832,7 +5938,6 @@ export function REPL({
!hasRunningTeammates && !hasRunningTeammates &&
isBriefOnly && isBriefOnly &&
!viewedAgentTask && <BriefIdleStatus />} !viewedAgentTask && <BriefIdleStatus />}
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
</> </>
} }
bottom={ bottom={
@@ -5845,6 +5950,7 @@ export function REPL({
<CompanionSprite /> <CompanionSprite />
) : null} ) : null}
<Box flexDirection="column" flexGrow={1}> <Box flexDirection="column" flexGrow={1}>
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
{permissionStickyFooter} {permissionStickyFooter}
{/* Immediate local-jsx commands (/btw, /sandbox, /assistant, {/* Immediate local-jsx commands (/btw, /sandbox, /assistant,
/issue) render here, NOT inside scrollable. They stay mounted /issue) render here, NOT inside scrollable. They stay mounted

View File

@@ -1,4 +1,5 @@
import { feature } from 'bun:bundle'; import { feature } from 'bun:bundle';
import type { UUID } from 'crypto';
import { dirname } from 'path'; import { dirname } from 'path';
import React from 'react'; import React from 'react';
import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; import { useTerminalSize } from 'src/hooks/useTerminalSize.js';
@@ -296,6 +297,14 @@ export function ResumeConversation({
} }
} }
if (feature('GOAL') && result.goal && result.sessionId) {
const { hydrateGoalFromTranscript } =
require('src/services/goal/goalStorage.js') as typeof import('src/services/goal/goalStorage.js');
const goalsMap = new Map<UUID, import('src/types/logs.js').GoalState>();
goalsMap.set(result.sessionId, result.goal);
hydrateGoalFromTranscript(goalsMap, result.sessionId);
}
if (feature('CONTEXT_COLLAPSE')) { if (feature('CONTEXT_COLLAPSE')) {
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
( (

View File

@@ -0,0 +1,272 @@
/**
* Unit tests for the per-session goal state machine.
*
* Pure-function tests: no FS, no network. The bootstrap/state.ts side
* effect chain pulls in log.ts so we mock that to keep the suite fast
* and side-effect free.
*/
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { logMock } from '../../../../tests/mocks/log.js'
mock.module('src/utils/log.ts', logMock)
import {
_clearAllGoalsForTesting,
BLOCKED_CONSECUTIVE_THRESHOLD,
continueGoalFromMaxTurns,
clearGoal,
completeGoal,
formatGoalElapsed,
formatGoalStatusLabel,
getActiveElapsedMs,
getGoal,
incrementGoalTurns,
markUsageLimited,
markGoalMaxTurnsReached,
MAX_GOAL_TURNS,
pauseGoal,
recordBlockedAttempt,
resumeGoal,
setGoal,
updateGoalTokens,
} from '../goalState.js'
const SESSION = 'test-session-id'
beforeEach(() => {
_clearAllGoalsForTesting()
})
describe('setGoal — creates an active goal with sane defaults', () => {
test('initial state has status active, zero tokens, no budget by default', () => {
const g = setGoal('improve test coverage', { sessionId: SESSION })
expect(g.status).toBe('active')
expect(g.objective).toBe('improve test coverage')
expect(g.tokensUsed).toBe(0)
expect(g.tokenBudget).toBeNull()
expect(g.blockedAttempts).toBe(0)
expect(g.turnsExecuted).toBe(0)
})
test('accepts a positive integer token budget', () => {
const g = setGoal('x', { tokenBudget: 5000, sessionId: SESSION })
expect(g.tokenBudget).toBe(5000)
})
test('rejects non-finite or negative budgets as null', () => {
expect(
setGoal('a', { tokenBudget: Number.NaN, sessionId: SESSION }).tokenBudget,
).toBeNull()
expect(
setGoal('a', { tokenBudget: -1, sessionId: SESSION }).tokenBudget,
).toBeNull()
expect(
setGoal('a', { tokenBudget: Infinity, sessionId: SESSION }).tokenBudget,
).toBeNull()
})
test('setGoal replaces an existing goal entirely', () => {
setGoal('first', { tokenBudget: 100, sessionId: SESSION })
updateGoalTokens(50, SESSION)
const g = setGoal('second', { sessionId: SESSION })
expect(g.objective).toBe('second')
expect(g.tokensUsed).toBe(0)
expect(g.tokenBudget).toBeNull()
})
})
describe('pause / resume — preserves active elapsed time', () => {
test('pause then resume keeps accumulated active time', async () => {
setGoal('x', { sessionId: SESSION })
await Bun.sleep(10)
const paused = pauseGoal(SESSION)
expect(paused?.status).toBe('paused')
expect(paused?.accumulatedActiveMs).toBeGreaterThanOrEqual(10)
const before = paused?.accumulatedActiveMs ?? 0
await Bun.sleep(20)
const resumed = resumeGoal(SESSION)
expect(resumed?.status).toBe('active')
expect(resumed?.accumulatedActiveMs).toBe(before)
})
test('pause is a no-op on a non-active goal', () => {
setGoal('x', { sessionId: SESSION })
pauseGoal(SESSION)
const second = pauseGoal(SESSION)
expect(second).toBeNull()
})
test('resume is a no-op on an active goal', () => {
setGoal('x', { sessionId: SESSION })
expect(resumeGoal(SESSION)).toBeNull()
})
test('getActiveElapsedMs while active includes ongoing interval', async () => {
setGoal('x', { sessionId: SESSION })
await Bun.sleep(10)
const g = getGoal(SESSION)!
expect(getActiveElapsedMs(g)).toBeGreaterThanOrEqual(10)
})
test('getActiveElapsedMs while paused freezes at accumulated total', async () => {
setGoal('x', { sessionId: SESSION })
await Bun.sleep(10)
pauseGoal(SESSION)
const g = getGoal(SESSION)!
const a = getActiveElapsedMs(g)
await Bun.sleep(20)
const b = getActiveElapsedMs(g)
expect(b).toBe(a)
})
})
describe('updateGoalTokens — accumulates and triggers budget_limited', () => {
test('accumulates positive deltas', () => {
setGoal('x', { tokenBudget: 1000, sessionId: SESSION })
updateGoalTokens(100, SESSION)
updateGoalTokens(200, SESSION)
expect(getGoal(SESSION)?.tokensUsed).toBe(300)
})
test('crossing budget transitions to budget_limited', () => {
setGoal('x', { tokenBudget: 100, sessionId: SESSION })
updateGoalTokens(150, SESSION)
expect(getGoal(SESSION)?.status).toBe('budget_limited')
})
test('further updates after budget_limited are no-ops (status-guarded)', () => {
setGoal('x', { tokenBudget: 100, sessionId: SESSION })
updateGoalTokens(150, SESSION)
updateGoalTokens(50, SESSION) // should not accumulate
expect(getGoal(SESSION)?.tokensUsed).toBe(150)
})
test('coerces non-finite or negative deltas to zero', () => {
setGoal('x', { tokenBudget: 1000, sessionId: SESSION })
updateGoalTokens(Number.NaN, SESSION)
updateGoalTokens(-100, SESSION)
updateGoalTokens(Infinity, SESSION)
expect(getGoal(SESSION)?.tokensUsed).toBe(0)
})
test('no-op when there is no goal', () => {
expect(updateGoalTokens(100, SESSION)).toBeNull()
})
})
describe('recordBlockedAttempt — CODEX 3-consecutive-attempts audit', () => {
test('first attempt records but stays active', () => {
setGoal('x', { sessionId: SESSION })
const r = recordBlockedAttempt('compile error', SESSION)
expect(r?.status).toBe('active')
expect(r?.attempts).toBe(1)
})
test('three same-reason attempts in a row flip to blocked', () => {
setGoal('x', { sessionId: SESSION })
recordBlockedAttempt('compile error', SESSION)
recordBlockedAttempt('compile error', SESSION)
const r = recordBlockedAttempt('compile error', SESSION)
expect(r?.status).toBe('blocked')
expect(r?.attempts).toBe(BLOCKED_CONSECUTIVE_THRESHOLD)
})
test('different reason resets counter', () => {
setGoal('x', { sessionId: SESSION })
recordBlockedAttempt('A', SESSION)
recordBlockedAttempt('A', SESSION)
const r = recordBlockedAttempt('B', SESSION)
expect(r?.status).toBe('active')
expect(r?.attempts).toBe(1)
})
test('case-insensitive comparison', () => {
setGoal('x', { sessionId: SESSION })
recordBlockedAttempt('compile error', SESSION)
recordBlockedAttempt('Compile Error', SESSION)
const r = recordBlockedAttempt('COMPILE ERROR', SESSION)
expect(r?.status).toBe('blocked')
})
test('resume resets blocked attempts', () => {
setGoal('x', { sessionId: SESSION })
recordBlockedAttempt('oops', SESSION)
recordBlockedAttempt('oops', SESSION)
pauseGoal(SESSION)
resumeGoal(SESSION)
expect(getGoal(SESSION)!.blockedAttempts).toBe(0)
})
})
describe('completeGoal / clearGoal / markUsageLimited', () => {
test('completeGoal transitions to complete', () => {
setGoal('x', { sessionId: SESSION })
const g = completeGoal(SESSION)
expect(g?.status).toBe('complete')
})
test('clearGoal removes entirely', () => {
setGoal('x', { sessionId: SESSION })
expect(clearGoal(SESSION)).toBe(true)
expect(getGoal(SESSION)).toBeNull()
})
test('markUsageLimited transitions active → usage_limited', () => {
setGoal('x', { sessionId: SESSION })
markUsageLimited(SESSION)
expect(getGoal(SESSION)?.status).toBe('usage_limited')
})
})
describe('incrementGoalTurns', () => {
test('counts correctly', () => {
setGoal('x', { sessionId: SESSION })
expect(incrementGoalTurns(SESSION)).toBe(1)
expect(incrementGoalTurns(SESSION)).toBe(2)
expect(getGoal(SESSION)?.turnsExecuted).toBe(2)
})
test('returns 0 when no goal', () => {
expect(incrementGoalTurns(SESSION)).toBe(0)
})
})
describe('max_turns lifecycle', () => {
test('markGoalMaxTurnsReached flips active goal once cap is reached', () => {
setGoal('x', { sessionId: SESSION })
const goal = getGoal(SESSION)!
goal.turnsExecuted = MAX_GOAL_TURNS
const marked = markGoalMaxTurnsReached(SESSION)
expect(marked?.status).toBe('max_turns')
})
test('continueGoalFromMaxTurns resets turns and re-activates goal', () => {
setGoal('x', { sessionId: SESSION })
const goal = getGoal(SESSION)!
goal.turnsExecuted = MAX_GOAL_TURNS
markGoalMaxTurnsReached(SESSION)
const resumed = continueGoalFromMaxTurns(SESSION)
expect(resumed?.status).toBe('active')
expect(resumed?.turnsExecuted).toBe(0)
})
})
describe('formatGoalStatusLabel', () => {
test('returns human-readable labels', () => {
expect(formatGoalStatusLabel('active')).toBe('Active')
expect(formatGoalStatusLabel('paused')).toBe('Paused')
expect(formatGoalStatusLabel('blocked')).toBe('Blocked')
expect(formatGoalStatusLabel('budget_limited')).toBe('Budget Limited')
expect(formatGoalStatusLabel('usage_limited')).toBe('Usage Limited')
expect(formatGoalStatusLabel('max_turns')).toBe('Max Turns Reached')
expect(formatGoalStatusLabel('complete')).toBe('Complete')
})
})
describe('formatGoalElapsed', () => {
test('returns "0s" for brand-new goals', () => {
const g = setGoal('x', { sessionId: SESSION })
expect(formatGoalElapsed(g)).toBe('0s')
})
})

View File

@@ -0,0 +1,33 @@
/**
* Audit rules constants for goal completion and blocked assessment.
* Shared by prompt templates and integration tests.
*/
import { BLOCKED_CONSECUTIVE_THRESHOLD, MAX_GOAL_TURNS } from './goalState.js'
import type { GoalStatus } from '../../types/logs.js'
export { BLOCKED_CONSECUTIVE_THRESHOLD, MAX_GOAL_TURNS }
export const COMPLETION_AUDIT_RULES = [
'Derive concrete requirements from the objective and any referenced files.',
'Preserve the original scope — do not redefine success around what is already done.',
'For every explicit requirement, identify authoritative evidence (test output, file content, command result).',
'Treat tests, manifests, and verifiers as evidence only after confirming they actually cover the requirement.',
'Treat uncertain or indirect evidence as "not achieved".',
'The audit must PROVE completion, not merely fail to find remaining work.',
] as const
export const BLOCKED_AUDIT_RULES = [
'The same blocking condition must persist across at least 3 consecutive continuation turns.',
'"Difficult", "slow", or "partially incomplete" is NOT blocked.',
'Only genuinely insurmountable obstacles qualify (missing credentials, external service down, etc.).',
] as const
export function isGoalTerminal(status: GoalStatus): boolean {
return (
status === 'complete' ||
status === 'blocked' ||
status === 'budget_limited' ||
status === 'usage_limited' ||
status === 'max_turns'
)
}

View File

@@ -1,30 +1,295 @@
/** /**
* Stub for the goal feature module. * Per-session goal state machine. Pure in-memory management — no FS,
* no network. Persistence is handled by goalStorage.ts.
* *
* The goal feature is not yet implemented. This stub exists so that * Uses Map<string, GoalState> keyed by sessionId so concurrent
* PromptInputFooterLeftSide.tsx's require() can be resolved by Bun's * sub-sessions (agents, worktrees) don't leak into each other.
* bundler (build.ts). At runtime, getGoal() returns null, so the
* GoalElapsedIndicator component renders nothing.
*
* When the goal feature is implemented, replace this stub with the
* real implementation.
*/ */
import type { GoalState, GoalStatus } from '../../types/logs.js'
import { getSessionId } from '../../bootstrap/state.js'
import { logForDebugging } from '../../utils/debug.js'
export type GoalState = { export const BLOCKED_CONSECUTIVE_THRESHOLD = 3
status: export const MAX_GOAL_TURNS = 150
| 'active'
| 'paused' const goals = new Map<string, GoalState>()
| 'budget_limited'
| 'usage_limited' function goalLog(
| 'blocked' tag: string,
| 'complete' msg: string,
[key: string]: unknown extra?: Record<string, unknown>,
): void {
const suffix = extra ? ` ${JSON.stringify(extra)}` : ''
logForDebugging(`[goal] ${tag}: ${msg}${suffix}`)
} }
export function getGoal(): GoalState | null { function resolveSessionId(sessionId?: string): string {
return null return sessionId ?? getSessionId()
} }
export function getActiveElapsedMs(_goal: GoalState): number { export function setGoal(
return 0 objective: string,
options?: { tokenBudget?: number; sessionId?: string },
): GoalState {
const id = resolveSessionId(options?.sessionId)
const budget =
options?.tokenBudget !== undefined &&
Number.isFinite(options.tokenBudget) &&
options.tokenBudget > 0
? options.tokenBudget
: null
const now = Date.now()
const state: GoalState = {
objective,
status: 'active',
tokenBudget: budget,
tokensUsed: 0,
startTime: now,
pausedAt: null,
accumulatedActiveMs: 0,
blockedAttempts: 0,
lastBlockReason: null,
createdAt: now,
updatedAt: now,
turnsExecuted: 0,
}
goals.set(id, state)
goalLog('SET', `objective="${objective.slice(0, 80)}"`, {
tokenBudget: state.tokenBudget,
})
return state
}
export function getGoal(sessionId?: string): GoalState | null {
return goals.get(resolveSessionId(sessionId)) ?? null
}
export function clearGoal(sessionId?: string): boolean {
const had = goals.has(resolveSessionId(sessionId))
const result = goals.delete(resolveSessionId(sessionId))
if (had) goalLog('CLEAR', 'goal removed')
return result
}
export function pauseGoal(sessionId?: string): GoalState | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal || goal.status !== 'active') return null
const now = Date.now()
goal.accumulatedActiveMs += now - goal.startTime
goal.pausedAt = now
goal.status = 'paused'
goal.updatedAt = now
goalLog(
'PAUSE',
`paused after ${Math.round(goal.accumulatedActiveMs / 1000)}s active`,
)
return goal
}
export function resumeGoal(sessionId?: string): GoalState | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal) return null
if (goal.status !== 'paused') {
return null
}
const now = Date.now()
goal.startTime = now
goal.pausedAt = null
goal.status = 'active'
goal.updatedAt = now
goalLog('RESUME', 'goal resumed, blockedAttempts reset')
goal.blockedAttempts = 0
goal.lastBlockReason = null
return goal
}
/**
* Transition an active goal into max_turns once continuation cap is hit.
* Idempotent: repeated calls while already max_turns are no-ops.
*/
export function markGoalMaxTurnsReached(sessionId?: string): GoalState | null {
const goal = getGoal(sessionId)
if (!goal || goal.status !== 'active') return null
if (goal.turnsExecuted < MAX_GOAL_TURNS) return null
goal.status = 'max_turns'
goal.updatedAt = Date.now()
goalLog('MAX_TURNS', `reached ${MAX_GOAL_TURNS} turns`)
return goal
}
/**
* Reset continuation turn counter after a max_turns stop and resume work.
* This is a deliberate user action (`/goal continue`) to prevent silent
* runaway loops.
*/
export function continueGoalFromMaxTurns(sessionId?: string): GoalState | null {
const goal = getGoal(sessionId)
if (!goal || goal.status !== 'max_turns') return null
const now = Date.now()
goal.turnsExecuted = 0
goal.status = 'active'
goal.startTime = now
goal.pausedAt = null
goal.blockedAttempts = 0
goal.lastBlockReason = null
goal.updatedAt = now
goalLog(
'CONTINUE',
`turn counter reset, status active (max=${MAX_GOAL_TURNS})`,
)
return goal
}
export function completeGoal(sessionId?: string): GoalState | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal) return null
const now = Date.now()
if (goal.status === 'active' && goal.pausedAt === null) {
goal.accumulatedActiveMs += now - goal.startTime
}
goal.status = 'complete'
goal.updatedAt = now
goalLog('COMPLETE', `goal achieved`, {
tokensUsed: goal.tokensUsed,
turns: goal.turnsExecuted,
})
return goal
}
export function updateGoalTokens(
delta: number,
sessionId?: string,
): GoalState | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal) return null
if (goal.status !== 'active') return null
if (!Number.isFinite(delta) || delta <= 0) return goal
const sanitized = delta
goal.tokensUsed += sanitized
goal.updatedAt = Date.now()
if (goal.tokenBudget !== null && goal.tokensUsed >= goal.tokenBudget) {
goal.status = 'budget_limited'
goalLog(
'BUDGET_LIMITED',
`tokens ${goal.tokensUsed} >= budget ${goal.tokenBudget}`,
)
} else if (sanitized > 0) {
goalLog(
'TOKENS',
`+${sanitized} → total ${goal.tokensUsed}${goal.tokenBudget ? `/${goal.tokenBudget}` : ''}`,
)
}
return goal
}
export function markUsageLimited(sessionId?: string): GoalState | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal || goal.status !== 'active') return null
goal.status = 'usage_limited'
goal.updatedAt = Date.now()
return goal
}
export function incrementGoalTurns(sessionId?: string): number {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal) return 0
goal.turnsExecuted += 1
goal.updatedAt = Date.now()
goalLog('TURN', `#${goal.turnsExecuted}/${MAX_GOAL_TURNS}`, {
status: goal.status,
tokensUsed: goal.tokensUsed,
})
return goal.turnsExecuted
}
export function recordBlockedAttempt(
reason: string,
sessionId?: string,
): { status: GoalStatus; attempts: number } | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal || goal.status !== 'active') return null
const normalised = reason.trim().toLowerCase()
if (
goal.lastBlockReason !== null &&
goal.lastBlockReason.trim().toLowerCase() !== normalised
) {
goal.blockedAttempts = 0
}
goal.lastBlockReason = reason
goal.blockedAttempts += 1
goal.updatedAt = Date.now()
if (goal.blockedAttempts >= BLOCKED_CONSECUTIVE_THRESHOLD) {
goal.status = 'blocked'
goalLog('BLOCKED', `3-strike reached! reason="${normalised}"`)
} else {
goalLog(
'BLOCK_ATTEMPT',
`attempt ${goal.blockedAttempts}/${BLOCKED_CONSECUTIVE_THRESHOLD} reason="${normalised}"`,
)
}
return { status: goal.status, attempts: goal.blockedAttempts }
}
/**
* Wall-clock time the goal has been actively worked on (excludes
* paused intervals). Used by status displays and completion reports.
*/
export function getActiveElapsedMs(goal: GoalState): number {
const ongoing =
goal.status === 'active' && goal.pausedAt === null
? Date.now() - goal.startTime
: 0
return goal.accumulatedActiveMs + ongoing
}
/** Test-only: wipe the in-memory map without touching disk. */
export function _clearAllGoalsForTesting(): void {
goals.clear()
}
/**
* Test/internal: hydrate the in-memory map from persisted state.
* Called by goalStorage on session resume.
*/
export function _setGoalFromPersistedState(
state: GoalState,
sessionId?: string,
): void {
goals.set(resolveSessionId(sessionId), state)
}
/** Format the elapsed time as "Xm Ys" / "Ys" for UI display. */
export function formatGoalElapsed(goal: GoalState): string {
const elapsedMs = getActiveElapsedMs(goal)
const seconds = Math.floor(elapsedMs / 1000)
const minutes = Math.floor(seconds / 60)
if (minutes === 0) return `${seconds}s`
return `${minutes}m ${seconds % 60}s`
}
/** Human-readable status label for UI. */
export function formatGoalStatusLabel(status: GoalStatus): string {
switch (status) {
case 'active':
return 'Active'
case 'paused':
return 'Paused'
case 'blocked':
return 'Blocked'
case 'budget_limited':
return 'Budget Limited'
case 'usage_limited':
return 'Usage Limited'
case 'max_turns':
return 'Max Turns Reached'
case 'complete':
return 'Complete'
}
} }

View File

@@ -0,0 +1,55 @@
/**
* Goal persistence bridge — connects the in-memory `goalState` map
* to the JSONL transcript that backs --resume.
*
* Splitting this off keeps goalState pure (testable without touching
* the file system) while still giving the slash command + tool a
* single call to "save the current goal".
*/
import type { UUID } from 'crypto'
import { getSessionId } from '../../bootstrap/state.js'
import type { GoalState } from '../../types/logs.js'
import {
clearGoalEntry as clearGoalEntryOnDisk,
saveGoal as saveGoalOnDisk,
} from '../../utils/sessionStorage.js'
import { _setGoalFromPersistedState, getGoal } from './goalState.js'
/**
* Snapshot the current in-memory goal for the running session to the
* JSONL transcript. Called by every mutating helper in goalState
* (set / pause / resume / complete / token update / blocked).
*
* No-op when there is no goal — used as a fire-and-forget convenience.
*/
export function persistCurrentGoal(): void {
const sessionId = getSessionId() as UUID
const goal = getGoal(sessionId)
if (!goal) return
saveGoalOnDisk(sessionId, goal)
}
/**
* Hydrate the in-memory map from a `loadTranscriptFile` result. Called
* by REPL.tsx after restoreSessionMetadata so `--resume` carries the
* goal across process restarts.
*/
export function hydrateGoalFromTranscript(
goalsMap: Map<UUID, GoalState>,
sessionId?: UUID,
): GoalState | null {
const id = (sessionId ?? (getSessionId() as UUID)) as UUID
const state = goalsMap.get(id)
if (!state) return null
_setGoalFromPersistedState(state, id)
return state
}
/**
* Persist an explicit clear — writes the `goal-cleared` tombstone so
* a future --resume cannot resurrect a stale goal entry.
*/
export function persistGoalClear(): void {
const sessionId = getSessionId() as UUID
clearGoalEntryOnDisk(sessionId)
}

View File

@@ -0,0 +1,149 @@
/**
* Goal-steering prompt templates injected into the model as meta-messages.
*
* Three templates correspond to the three CODEX steering scenarios:
*
* 1. **Continuation** — injected automatically when the agent is idle
* and a goal is active. Drives the auto-continuation loop.
*
* 2. **Budget limit** — injected once when the token budget is reached.
* Instructs the model to stop substantive work and summarize progress.
*
* 3. **Objective updated** — injected when the user changes the
* objective mid-conversation via `/goal <new>`.
*
* All templates are wrapped in `<goal-steering>` XML so the model can
* distinguish system-injected goal guidance from user conversation.
*/
import type { GoalState } from '../../types/logs.js'
import { formatGoalElapsed, getActiveElapsedMs } from './goalState.js'
function formatTokenUsage(goal: GoalState): string {
if (goal.tokenBudget !== null) {
const remaining = Math.max(0, goal.tokenBudget - goal.tokensUsed)
return `Tokens used: ${goal.tokensUsed} / ${goal.tokenBudget} (${remaining} remaining)`
}
return `Tokens used: ${goal.tokensUsed}`
}
/**
* Continuation prompt — the core auto-run steering message.
*
* Mirrors CODEX `prompts/templates/goals/continuation.md`:
* - Reminds model of the objective
* - Provides token budget status
* - Embeds completion-audit and blocked-audit rules
* - Forbids scope reduction
*/
export function buildContinuationPrompt(goal: GoalState): string {
const elapsed = formatGoalElapsed(goal)
const tokenInfo = formatTokenUsage(goal)
const turnInfo = `Continuation turns executed: ${goal.turnsExecuted}`
return `<goal-steering type="continuation">
You have an active goal to work on. Continue making progress.
## Active Goal
${goal.objective}
## Status
- Elapsed active time: ${elapsed}
- ${tokenInfo}
- ${turnInfo}
## Instructions
Continue working towards the goal. Do NOT narrow the scope of the goal — even if you cannot complete everything in one turn, maintain the full objective and make as much progress as possible.
When you believe the goal is fully achieved, use the GoalTool to mark it complete. Before doing so, perform a strict Completion Audit:
### Completion Audit
1. Derive concrete requirements from the objective and any referenced files.
2. Preserve the original scope — do not redefine success around what is already done.
3. For every explicit requirement, identify authoritative evidence (test output, file content, command result).
4. Treat tests, manifests, and verifiers as evidence only after confirming they actually cover the requirement.
5. Treat uncertain or indirect evidence as "not achieved".
6. The audit must PROVE completion, not merely fail to find remaining work.
### Blocked Audit
If you encounter an obstacle you genuinely cannot overcome:
- Do NOT mark blocked on the first encounter.
- The same blocking condition must persist for at least 3 consecutive continuation turns before you may mark blocked.
- "Difficult", "slow", or "partially incomplete" is NOT blocked.
- If blocked, use the GoalTool with status "blocked" and a clear reason.
Resume working now.
</goal-steering>`
}
/**
* Budget-limit prompt — injected once when tokensUsed >= tokenBudget.
*
* Mirrors CODEX `prompts/templates/goals/budget_limit.md`:
* - Instructs the model to stop new substantive work
* - Asks for a progress summary and what remains
*/
export function buildBudgetLimitPrompt(goal: GoalState): string {
return `<goal-steering type="budget_limit">
## Token Budget Reached
Your token budget for this goal has been exhausted.
- Goal: ${goal.objective}
- Tokens used: ${goal.tokensUsed}${goal.tokenBudget !== null ? ` / ${goal.tokenBudget}` : ''}
- Active time: ${formatGoalElapsed(goal)}
**Stop all substantive work immediately.** Do NOT start new file edits, tool calls, or explorations.
Instead, provide a brief summary:
1. What has been accomplished so far.
2. What remains to be done.
3. Any blockers or issues encountered.
Then use the GoalTool to mark the goal as complete (if truly done) or leave it in its current state for the user to decide.
</goal-steering>`
}
/**
* Objective-updated prompt — injected by the `/goal` command when the
* user replaces or sets a new objective mid-conversation.
*
* Mirrors CODEX `prompts/templates/goals/objective_updated.md`.
* Note: the `/goal` command already injects `<goal-objective-updated>`
* directly; this function provides the full steering context around it.
*/
export function buildObjectiveUpdatedPrompt(
newObjective: string,
previousObjective?: string,
): string {
const previousSection = previousObjective
? `\nPrevious objective: ${previousObjective}\n`
: ''
return `<goal-steering type="objective_updated">
The user has updated the active goal.${previousSection}
New objective: ${newObjective}
Acknowledge the updated objective and begin working towards it. All previous progress that is still relevant should be preserved, but the new objective takes priority.
Follow the same Completion Audit and Blocked Audit rules described in prior goal-steering messages.
</goal-steering>`
}
/**
* Compact summary of the goal for system prompt injection.
* Kept short to minimise prompt-cache displacement.
*/
export function buildGoalContextBlock(goal: GoalState): string {
const elapsed = formatGoalElapsed(goal)
const elapsedMs = getActiveElapsedMs(goal)
const budget =
goal.tokenBudget !== null ? ` budget="${goal.tokenBudget}"` : ''
return [
`<active-goal status="${goal.status}" elapsed="${elapsed}" elapsed_ms="${elapsedMs}" tokens="${goal.tokensUsed}"${budget} turns="${goal.turnsExecuted}">`,
goal.objective,
'</active-goal>',
].join('\n')
}

View File

@@ -87,6 +87,10 @@ import { EnterPlanModeTool } from '@claude-code-best/builtin-tools/tools/EnterPl
import { EnterWorktreeTool } from '@claude-code-best/builtin-tools/tools/EnterWorktreeTool/EnterWorktreeTool.js' import { EnterWorktreeTool } from '@claude-code-best/builtin-tools/tools/EnterWorktreeTool/EnterWorktreeTool.js'
import { ExitWorktreeTool } from '@claude-code-best/builtin-tools/tools/ExitWorktreeTool/ExitWorktreeTool.js' import { ExitWorktreeTool } from '@claude-code-best/builtin-tools/tools/ExitWorktreeTool/ExitWorktreeTool.js'
import { ConfigTool } from '@claude-code-best/builtin-tools/tools/ConfigTool/ConfigTool.js' import { ConfigTool } from '@claude-code-best/builtin-tools/tools/ConfigTool/ConfigTool.js'
const GoalTool = feature('GOAL')
? require('@claude-code-best/builtin-tools/tools/GoalTool/GoalTool.js')
.GoalTool
: null
import { LocalMemoryRecallTool } from '@claude-code-best/builtin-tools/tools/LocalMemoryRecallTool/LocalMemoryRecallTool.js' import { LocalMemoryRecallTool } from '@claude-code-best/builtin-tools/tools/LocalMemoryRecallTool/LocalMemoryRecallTool.js'
import { VaultHttpFetchTool } from '@claude-code-best/builtin-tools/tools/VaultHttpFetchTool/VaultHttpFetchTool.js' import { VaultHttpFetchTool } from '@claude-code-best/builtin-tools/tools/VaultHttpFetchTool/VaultHttpFetchTool.js'
import { TaskCreateTool } from '@claude-code-best/builtin-tools/tools/TaskCreateTool/TaskCreateTool.js' import { TaskCreateTool } from '@claude-code-best/builtin-tools/tools/TaskCreateTool/TaskCreateTool.js'
@@ -238,6 +242,7 @@ export function getAllBaseTools(): Tools {
LocalMemoryRecallTool, LocalMemoryRecallTool,
VaultHttpFetchTool, VaultHttpFetchTool,
...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []), ...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),
...(GoalTool ? [GoalTool] : []),
...(process.env.USER_TYPE === 'ant' ? [TungstenTool] : []), ...(process.env.USER_TYPE === 'ant' ? [TungstenTool] : []),
...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []), ...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []),
...(WebBrowserTool ? [WebBrowserTool] : []), ...(WebBrowserTool ? [WebBrowserTool] : []),

View File

@@ -122,6 +122,8 @@ export type LocalJSXCommandOnDone = (
metaMessages?: string[] metaMessages?: string[]
nextInput?: string nextInput?: string
submitNextInput?: boolean submitNextInput?: boolean
/** Override the args shown in the command breadcrumb (e.g. truncated). Full args still reach metaMessages. */
displayArgs?: string
}, },
) => void ) => void

View File

@@ -50,6 +50,7 @@ export type LogOption = {
mode?: 'coordinator' | 'normal' // Session mode for coordinator/normal detection mode?: 'coordinator' | 'normal' // Session mode for coordinator/normal detection
worktreeSession?: PersistedWorktreeSession | null // Worktree state at session end (null = exited, undefined = never entered) worktreeSession?: PersistedWorktreeSession | null // Worktree state at session end (null = exited, undefined = never entered)
contentReplacements?: ContentReplacementRecord[] // Replacement decisions for resume reconstruction contentReplacements?: ContentReplacementRecord[] // Replacement decisions for resume reconstruction
goal?: GoalState // Active goal state at session end (for resume)
} }
export type SummaryMessage = { export type SummaryMessage = {
@@ -140,6 +141,77 @@ export type ModeEntry = {
mode: 'coordinator' | 'normal' mode: 'coordinator' | 'normal'
} }
/**
* Lifecycle states for a persistent thread goal.
* - active: agent should auto-continue toward the objective
* - paused: user temporarily halted progress
* - blocked: model reported the same blocker for >=3 consecutive turns
* - budget_limited: tokensUsed >= tokenBudget (auto-transition)
* - usage_limited: provider rate/usage limit triggered (auto-transition)
* - max_turns: auto-continuation reached MAX_GOAL_TURNS safety cap
* - complete: model audit confirmed objective achieved
*/
export type GoalStatus =
| 'active'
| 'paused'
| 'blocked'
| 'budget_limited'
| 'usage_limited'
| 'max_turns'
| 'complete'
/**
* Per-session goal state. Persisted to the JSONL transcript as a `goal`
* entry on every mutation; last-wins on read.
*
* Timing fields handle pause correctly: `getActiveElapsedMs(state)`
* = accumulatedActiveMs + (now - startTime if active, else 0).
*
* `turnsExecuted` is a defensive upper bound for the auto-continuation
* loop so a runaway goal cannot spin indefinitely.
*
* `blockedAttempts` + `lastBlockReason` implement CODEX's "blocked
* only after 3 consecutive same-reason attempts" audit rule.
*/
export type GoalState = {
objective: string
status: GoalStatus
tokenBudget: number | null
tokensUsed: number
startTime: number
pausedAt: number | null
accumulatedActiveMs: number
blockedAttempts: number
lastBlockReason: string | null
createdAt: number
updatedAt: number
turnsExecuted: number
}
/**
* JSONL entry representing a goal-state checkpoint. Written on every
* mutation (set / pause / resume / complete / token update). Readers
* use the latest entry by sessionId as the authoritative state.
*/
export type GoalMetadataEntry = {
type: 'goal'
sessionId: UUID
state: GoalState
timestamp: string
}
/**
* JSONL entry signalling the user explicitly cleared the goal.
* Distinct from `complete` (which preserves the achievement). Readers
* encountering this entry after a `goal` entry should treat the goal
* as absent.
*/
export type GoalClearedEntry = {
type: 'goal-cleared'
sessionId: UUID
timestamp: string
}
/** /**
* Worktree session state persisted to the transcript for resume. * Worktree session state persisted to the transcript for resume.
* Subset of WorktreeSession from utils/worktree.ts — excludes ephemeral * Subset of WorktreeSession from utils/worktree.ts — excludes ephemeral
@@ -315,6 +387,8 @@ export type Entry =
| ContentReplacementEntry | ContentReplacementEntry
| ContextCollapseCommitEntry | ContextCollapseCommitEntry
| ContextCollapseSnapshotEntry | ContextCollapseSnapshotEntry
| GoalMetadataEntry
| GoalClearedEntry
export function sortLogs(logs: LogOption[]): LogOption[] { export function sortLogs(logs: LogOption[]): LogOption[] {
return logs.sort((a, b) => { return logs.sort((a, b) => {

View File

@@ -507,6 +507,8 @@ export async function loadConversationForResume(
prRepository?: string prRepository?: string
// Full path to the session file (for cross-directory resume) // Full path to the session file (for cross-directory resume)
fullPath?: string fullPath?: string
// Goal state for hydration on resume
goal?: import('../types/logs.js').GoalState
} | null> { } | null> {
try { try {
let log: LogOption | null = null let log: LogOption | null = null
@@ -618,6 +620,8 @@ export async function loadConversationForResume(
prRepository: log?.prRepository, prRepository: log?.prRepository,
// Include full path for cross-directory resume // Include full path for cross-directory resume
fullPath: log?.fullPath, fullPath: log?.fullPath,
// Goal state for hydration on resume
goal: log?.goal,
} }
} catch (error) { } catch (error) {
logError(error as Error) logError(error as Error)

View File

@@ -740,6 +740,7 @@ async function getMessagesForSlashCommand(
metaMessages?: string[]; metaMessages?: string[];
nextInput?: string; nextInput?: string;
submitNextInput?: boolean; submitNextInput?: boolean;
displayArgs?: string;
}, },
) => { ) => {
doneWasCalled = true; doneWasCalled = true;
@@ -773,20 +774,22 @@ async function getMessagesForSlashCommand(
const skipTranscript = const skipTranscript =
isFullscreenEnvEnabled() && typeof result === 'string' && result.endsWith(' dismissed'); isFullscreenEnvEnabled() && typeof result === 'string' && result.endsWith(' dismissed');
const breadcrumbArgs = options?.displayArgs ?? args;
void resolve({ void resolve({
messages: messages:
options?.display === 'system' options?.display === 'system'
? skipTranscript ? skipTranscript
? metaMessages ? metaMessages
: [ : [
createCommandInputMessage(formatCommandInput(command, args)), createCommandInputMessage(formatCommandInput(command, breadcrumbArgs)),
createCommandInputMessage(`<local-command-stdout>${result}</local-command-stdout>`), createCommandInputMessage(`<local-command-stdout>${result}</local-command-stdout>`),
...metaMessages, ...metaMessages,
] ]
: [ : [
createUserMessage({ createUserMessage({
content: prepareUserContent({ content: prepareUserContent({
inputString: formatCommandInput(command, args), inputString: formatCommandInput(command, breadcrumbArgs),
precedingInputBlocks, precedingInputBlocks,
}), }),
}), }),

View File

@@ -146,9 +146,10 @@ export async function processUserInput({
}): Promise<ProcessUserInputBaseResult> { }): Promise<ProcessUserInputBaseResult> {
const inputString = typeof input === 'string' ? input : null const inputString = typeof input === 'string' ? input : null
// Immediately show the user input prompt while we are still processing the input. // Immediately show the user input prompt while we are still processing the input.
// Skip for isMeta (system-generated prompts like scheduled tasks) — those // Skip for isMeta (system-generated prompts like scheduled tasks) and slash
// should run invisibly. // commands (they produce their own system message echo via createCommandInputMessage).
if (mode === 'prompt' && inputString !== null && !isMeta) { const isSlashInput = inputString?.startsWith('/') && !skipSlashCommands
if (mode === 'prompt' && inputString !== null && !isMeta && !isSlashInput) {
setUserInputOnProcessing?.(inputString) setUserInputOnProcessing?.(inputString)
} }

View File

@@ -312,6 +312,7 @@ type ResumeLoadResult = {
prNumber?: number prNumber?: number
prUrl?: string prUrl?: string
prRepository?: string prRepository?: string
goal?: import('../types/logs.js').GoalState
} }
/** /**
@@ -471,6 +472,18 @@ export async function processResumedConversation(
opts.forkSession ? { ...result, worktreeSession: undefined } : result, opts.forkSession ? { ...result, worktreeSession: undefined } : result,
) )
if (feature('GOAL') && result.goal) {
const { hydrateGoalFromTranscript } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js')
const goalsMap = new Map<UUID, import('../types/logs.js').GoalState>()
const sid = (opts.sessionIdOverride ??
result.sessionId ??
getSessionId()) as UUID
goalsMap.set(sid, result.goal)
hydrateGoalFromTranscript(goalsMap, sid)
}
if (!opts.forkSession) { if (!opts.forkSession) {
// Cd back into the worktree the session was in when it last exited. // Cd back into the worktree the session was in when it last exited.
// Done after restoreSessionMetadata (which caches the worktree state // Done after restoreSessionMetadata (which caches the worktree state

View File

@@ -48,6 +48,7 @@ import {
type ContextCollapseSnapshotEntry, type ContextCollapseSnapshotEntry,
type Entry, type Entry,
type FileHistorySnapshotMessage, type FileHistorySnapshotMessage,
type GoalState,
type LogOption, type LogOption,
type PersistedWorktreeSession, type PersistedWorktreeSession,
type SerializedMessage, type SerializedMessage,
@@ -542,6 +543,7 @@ class Project {
currentSessionLastPrompt: string | undefined currentSessionLastPrompt: string | undefined
currentSessionAgentSetting: string | undefined currentSessionAgentSetting: string | undefined
currentSessionMode: 'coordinator' | 'normal' | undefined currentSessionMode: 'coordinator' | 'normal' | undefined
currentSessionGoal: GoalState | undefined
// Tri-state: undefined = never touched (don't write), null = exited worktree, // Tri-state: undefined = never touched (don't write), null = exited worktree,
// object = currently in worktree. reAppendSessionMetadata writes null so // object = currently in worktree. reAppendSessionMetadata writes null so
// --resume knows the session exited (vs. crashed while inside). // --resume knows the session exited (vs. crashed while inside).
@@ -827,6 +829,14 @@ class Project {
sessionId, sessionId,
}) })
} }
if (this.currentSessionGoal) {
appendEntryToFile(this.sessionFile, {
type: 'goal',
sessionId,
state: this.currentSessionGoal,
timestamp: new Date().toISOString(),
})
}
if (this.currentSessionWorktree !== undefined) { if (this.currentSessionWorktree !== undefined) {
appendEntryToFile(this.sessionFile, { appendEntryToFile(this.sessionFile, {
type: 'worktree-state', type: 'worktree-state',
@@ -1226,6 +1236,10 @@ class Project {
} else if (entry.type === 'marble-origami-snapshot') { } else if (entry.type === 'marble-origami-snapshot') {
// Always append. Last-wins on restore — later entries supersede. // Always append. Last-wins on restore — later entries supersede.
void this.enqueueWrite(sessionFile, entry) void this.enqueueWrite(sessionFile, entry)
} else if (entry.type === 'goal') {
void this.enqueueWrite(sessionFile, entry)
} else if (entry.type === 'goal-cleared') {
void this.enqueueWrite(sessionFile, entry)
} else { } else {
const messageSet = await getSessionMessages(sessionId) const messageSet = await getSessionMessages(sessionId)
if (entry.type === 'queue-operation') { if (entry.type === 'queue-operation') {
@@ -2723,6 +2737,48 @@ export async function saveTag(sessionId: UUID, tag: string, fullPath?: string) {
logEvent('tengu_session_tagged', {}) logEvent('tengu_session_tagged', {})
} }
/**
* Persist a goal-state checkpoint to the JSONL transcript. Called by
* src/services/goal/goalStorage.ts on every mutation. The latest entry
* wins on read; older entries are harmlessly ignored.
*
* Cached on Project so reAppendSessionMetadata can keep the goal alive
* past compaction's tail-read window.
*/
export function saveGoal(
sessionId: UUID,
state: GoalState,
fullPath?: string,
): void {
const resolvedPath = fullPath ?? getTranscriptPathForSession(sessionId)
appendEntryToFile(resolvedPath, {
type: 'goal',
sessionId,
state,
timestamp: new Date().toISOString(),
})
if (sessionId === getSessionId()) {
getProject().currentSessionGoal = state
}
}
/**
* Persist a "goal cleared" tombstone so a future --resume cannot
* resurrect the goal from a prior `goal` entry. Also drops the
* in-memory cache for the current session.
*/
export function clearGoalEntry(sessionId: UUID, fullPath?: string): void {
const resolvedPath = fullPath ?? getTranscriptPathForSession(sessionId)
appendEntryToFile(resolvedPath, {
type: 'goal-cleared',
sessionId,
timestamp: new Date().toISOString(),
})
if (sessionId === getSessionId()) {
getProject().currentSessionGoal = undefined
}
}
/** /**
* Link a session to a GitHub pull request. * Link a session to a GitHub pull request.
* This stores the PR number, URL, and repository for tracking and navigation. * This stores the PR number, URL, and repository for tracking and navigation.
@@ -2791,6 +2847,7 @@ export function restoreSessionMetadata(meta: {
prNumber?: number prNumber?: number
prUrl?: string prUrl?: string
prRepository?: string prRepository?: string
goal?: GoalState
}): void { }): void {
const project = getProject() const project = getProject()
// ??= so --name (cacheSessionTitle) wins over the resumed // ??= so --name (cacheSessionTitle) wins over the resumed
@@ -2807,6 +2864,7 @@ export function restoreSessionMetadata(meta: {
project.currentSessionPrNumber = meta.prNumber project.currentSessionPrNumber = meta.prNumber
if (meta.prUrl) project.currentSessionPrUrl = meta.prUrl if (meta.prUrl) project.currentSessionPrUrl = meta.prUrl
if (meta.prRepository) project.currentSessionPrRepository = meta.prRepository if (meta.prRepository) project.currentSessionPrRepository = meta.prRepository
if (meta.goal) project.currentSessionGoal = meta.goal
} }
/** /**
@@ -2823,6 +2881,7 @@ export function clearSessionMetadata(): void {
project.currentSessionLastPrompt = undefined project.currentSessionLastPrompt = undefined
project.currentSessionAgentSetting = undefined project.currentSessionAgentSetting = undefined
project.currentSessionMode = undefined project.currentSessionMode = undefined
project.currentSessionGoal = undefined
project.currentSessionWorktree = undefined project.currentSessionWorktree = undefined
project.currentSessionPrNumber = undefined project.currentSessionPrNumber = undefined
project.currentSessionPrUrl = undefined project.currentSessionPrUrl = undefined
@@ -2997,6 +3056,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
prRepositories, prRepositories,
modes, modes,
worktreeStates, worktreeStates,
goals,
fileHistorySnapshots, fileHistorySnapshots,
attributionSnapshots, attributionSnapshots,
contentReplacements, contentReplacements,
@@ -3006,7 +3066,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
} = await loadTranscriptFile(sessionFile) } = await loadTranscriptFile(sessionFile)
if (messages.size === 0) { if (messages.size === 0) {
return log const fallbackGoal = log.sessionId
? goals.get(log.sessionId as UUID)
: undefined
return fallbackGoal ? { ...log, goal: fallbackGoal } : log
} }
// Find the most recent user/assistant leaf message from the transcript // Find the most recent user/assistant leaf message from the transcript
@@ -3017,7 +3080,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
(msg.type === 'user' || msg.type === 'assistant'), (msg.type === 'user' || msg.type === 'assistant'),
) )
if (!mostRecentLeaf) { if (!mostRecentLeaf) {
return log const fallbackGoal = log.sessionId
? goals.get(log.sessionId as UUID)
: undefined
return fallbackGoal ? { ...log, goal: fallbackGoal } : log
} }
// Build the conversation chain from this leaf // Build the conversation chain from this leaf
@@ -3043,6 +3109,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
sessionId && worktreeStates.has(sessionId) sessionId && worktreeStates.has(sessionId)
? worktreeStates.get(sessionId) ? worktreeStates.get(sessionId)
: log.worktreeSession, : log.worktreeSession,
goal: sessionId ? goals.get(sessionId) : log.goal,
prNumber: sessionId ? prNumbers.get(sessionId) : log.prNumber, prNumber: sessionId ? prNumbers.get(sessionId) : log.prNumber,
prUrl: sessionId ? prUrls.get(sessionId) : log.prUrl, prUrl: sessionId ? prUrls.get(sessionId) : log.prUrl,
prRepository: sessionId prRepository: sessionId
@@ -3144,6 +3211,8 @@ const METADATA_TYPE_MARKERS = [
'"type":"agent-setting"', '"type":"agent-setting"',
'"type":"mode"', '"type":"mode"',
'"type":"worktree-state"', '"type":"worktree-state"',
'"type":"goal"',
'"type":"goal-cleared"',
'"type":"pr-link"', '"type":"pr-link"',
] ]
const METADATA_MARKER_BUFS = METADATA_TYPE_MARKERS.map(m => Buffer.from(m)) const METADATA_MARKER_BUFS = METADATA_TYPE_MARKERS.map(m => Buffer.from(m))
@@ -3510,6 +3579,7 @@ export async function loadTranscriptFile(
prRepositories: Map<UUID, string> prRepositories: Map<UUID, string>
modes: Map<UUID, string> modes: Map<UUID, string>
worktreeStates: Map<UUID, PersistedWorktreeSession | null> worktreeStates: Map<UUID, PersistedWorktreeSession | null>
goals: Map<UUID, GoalState>
fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage> fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage>
attributionSnapshots: Map<UUID, AttributionSnapshotMessage> attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
contentReplacements: Map<UUID, ContentReplacementRecord[]> contentReplacements: Map<UUID, ContentReplacementRecord[]>
@@ -3530,6 +3600,7 @@ export async function loadTranscriptFile(
const prRepositories = new Map<UUID, string>() const prRepositories = new Map<UUID, string>()
const modes = new Map<UUID, string>() const modes = new Map<UUID, string>()
const worktreeStates = new Map<UUID, PersistedWorktreeSession | null>() const worktreeStates = new Map<UUID, PersistedWorktreeSession | null>()
const goals = new Map<UUID, GoalState>()
const fileHistorySnapshots = new Map<UUID, FileHistorySnapshotMessage>() const fileHistorySnapshots = new Map<UUID, FileHistorySnapshotMessage>()
const attributionSnapshots = new Map<UUID, AttributionSnapshotMessage>() const attributionSnapshots = new Map<UUID, AttributionSnapshotMessage>()
const contentReplacements = new Map<UUID, ContentReplacementRecord[]>() const contentReplacements = new Map<UUID, ContentReplacementRecord[]>()
@@ -3628,6 +3699,10 @@ export async function loadTranscriptFile(
modes.set(entry.sessionId, entry.mode) modes.set(entry.sessionId, entry.mode)
} else if (entry.type === 'worktree-state' && entry.sessionId) { } else if (entry.type === 'worktree-state' && entry.sessionId) {
worktreeStates.set(entry.sessionId, entry.worktreeSession) worktreeStates.set(entry.sessionId, entry.worktreeSession)
} else if (entry.type === 'goal' && entry.sessionId) {
goals.set(entry.sessionId, entry.state)
} else if (entry.type === 'goal-cleared' && entry.sessionId) {
goals.delete(entry.sessionId)
} else if (entry.type === 'pr-link' && entry.sessionId) { } else if (entry.type === 'pr-link' && entry.sessionId) {
prNumbers.set(entry.sessionId, entry.prNumber) prNumbers.set(entry.sessionId, entry.prNumber)
prUrls.set(entry.sessionId, entry.prUrl) prUrls.set(entry.sessionId, entry.prUrl)
@@ -3696,6 +3771,10 @@ export async function loadTranscriptFile(
modes.set(entry.sessionId, entry.mode) modes.set(entry.sessionId, entry.mode)
} else if (entry.type === 'worktree-state' && entry.sessionId) { } else if (entry.type === 'worktree-state' && entry.sessionId) {
worktreeStates.set(entry.sessionId, entry.worktreeSession) worktreeStates.set(entry.sessionId, entry.worktreeSession)
} else if (entry.type === 'goal' && entry.sessionId) {
goals.set(entry.sessionId, entry.state)
} else if (entry.type === 'goal-cleared' && entry.sessionId) {
goals.delete(entry.sessionId)
} else if (entry.type === 'pr-link' && entry.sessionId) { } else if (entry.type === 'pr-link' && entry.sessionId) {
prNumbers.set(entry.sessionId, entry.prNumber) prNumbers.set(entry.sessionId, entry.prNumber)
prUrls.set(entry.sessionId, entry.prUrl) prUrls.set(entry.sessionId, entry.prUrl)
@@ -3827,6 +3906,7 @@ export async function loadTranscriptFile(
prRepositories, prRepositories,
modes, modes,
worktreeStates, worktreeStates,
goals,
fileHistorySnapshots, fileHistorySnapshots,
attributionSnapshots, attributionSnapshots,
contentReplacements, contentReplacements,
@@ -3847,6 +3927,7 @@ async function loadSessionFile(sessionId: UUID): Promise<{
tags: Map<UUID, string> tags: Map<UUID, string>
agentSettings: Map<UUID, string> agentSettings: Map<UUID, string>
worktreeStates: Map<UUID, PersistedWorktreeSession | null> worktreeStates: Map<UUID, PersistedWorktreeSession | null>
goals: Map<UUID, GoalState>
fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage> fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage>
attributionSnapshots: Map<UUID, AttributionSnapshotMessage> attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
contentReplacements: Map<UUID, ContentReplacementRecord[]> contentReplacements: Map<UUID, ContentReplacementRecord[]>
@@ -3905,6 +3986,7 @@ export async function getLastSessionLog(
fileHistorySnapshots, fileHistorySnapshots,
attributionSnapshots, attributionSnapshots,
contentReplacements, contentReplacements,
goals,
contextCollapseCommits, contextCollapseCommits,
contextCollapseSnapshot, contextCollapseSnapshot,
} = await loadSessionFile(sessionId) } = await loadSessionFile(sessionId)
@@ -3946,6 +4028,7 @@ export async function getLastSessionLog(
contentReplacements.get(sessionId) ?? [], contentReplacements.get(sessionId) ?? [],
), ),
worktreeSession: worktreeStates.get(sessionId), worktreeSession: worktreeStates.get(sessionId),
goal: goals.get(sessionId),
contextCollapseCommits: contextCollapseCommits.filter( contextCollapseCommits: contextCollapseCommits.filter(
e => e.sessionId === sessionId, e => e.sessionId === sessionId,
), ),

View File

@@ -0,0 +1,289 @@
/**
* Integration test for the goal lifecycle.
* Verifies set → work → complete flow, pause/resume, budget limiting,
* blocked attempts, prompt generation, and audit rules consistency.
*/
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { logMock } from '../mocks/log.js'
mock.module('src/utils/log.ts', logMock)
mock.module('bun:bundle', () => ({
feature: () => true,
}))
import {
setGoal,
getGoal,
clearGoal,
pauseGoal,
resumeGoal,
completeGoal,
updateGoalTokens,
markUsageLimited,
incrementGoalTurns,
recordBlockedAttempt,
formatGoalElapsed,
formatGoalStatusLabel,
getActiveElapsedMs,
_clearAllGoalsForTesting,
BLOCKED_CONSECUTIVE_THRESHOLD,
MAX_GOAL_TURNS,
} from '../../src/services/goal/goalState'
import {
buildContinuationPrompt,
buildBudgetLimitPrompt,
buildObjectiveUpdatedPrompt,
buildGoalContextBlock,
} from '../../src/services/goal/prompts'
import {
COMPLETION_AUDIT_RULES,
BLOCKED_AUDIT_RULES,
isGoalTerminal,
} from '../../src/services/goal/goalAudit'
const TEST_SESSION = 'test-integration-session'
beforeEach(() => {
_clearAllGoalsForTesting()
})
describe('Goal lifecycle: set → work → complete', () => {
test('full happy path', () => {
const goal = setGoal('Implement feature X with tests', {
tokenBudget: 100_000,
sessionId: TEST_SESSION,
})
expect(goal.status).toBe('active')
expect(goal.objective).toBe('Implement feature X with tests')
expect(goal.tokenBudget).toBe(100_000)
expect(goal.tokensUsed).toBe(0)
expect(goal.turnsExecuted).toBe(0)
updateGoalTokens(15_000, TEST_SESSION)
incrementGoalTurns(TEST_SESSION)
updateGoalTokens(20_000, TEST_SESSION)
incrementGoalTurns(TEST_SESSION)
const mid = getGoal(TEST_SESSION)!
expect(mid.tokensUsed).toBe(35_000)
expect(mid.turnsExecuted).toBe(2)
expect(mid.status).toBe('active')
const completed = completeGoal(TEST_SESSION)!
expect(completed.status).toBe('complete')
expect(completed.tokensUsed).toBe(35_000)
expect(getGoal(TEST_SESSION)).not.toBeNull()
expect(clearGoal(TEST_SESSION)).toBe(true)
expect(getGoal(TEST_SESSION)).toBeNull()
})
})
describe('Goal lifecycle: pause and resume', () => {
test('pause accumulates active time, resume resets start', () => {
setGoal('Refactor module', { sessionId: TEST_SESSION })
const paused = pauseGoal(TEST_SESSION)!
expect(paused.status).toBe('paused')
expect(paused.pausedAt).not.toBeNull()
const resumed = resumeGoal(TEST_SESSION)!
expect(resumed.status).toBe('active')
expect(resumed.pausedAt).toBeNull()
expect(resumed.blockedAttempts).toBe(0)
})
test('pause on non-active goal is no-op', () => {
setGoal('Something', { sessionId: TEST_SESSION })
completeGoal(TEST_SESSION)
expect(pauseGoal(TEST_SESSION)).toBeNull()
})
test('resume on non-paused goal is no-op', () => {
setGoal('Something', { sessionId: TEST_SESSION })
expect(resumeGoal(TEST_SESSION)).toBeNull()
})
})
describe('Goal lifecycle: budget limiting', () => {
test('exceeding budget transitions to budget_limited', () => {
setGoal('Big task', {
tokenBudget: 50_000,
sessionId: TEST_SESSION,
})
updateGoalTokens(30_000, TEST_SESSION)
expect(getGoal(TEST_SESSION)!.status).toBe('active')
updateGoalTokens(25_000, TEST_SESSION)
expect(getGoal(TEST_SESSION)!.status).toBe('budget_limited')
expect(getGoal(TEST_SESSION)!.tokensUsed).toBe(55_000)
})
test('budget_limited is terminal', () => {
expect(isGoalTerminal('budget_limited')).toBe(true)
})
})
describe('Goal lifecycle: usage limiting', () => {
test('markUsageLimited transitions active → usage_limited', () => {
setGoal('Rate limited task', { sessionId: TEST_SESSION })
markUsageLimited(TEST_SESSION)
expect(getGoal(TEST_SESSION)!.status).toBe('usage_limited')
})
test('usage_limited is terminal', () => {
expect(isGoalTerminal('usage_limited')).toBe(true)
})
})
describe('Goal lifecycle: blocked attempts', () => {
test('3 consecutive same-reason attempts transition to blocked', () => {
setGoal('Need credentials', { sessionId: TEST_SESSION })
const r1 = recordBlockedAttempt('missing API key', TEST_SESSION)!
expect(r1.status).toBe('active')
expect(r1.attempts).toBe(1)
const r2 = recordBlockedAttempt('missing API key', TEST_SESSION)!
expect(r2.status).toBe('active')
expect(r2.attempts).toBe(2)
const r3 = recordBlockedAttempt('missing API key', TEST_SESSION)!
expect(r3.status).toBe('blocked')
expect(r3.attempts).toBe(3)
})
test('different reason resets counter', () => {
setGoal('Flaky thing', { sessionId: TEST_SESSION })
recordBlockedAttempt('error A', TEST_SESSION)
recordBlockedAttempt('error A', TEST_SESSION)
const r = recordBlockedAttempt('error B', TEST_SESSION)!
expect(r.status).toBe('active')
expect(r.attempts).toBe(1)
})
test('resume resets blocked attempts', () => {
setGoal('Was stuck', { sessionId: TEST_SESSION })
recordBlockedAttempt('oops', TEST_SESSION)
recordBlockedAttempt('oops', TEST_SESSION)
pauseGoal(TEST_SESSION)
resumeGoal(TEST_SESSION)
expect(getGoal(TEST_SESSION)!.blockedAttempts).toBe(0)
})
test('BLOCKED_CONSECUTIVE_THRESHOLD is 3', () => {
expect(BLOCKED_CONSECUTIVE_THRESHOLD).toBe(3)
})
})
describe('Goal lifecycle: turn limits', () => {
test('MAX_GOAL_TURNS is a reasonable upper bound', () => {
expect(MAX_GOAL_TURNS).toBeGreaterThanOrEqual(10)
expect(MAX_GOAL_TURNS).toBeLessThanOrEqual(200)
})
test('incrementGoalTurns counts correctly', () => {
setGoal('Counting', { sessionId: TEST_SESSION })
for (let i = 1; i <= 5; i++) {
expect(incrementGoalTurns(TEST_SESSION)).toBe(i)
}
expect(getGoal(TEST_SESSION)!.turnsExecuted).toBe(5)
})
})
describe('isGoalTerminal', () => {
test('active and paused are NOT terminal', () => {
expect(isGoalTerminal('active')).toBe(false)
expect(isGoalTerminal('paused')).toBe(false)
})
test('complete, blocked, budget_limited, usage_limited are terminal', () => {
expect(isGoalTerminal('complete')).toBe(true)
expect(isGoalTerminal('blocked')).toBe(true)
expect(isGoalTerminal('budget_limited')).toBe(true)
expect(isGoalTerminal('usage_limited')).toBe(true)
})
})
describe('Goal prompt templates', () => {
test('continuation prompt contains objective and audit rules', () => {
const goal = setGoal('Build dashboard', {
tokenBudget: 200_000,
sessionId: TEST_SESSION,
})
const prompt = buildContinuationPrompt(goal)
expect(prompt).toContain('Build dashboard')
expect(prompt).toContain('goal-steering')
expect(prompt).toContain('continuation')
expect(prompt).toContain('Completion Audit')
expect(prompt).toContain('Blocked Audit')
expect(prompt).toContain('200000')
})
test('budget limit prompt instructs stop', () => {
const goal = setGoal('Over budget', {
tokenBudget: 50_000,
sessionId: TEST_SESSION,
})
updateGoalTokens(60_000, TEST_SESSION)
const updated = getGoal(TEST_SESSION)!
const prompt = buildBudgetLimitPrompt(updated)
expect(prompt).toContain('budget_limit')
expect(prompt).toContain('Stop all substantive work')
expect(prompt).toContain('60000')
})
test('objective updated prompt contains new objective', () => {
const prompt = buildObjectiveUpdatedPrompt('New objective', 'Old objective')
expect(prompt).toContain('objective_updated')
expect(prompt).toContain('New objective')
expect(prompt).toContain('Old objective')
})
test('goal context block is compact', () => {
const goal = setGoal('Short task', { sessionId: TEST_SESSION })
const block = buildGoalContextBlock(goal)
expect(block).toContain('<active-goal')
expect(block).toContain('Short task')
expect(block).toContain('</active-goal>')
expect(block.split('\n').length).toBeLessThanOrEqual(5)
})
})
describe('Audit rules consistency', () => {
test('completion audit has 6 rules', () => {
expect(COMPLETION_AUDIT_RULES.length).toBe(6)
})
test('blocked audit has 3 rules', () => {
expect(BLOCKED_AUDIT_RULES.length).toBe(3)
})
test('continuation prompt embeds all completion audit rules', () => {
const goal = setGoal('Audit check', { sessionId: TEST_SESSION })
const prompt = buildContinuationPrompt(goal)
for (const rule of COMPLETION_AUDIT_RULES) {
expect(prompt).toContain(rule)
}
})
})
describe('Format helpers', () => {
test('formatGoalStatusLabel returns human-readable labels', () => {
expect(formatGoalStatusLabel('active')).toBe('Active')
expect(formatGoalStatusLabel('budget_limited')).toBe('Budget Limited')
expect(formatGoalStatusLabel('complete')).toBe('Complete')
})
test('getActiveElapsedMs returns accumulated time for paused goals', () => {
const goal = setGoal('Timed', { sessionId: TEST_SESSION })
const elapsed = getActiveElapsedMs(goal)
expect(elapsed).toBeGreaterThanOrEqual(0)
})
})