mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 04:45:51 +00:00
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:
@@ -20,6 +20,7 @@ export { FileEditTool } from './tools/FileEditTool/FileEditTool.js'
|
||||
export { FileReadTool } from './tools/FileReadTool/FileReadTool.js'
|
||||
export { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js'
|
||||
export { GlobTool } from './tools/GlobTool/GlobTool.js'
|
||||
export { GoalTool } from './tools/GoalTool/GoalTool.js'
|
||||
export { GrepTool } from './tools/GrepTool/GrepTool.js'
|
||||
export { LSPTool } from './tools/LSPTool/LSPTool.js'
|
||||
export { ListMcpResourcesTool } from './tools/ListMcpResourcesTool/ListMcpResourcesTool.js'
|
||||
|
||||
253
packages/builtin-tools/src/tools/GoalTool/GoalTool.ts
Normal file
253
packages/builtin-tools/src/tools/GoalTool/GoalTool.ts
Normal 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>)
|
||||
1
packages/builtin-tools/src/tools/GoalTool/constants.ts
Normal file
1
packages/builtin-tools/src/tools/GoalTool/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const GOAL_TOOL_NAME = 'GoalTool'
|
||||
38
packages/builtin-tools/src/tools/GoalTool/prompt.ts
Normal file
38
packages/builtin-tools/src/tools/GoalTool/prompt.ts
Normal 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).`
|
||||
}
|
||||
@@ -95,4 +95,7 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
|
||||
// Autofix PR
|
||||
'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
|
||||
|
||||
@@ -162,6 +162,11 @@ const poor = feature('POOR')
|
||||
require('./commands/poor/index.js') as typeof import('./commands/poor/index.js')
|
||||
).default
|
||||
: 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 */
|
||||
import thinkback from './commands/thinkback/index.js'
|
||||
import thinkbackPlay from './commands/thinkback-play/index.js'
|
||||
@@ -362,6 +367,7 @@ const COMMANDS = memoize((): Command[] => [
|
||||
...(forkCmd ? [forkCmd] : []),
|
||||
...(buddy ? [buddy] : []),
|
||||
...(poor ? [poor] : []),
|
||||
...(goalCmd ? [goalCmd] : []),
|
||||
...(proactive ? [proactive] : []),
|
||||
...(monitorCmd ? [monitorCmd] : []),
|
||||
...(coordinatorCmd ? [coordinatorCmd] : []),
|
||||
|
||||
75
src/commands/goal/GoalReplaceConfirmDialog.tsx
Normal file
75
src/commands/goal/GoalReplaceConfirmDialog.tsx
Normal 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
207
src/commands/goal/goal.tsx
Normal 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',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
13
src/commands/goal/index.ts
Normal file
13
src/commands/goal/index.ts
Normal 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
|
||||
@@ -45,6 +45,7 @@ import { isVimModeEnabled } from './PromptInput/utils.js';
|
||||
import { computeHitRate, tokenSignature } from '../utils/cacheStats.js';
|
||||
import { onResponse as cacheOnResponse, getCacheStatsState, initCacheStatsState } from '../utils/cacheStatsState.js';
|
||||
import { BuiltinStatusLine } from './BuiltinStatusLine.js';
|
||||
import { formatTokens } from 'src/utils/format.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 {
|
||||
// Assistant mode: statusline fields (model, permission mode, cwd) reflect the
|
||||
// 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()}
|
||||
rateLimits={builtinRateLimits}
|
||||
/>
|
||||
<GoalPill />
|
||||
<CachePill messages={messagesRef.current} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BetaUsage as Usage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import { feature } from 'bun:bundle'
|
||||
import chalk from 'chalk'
|
||||
import {
|
||||
addToTotalCostState,
|
||||
@@ -282,6 +283,24 @@ export function addToTotalSessionCost(
|
||||
): number {
|
||||
const modelUsage = addToTotalModelUsage(cost, usage, 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 =
|
||||
isFastModeEnabled() && usage.speed === 'fast'
|
||||
|
||||
192
src/hooks/useGoalContinuation.ts
Normal file
192
src/hooks/useGoalContinuation.ts
Normal 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,
|
||||
])
|
||||
}
|
||||
@@ -355,6 +355,9 @@ const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
|
||||
const useProactive =
|
||||
feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : 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')
|
||||
? require('../hooks/useMasterMonitor.js').useMasterMonitor
|
||||
: () => undefined;
|
||||
@@ -1133,6 +1136,12 @@ export function REPL({
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
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,
|
||||
// read in the onQuery finally block to notify mobile clients that a turn ended.
|
||||
const sendBridgeResultRef = useRef<() => void>(() => {});
|
||||
@@ -2220,6 +2229,16 @@ export function REPL({
|
||||
// cached name and write it to the wrong transcript on first message.
|
||||
clearSessionMetadata();
|
||||
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
|
||||
// (same reasoning as the useRef seed), and the previous session's
|
||||
// Haiku title shouldn't carry over.
|
||||
@@ -2523,6 +2542,24 @@ export function REPL({
|
||||
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();
|
||||
skipIdleCheckRef.current = false;
|
||||
|
||||
@@ -3162,6 +3199,43 @@ export function REPL({
|
||||
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.
|
||||
if (feature('UDS_INBOX') && newMessage.type === 'assistant') {
|
||||
// Extract text from content blocks (API format)
|
||||
@@ -3550,6 +3624,7 @@ export function REPL({
|
||||
|
||||
try {
|
||||
pipeReturnHadErrorRef.current = false;
|
||||
setWasAborted(false);
|
||||
// isLoading is derived from queryGuard — tryStart() above already
|
||||
// transitioned dispatching→running, so no setter call needed here.
|
||||
resetTimingRefs();
|
||||
@@ -3607,6 +3682,7 @@ export function REPL({
|
||||
// running→idle. Returns false if a newer query owns the guard
|
||||
// (cancel+resubmit race where the stale finally fires as a microtask).
|
||||
if (queryGuard.end(thisGeneration)) {
|
||||
setWasAborted(abortController.signal.aborted);
|
||||
setLastQueryCompletionTime(Date.now());
|
||||
skipIdleCheckRef.current = false;
|
||||
// Always reset loading state in finally - this ensures cleanup even
|
||||
@@ -3960,6 +4036,7 @@ export function REPL({
|
||||
doneOptions?: {
|
||||
display?: CommandResultDisplay;
|
||||
metaMessages?: string[];
|
||||
displayArgs?: string;
|
||||
},
|
||||
): void => {
|
||||
doneWasCalled = true;
|
||||
@@ -3983,8 +4060,9 @@ export function REPL({
|
||||
// doesn't change model context). Outside fullscreen the
|
||||
// transcript entry stays so scrollback shows what ran.
|
||||
if (!isFullscreenEnvEnabled()) {
|
||||
const breadcrumbArgs = doneOptions?.displayArgs ?? commandArgs;
|
||||
newMessages.push(
|
||||
createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), commandArgs)),
|
||||
createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), breadcrumbArgs)),
|
||||
createCommandInputMessage(
|
||||
`<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}</${LOCAL_COMMAND_STDOUT_TAG}>`,
|
||||
),
|
||||
@@ -5015,6 +5093,34 @@ export function REPL({
|
||||
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(() => {
|
||||
if (!proactiveActive) {
|
||||
notifyAutomationStateChanged(null);
|
||||
@@ -5832,7 +5938,6 @@ export function REPL({
|
||||
!hasRunningTeammates &&
|
||||
isBriefOnly &&
|
||||
!viewedAgentTask && <BriefIdleStatus />}
|
||||
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
|
||||
</>
|
||||
}
|
||||
bottom={
|
||||
@@ -5845,6 +5950,7 @@ export function REPL({
|
||||
<CompanionSprite />
|
||||
) : null}
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
|
||||
{permissionStickyFooter}
|
||||
{/* Immediate local-jsx commands (/btw, /sandbox, /assistant,
|
||||
/issue) render here, NOT inside scrollable. They stay mounted
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import type { UUID } from 'crypto';
|
||||
import { dirname } from 'path';
|
||||
import React from 'react';
|
||||
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')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
(
|
||||
|
||||
272
src/services/goal/__tests__/goalState.test.ts
Normal file
272
src/services/goal/__tests__/goalState.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
33
src/services/goal/goalAudit.ts
Normal file
33
src/services/goal/goalAudit.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
* PromptInputFooterLeftSide.tsx's require() can be resolved by Bun's
|
||||
* 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.
|
||||
* Uses Map<string, GoalState> keyed by sessionId so concurrent
|
||||
* sub-sessions (agents, worktrees) don't leak into each other.
|
||||
*/
|
||||
import type { GoalState, GoalStatus } from '../../types/logs.js'
|
||||
import { getSessionId } from '../../bootstrap/state.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
|
||||
export type GoalState = {
|
||||
status:
|
||||
| 'active'
|
||||
| 'paused'
|
||||
| 'budget_limited'
|
||||
| 'usage_limited'
|
||||
| 'blocked'
|
||||
| 'complete'
|
||||
[key: string]: unknown
|
||||
export const BLOCKED_CONSECUTIVE_THRESHOLD = 3
|
||||
export const MAX_GOAL_TURNS = 150
|
||||
|
||||
const goals = new Map<string, GoalState>()
|
||||
|
||||
function goalLog(
|
||||
tag: string,
|
||||
msg: string,
|
||||
extra?: Record<string, unknown>,
|
||||
): void {
|
||||
const suffix = extra ? ` ${JSON.stringify(extra)}` : ''
|
||||
logForDebugging(`[goal] ${tag}: ${msg}${suffix}`)
|
||||
}
|
||||
|
||||
export function getGoal(): GoalState | null {
|
||||
return null
|
||||
function resolveSessionId(sessionId?: string): string {
|
||||
return sessionId ?? getSessionId()
|
||||
}
|
||||
|
||||
export function getActiveElapsedMs(_goal: GoalState): number {
|
||||
return 0
|
||||
export function setGoal(
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
55
src/services/goal/goalStorage.ts
Normal file
55
src/services/goal/goalStorage.ts
Normal 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)
|
||||
}
|
||||
149
src/services/goal/prompts.ts
Normal file
149
src/services/goal/prompts.ts
Normal 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')
|
||||
}
|
||||
@@ -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 { ExitWorktreeTool } from '@claude-code-best/builtin-tools/tools/ExitWorktreeTool/ExitWorktreeTool.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 { VaultHttpFetchTool } from '@claude-code-best/builtin-tools/tools/VaultHttpFetchTool/VaultHttpFetchTool.js'
|
||||
import { TaskCreateTool } from '@claude-code-best/builtin-tools/tools/TaskCreateTool/TaskCreateTool.js'
|
||||
@@ -238,6 +242,7 @@ export function getAllBaseTools(): Tools {
|
||||
LocalMemoryRecallTool,
|
||||
VaultHttpFetchTool,
|
||||
...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),
|
||||
...(GoalTool ? [GoalTool] : []),
|
||||
...(process.env.USER_TYPE === 'ant' ? [TungstenTool] : []),
|
||||
...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []),
|
||||
...(WebBrowserTool ? [WebBrowserTool] : []),
|
||||
|
||||
@@ -122,6 +122,8 @@ export type LocalJSXCommandOnDone = (
|
||||
metaMessages?: string[]
|
||||
nextInput?: string
|
||||
submitNextInput?: boolean
|
||||
/** Override the args shown in the command breadcrumb (e.g. truncated). Full args still reach metaMessages. */
|
||||
displayArgs?: string
|
||||
},
|
||||
) => void
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ export type LogOption = {
|
||||
mode?: 'coordinator' | 'normal' // Session mode for coordinator/normal detection
|
||||
worktreeSession?: PersistedWorktreeSession | null // Worktree state at session end (null = exited, undefined = never entered)
|
||||
contentReplacements?: ContentReplacementRecord[] // Replacement decisions for resume reconstruction
|
||||
goal?: GoalState // Active goal state at session end (for resume)
|
||||
}
|
||||
|
||||
export type SummaryMessage = {
|
||||
@@ -140,6 +141,77 @@ export type ModeEntry = {
|
||||
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.
|
||||
* Subset of WorktreeSession from utils/worktree.ts — excludes ephemeral
|
||||
@@ -315,6 +387,8 @@ export type Entry =
|
||||
| ContentReplacementEntry
|
||||
| ContextCollapseCommitEntry
|
||||
| ContextCollapseSnapshotEntry
|
||||
| GoalMetadataEntry
|
||||
| GoalClearedEntry
|
||||
|
||||
export function sortLogs(logs: LogOption[]): LogOption[] {
|
||||
return logs.sort((a, b) => {
|
||||
|
||||
@@ -507,6 +507,8 @@ export async function loadConversationForResume(
|
||||
prRepository?: string
|
||||
// Full path to the session file (for cross-directory resume)
|
||||
fullPath?: string
|
||||
// Goal state for hydration on resume
|
||||
goal?: import('../types/logs.js').GoalState
|
||||
} | null> {
|
||||
try {
|
||||
let log: LogOption | null = null
|
||||
@@ -618,6 +620,8 @@ export async function loadConversationForResume(
|
||||
prRepository: log?.prRepository,
|
||||
// Include full path for cross-directory resume
|
||||
fullPath: log?.fullPath,
|
||||
// Goal state for hydration on resume
|
||||
goal: log?.goal,
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
|
||||
@@ -740,6 +740,7 @@ async function getMessagesForSlashCommand(
|
||||
metaMessages?: string[];
|
||||
nextInput?: string;
|
||||
submitNextInput?: boolean;
|
||||
displayArgs?: string;
|
||||
},
|
||||
) => {
|
||||
doneWasCalled = true;
|
||||
@@ -773,20 +774,22 @@ async function getMessagesForSlashCommand(
|
||||
const skipTranscript =
|
||||
isFullscreenEnvEnabled() && typeof result === 'string' && result.endsWith(' dismissed');
|
||||
|
||||
const breadcrumbArgs = options?.displayArgs ?? args;
|
||||
|
||||
void resolve({
|
||||
messages:
|
||||
options?.display === 'system'
|
||||
? skipTranscript
|
||||
? metaMessages
|
||||
: [
|
||||
createCommandInputMessage(formatCommandInput(command, args)),
|
||||
createCommandInputMessage(formatCommandInput(command, breadcrumbArgs)),
|
||||
createCommandInputMessage(`<local-command-stdout>${result}</local-command-stdout>`),
|
||||
...metaMessages,
|
||||
]
|
||||
: [
|
||||
createUserMessage({
|
||||
content: prepareUserContent({
|
||||
inputString: formatCommandInput(command, args),
|
||||
inputString: formatCommandInput(command, breadcrumbArgs),
|
||||
precedingInputBlocks,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -146,9 +146,10 @@ export async function processUserInput({
|
||||
}): Promise<ProcessUserInputBaseResult> {
|
||||
const inputString = typeof input === 'string' ? input : null
|
||||
// Immediately show the user input prompt while we are still processing the input.
|
||||
// Skip for isMeta (system-generated prompts like scheduled tasks) — those
|
||||
// should run invisibly.
|
||||
if (mode === 'prompt' && inputString !== null && !isMeta) {
|
||||
// Skip for isMeta (system-generated prompts like scheduled tasks) and slash
|
||||
// commands (they produce their own system message echo via createCommandInputMessage).
|
||||
const isSlashInput = inputString?.startsWith('/') && !skipSlashCommands
|
||||
if (mode === 'prompt' && inputString !== null && !isMeta && !isSlashInput) {
|
||||
setUserInputOnProcessing?.(inputString)
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,7 @@ type ResumeLoadResult = {
|
||||
prNumber?: number
|
||||
prUrl?: string
|
||||
prRepository?: string
|
||||
goal?: import('../types/logs.js').GoalState
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,6 +472,18 @@ export async function processResumedConversation(
|
||||
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) {
|
||||
// Cd back into the worktree the session was in when it last exited.
|
||||
// Done after restoreSessionMetadata (which caches the worktree state
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type ContextCollapseSnapshotEntry,
|
||||
type Entry,
|
||||
type FileHistorySnapshotMessage,
|
||||
type GoalState,
|
||||
type LogOption,
|
||||
type PersistedWorktreeSession,
|
||||
type SerializedMessage,
|
||||
@@ -542,6 +543,7 @@ class Project {
|
||||
currentSessionLastPrompt: string | undefined
|
||||
currentSessionAgentSetting: string | undefined
|
||||
currentSessionMode: 'coordinator' | 'normal' | undefined
|
||||
currentSessionGoal: GoalState | undefined
|
||||
// Tri-state: undefined = never touched (don't write), null = exited worktree,
|
||||
// object = currently in worktree. reAppendSessionMetadata writes null so
|
||||
// --resume knows the session exited (vs. crashed while inside).
|
||||
@@ -827,6 +829,14 @@ class Project {
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
if (this.currentSessionGoal) {
|
||||
appendEntryToFile(this.sessionFile, {
|
||||
type: 'goal',
|
||||
sessionId,
|
||||
state: this.currentSessionGoal,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
if (this.currentSessionWorktree !== undefined) {
|
||||
appendEntryToFile(this.sessionFile, {
|
||||
type: 'worktree-state',
|
||||
@@ -1226,6 +1236,10 @@ class Project {
|
||||
} else if (entry.type === 'marble-origami-snapshot') {
|
||||
// Always append. Last-wins on restore — later entries supersede.
|
||||
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 {
|
||||
const messageSet = await getSessionMessages(sessionId)
|
||||
if (entry.type === 'queue-operation') {
|
||||
@@ -2723,6 +2737,48 @@ export async function saveTag(sessionId: UUID, tag: string, fullPath?: string) {
|
||||
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.
|
||||
* This stores the PR number, URL, and repository for tracking and navigation.
|
||||
@@ -2791,6 +2847,7 @@ export function restoreSessionMetadata(meta: {
|
||||
prNumber?: number
|
||||
prUrl?: string
|
||||
prRepository?: string
|
||||
goal?: GoalState
|
||||
}): void {
|
||||
const project = getProject()
|
||||
// ??= so --name (cacheSessionTitle) wins over the resumed
|
||||
@@ -2807,6 +2864,7 @@ export function restoreSessionMetadata(meta: {
|
||||
project.currentSessionPrNumber = meta.prNumber
|
||||
if (meta.prUrl) project.currentSessionPrUrl = meta.prUrl
|
||||
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.currentSessionAgentSetting = undefined
|
||||
project.currentSessionMode = undefined
|
||||
project.currentSessionGoal = undefined
|
||||
project.currentSessionWorktree = undefined
|
||||
project.currentSessionPrNumber = undefined
|
||||
project.currentSessionPrUrl = undefined
|
||||
@@ -2997,6 +3056,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
|
||||
prRepositories,
|
||||
modes,
|
||||
worktreeStates,
|
||||
goals,
|
||||
fileHistorySnapshots,
|
||||
attributionSnapshots,
|
||||
contentReplacements,
|
||||
@@ -3006,7 +3066,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
|
||||
} = await loadTranscriptFile(sessionFile)
|
||||
|
||||
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
|
||||
@@ -3017,7 +3080,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
|
||||
(msg.type === 'user' || msg.type === 'assistant'),
|
||||
)
|
||||
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
|
||||
@@ -3043,6 +3109,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
|
||||
sessionId && worktreeStates.has(sessionId)
|
||||
? worktreeStates.get(sessionId)
|
||||
: log.worktreeSession,
|
||||
goal: sessionId ? goals.get(sessionId) : log.goal,
|
||||
prNumber: sessionId ? prNumbers.get(sessionId) : log.prNumber,
|
||||
prUrl: sessionId ? prUrls.get(sessionId) : log.prUrl,
|
||||
prRepository: sessionId
|
||||
@@ -3144,6 +3211,8 @@ const METADATA_TYPE_MARKERS = [
|
||||
'"type":"agent-setting"',
|
||||
'"type":"mode"',
|
||||
'"type":"worktree-state"',
|
||||
'"type":"goal"',
|
||||
'"type":"goal-cleared"',
|
||||
'"type":"pr-link"',
|
||||
]
|
||||
const METADATA_MARKER_BUFS = METADATA_TYPE_MARKERS.map(m => Buffer.from(m))
|
||||
@@ -3510,6 +3579,7 @@ export async function loadTranscriptFile(
|
||||
prRepositories: Map<UUID, string>
|
||||
modes: Map<UUID, string>
|
||||
worktreeStates: Map<UUID, PersistedWorktreeSession | null>
|
||||
goals: Map<UUID, GoalState>
|
||||
fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage>
|
||||
attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
|
||||
contentReplacements: Map<UUID, ContentReplacementRecord[]>
|
||||
@@ -3530,6 +3600,7 @@ export async function loadTranscriptFile(
|
||||
const prRepositories = new Map<UUID, string>()
|
||||
const modes = new Map<UUID, string>()
|
||||
const worktreeStates = new Map<UUID, PersistedWorktreeSession | null>()
|
||||
const goals = new Map<UUID, GoalState>()
|
||||
const fileHistorySnapshots = new Map<UUID, FileHistorySnapshotMessage>()
|
||||
const attributionSnapshots = new Map<UUID, AttributionSnapshotMessage>()
|
||||
const contentReplacements = new Map<UUID, ContentReplacementRecord[]>()
|
||||
@@ -3628,6 +3699,10 @@ export async function loadTranscriptFile(
|
||||
modes.set(entry.sessionId, entry.mode)
|
||||
} else if (entry.type === 'worktree-state' && entry.sessionId) {
|
||||
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) {
|
||||
prNumbers.set(entry.sessionId, entry.prNumber)
|
||||
prUrls.set(entry.sessionId, entry.prUrl)
|
||||
@@ -3696,6 +3771,10 @@ export async function loadTranscriptFile(
|
||||
modes.set(entry.sessionId, entry.mode)
|
||||
} else if (entry.type === 'worktree-state' && entry.sessionId) {
|
||||
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) {
|
||||
prNumbers.set(entry.sessionId, entry.prNumber)
|
||||
prUrls.set(entry.sessionId, entry.prUrl)
|
||||
@@ -3827,6 +3906,7 @@ export async function loadTranscriptFile(
|
||||
prRepositories,
|
||||
modes,
|
||||
worktreeStates,
|
||||
goals,
|
||||
fileHistorySnapshots,
|
||||
attributionSnapshots,
|
||||
contentReplacements,
|
||||
@@ -3847,6 +3927,7 @@ async function loadSessionFile(sessionId: UUID): Promise<{
|
||||
tags: Map<UUID, string>
|
||||
agentSettings: Map<UUID, string>
|
||||
worktreeStates: Map<UUID, PersistedWorktreeSession | null>
|
||||
goals: Map<UUID, GoalState>
|
||||
fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage>
|
||||
attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
|
||||
contentReplacements: Map<UUID, ContentReplacementRecord[]>
|
||||
@@ -3905,6 +3986,7 @@ export async function getLastSessionLog(
|
||||
fileHistorySnapshots,
|
||||
attributionSnapshots,
|
||||
contentReplacements,
|
||||
goals,
|
||||
contextCollapseCommits,
|
||||
contextCollapseSnapshot,
|
||||
} = await loadSessionFile(sessionId)
|
||||
@@ -3946,6 +4028,7 @@ export async function getLastSessionLog(
|
||||
contentReplacements.get(sessionId) ?? [],
|
||||
),
|
||||
worktreeSession: worktreeStates.get(sessionId),
|
||||
goal: goals.get(sessionId),
|
||||
contextCollapseCommits: contextCollapseCommits.filter(
|
||||
e => e.sessionId === sessionId,
|
||||
),
|
||||
|
||||
289
tests/integration/goal-lifecycle.test.ts
Normal file
289
tests/integration/goal-lifecycle.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user