mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55: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 { FileReadTool } from './tools/FileReadTool/FileReadTool.js'
|
||||||
export { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js'
|
export { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js'
|
||||||
export { GlobTool } from './tools/GlobTool/GlobTool.js'
|
export { GlobTool } from './tools/GlobTool/GlobTool.js'
|
||||||
|
export { GoalTool } from './tools/GoalTool/GoalTool.js'
|
||||||
export { GrepTool } from './tools/GrepTool/GrepTool.js'
|
export { GrepTool } from './tools/GrepTool/GrepTool.js'
|
||||||
export { LSPTool } from './tools/LSPTool/LSPTool.js'
|
export { LSPTool } from './tools/LSPTool/LSPTool.js'
|
||||||
export { ListMcpResourcesTool } from './tools/ListMcpResourcesTool/ListMcpResourcesTool.js'
|
export { ListMcpResourcesTool } from './tools/ListMcpResourcesTool/ListMcpResourcesTool.js'
|
||||||
|
|||||||
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 + 远端工具执行
|
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
|
||||||
// Autofix PR
|
// Autofix PR
|
||||||
'AUTOFIX_PR', // /autofix-pr 命令(fork 引入;docs/jira/AUTOFIX-PR-001.md 承诺默认开启)
|
'AUTOFIX_PR', // /autofix-pr 命令(fork 引入;docs/jira/AUTOFIX-PR-001.md 承诺默认开启)
|
||||||
|
// Persistent thread goal command — auto-continuation, JSONL persistence,
|
||||||
|
// strict completion/blocked audit. See src/services/goal.
|
||||||
|
'GOAL',
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ const poor = feature('POOR')
|
|||||||
require('./commands/poor/index.js') as typeof import('./commands/poor/index.js')
|
require('./commands/poor/index.js') as typeof import('./commands/poor/index.js')
|
||||||
).default
|
).default
|
||||||
: null
|
: null
|
||||||
|
const goalCmd = feature('GOAL')
|
||||||
|
? (
|
||||||
|
require('./commands/goal/index.js') as typeof import('./commands/goal/index.js')
|
||||||
|
).default
|
||||||
|
: null
|
||||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
import thinkback from './commands/thinkback/index.js'
|
import thinkback from './commands/thinkback/index.js'
|
||||||
import thinkbackPlay from './commands/thinkback-play/index.js'
|
import thinkbackPlay from './commands/thinkback-play/index.js'
|
||||||
@@ -362,6 +367,7 @@ const COMMANDS = memoize((): Command[] => [
|
|||||||
...(forkCmd ? [forkCmd] : []),
|
...(forkCmd ? [forkCmd] : []),
|
||||||
...(buddy ? [buddy] : []),
|
...(buddy ? [buddy] : []),
|
||||||
...(poor ? [poor] : []),
|
...(poor ? [poor] : []),
|
||||||
|
...(goalCmd ? [goalCmd] : []),
|
||||||
...(proactive ? [proactive] : []),
|
...(proactive ? [proactive] : []),
|
||||||
...(monitorCmd ? [monitorCmd] : []),
|
...(monitorCmd ? [monitorCmd] : []),
|
||||||
...(coordinatorCmd ? [coordinatorCmd] : []),
|
...(coordinatorCmd ? [coordinatorCmd] : []),
|
||||||
|
|||||||
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 { computeHitRate, tokenSignature } from '../utils/cacheStats.js';
|
||||||
import { onResponse as cacheOnResponse, getCacheStatsState, initCacheStatsState } from '../utils/cacheStatsState.js';
|
import { onResponse as cacheOnResponse, getCacheStatsState, initCacheStatsState } from '../utils/cacheStatsState.js';
|
||||||
import { BuiltinStatusLine } from './BuiltinStatusLine.js';
|
import { BuiltinStatusLine } from './BuiltinStatusLine.js';
|
||||||
|
import { formatTokens } from 'src/utils/format.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CachePill — cache hit-rate + 1-hour TTL countdown pill
|
// CachePill — cache hit-rate + 1-hour TTL countdown pill
|
||||||
@@ -156,6 +157,51 @@ function CachePill({ messages }: CachePillProps): React.ReactNode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GoalPill(): React.ReactNode {
|
||||||
|
if (!feature('GOAL')) return null;
|
||||||
|
const { getGoal, formatGoalStatusLabel } =
|
||||||
|
require('../services/goal/goalState.js') as typeof import('../services/goal/goalState.js');
|
||||||
|
const goal = getGoal();
|
||||||
|
if (!goal) return null;
|
||||||
|
|
||||||
|
const truncatedObj = goal.objective.length > 30 ? `${goal.objective.slice(0, 27)}…` : goal.objective;
|
||||||
|
const budget =
|
||||||
|
goal.tokenBudget !== null
|
||||||
|
? `${formatTokens(goal.tokensUsed)}/${formatTokens(goal.tokenBudget)}`
|
||||||
|
: formatTokens(goal.tokensUsed);
|
||||||
|
const statusLabel = formatGoalStatusLabel(goal.status);
|
||||||
|
|
||||||
|
let statusNode: React.ReactNode;
|
||||||
|
switch (goal.status) {
|
||||||
|
case 'active':
|
||||||
|
statusNode = <Text color="ansi:green">{statusLabel}</Text>;
|
||||||
|
break;
|
||||||
|
case 'paused':
|
||||||
|
case 'budget_limited':
|
||||||
|
case 'usage_limited':
|
||||||
|
statusNode = <Text color="ansi:yellow">{statusLabel}</Text>;
|
||||||
|
break;
|
||||||
|
case 'blocked':
|
||||||
|
statusNode = <Text color="ansi:red">{statusLabel}</Text>;
|
||||||
|
break;
|
||||||
|
case 'complete':
|
||||||
|
statusNode = <Text color="ansi:cyan">{statusLabel}</Text>;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusNode = <Text>{statusLabel}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{statusNode}
|
||||||
|
<Text dimColor>{' · '}</Text>
|
||||||
|
<Text dimColor>{truncatedObj}</Text>
|
||||||
|
<Text dimColor>{' · '}</Text>
|
||||||
|
<Text>{budget}</Text>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function statusLineShouldDisplay(settings: ReadonlySettings): boolean {
|
export function statusLineShouldDisplay(settings: ReadonlySettings): boolean {
|
||||||
// Assistant mode: statusline fields (model, permission mode, cwd) reflect the
|
// Assistant mode: statusline fields (model, permission mode, cwd) reflect the
|
||||||
// REPL/daemon process, not what the agent child is actually running. Hide it.
|
// REPL/daemon process, not what the agent child is actually running. Hide it.
|
||||||
@@ -519,6 +565,7 @@ function StatusLineInner({ messagesRef, lastAssistantMessageId, vimMode }: Props
|
|||||||
totalCostUsd={getTotalCost()}
|
totalCostUsd={getTotalCost()}
|
||||||
rateLimits={builtinRateLimits}
|
rateLimits={builtinRateLimits}
|
||||||
/>
|
/>
|
||||||
|
<GoalPill />
|
||||||
<CachePill messages={messagesRef.current} />
|
<CachePill messages={messagesRef.current} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BetaUsage as Usage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
import type { BetaUsage as Usage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
import { feature } from 'bun:bundle'
|
||||||
import chalk from 'chalk'
|
import chalk from 'chalk'
|
||||||
import {
|
import {
|
||||||
addToTotalCostState,
|
addToTotalCostState,
|
||||||
@@ -282,6 +283,24 @@ export function addToTotalSessionCost(
|
|||||||
): number {
|
): number {
|
||||||
const modelUsage = addToTotalModelUsage(cost, usage, model)
|
const modelUsage = addToTotalModelUsage(cost, usage, model)
|
||||||
addToTotalCostState(cost, modelUsage, model)
|
addToTotalCostState(cost, modelUsage, model)
|
||||||
|
if (feature('GOAL')) {
|
||||||
|
const { getGoal, updateGoalTokens } =
|
||||||
|
require('./services/goal/goalState.js') as typeof import('./services/goal/goalState.js')
|
||||||
|
const totalDelta =
|
||||||
|
(usage.input_tokens ?? 0) +
|
||||||
|
(usage.output_tokens ?? 0) +
|
||||||
|
(usage.cache_read_input_tokens ?? 0) +
|
||||||
|
(usage.cache_creation_input_tokens ?? 0)
|
||||||
|
const currentGoal = getGoal()
|
||||||
|
if (totalDelta > 0 && currentGoal?.status === 'active') {
|
||||||
|
const { logForDebugging: goalDbg } =
|
||||||
|
require('./utils/debug.js') as typeof import('./utils/debug.js')
|
||||||
|
goalDbg(
|
||||||
|
`[goal] cost: in=${usage.input_tokens ?? 0} out=${usage.output_tokens ?? 0} cache_r=${usage.cache_read_input_tokens ?? 0} cache_w=${usage.cache_creation_input_tokens ?? 0} delta=${totalDelta}`,
|
||||||
|
)
|
||||||
|
updateGoalTokens(totalDelta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const attrs =
|
const attrs =
|
||||||
isFastModeEnabled() && usage.speed === 'fast'
|
isFastModeEnabled() && usage.speed === 'fast'
|
||||||
|
|||||||
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 =
|
const useProactive =
|
||||||
feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
|
feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
|
||||||
const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null;
|
const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null;
|
||||||
|
const useGoalContinuation: typeof import('../hooks/useGoalContinuation.js').useGoalContinuation | null = feature('GOAL')
|
||||||
|
? require('../hooks/useGoalContinuation.js').useGoalContinuation
|
||||||
|
: null;
|
||||||
const useMasterMonitor = feature('UDS_INBOX')
|
const useMasterMonitor = feature('UDS_INBOX')
|
||||||
? require('../hooks/useMasterMonitor.js').useMasterMonitor
|
? require('../hooks/useMasterMonitor.js').useMasterMonitor
|
||||||
: () => undefined;
|
: () => undefined;
|
||||||
@@ -1133,6 +1136,12 @@ export function REPL({
|
|||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
abortControllerRef.current = abortController;
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
// Track whether the last turn was user-aborted (Ctrl+C / Escape).
|
||||||
|
// When true, useGoalContinuation skips the continuation enqueue so
|
||||||
|
// interrupted turns don't spin into an unstoppable loop. Reset to
|
||||||
|
// false at the start of the next user-initiated turn.
|
||||||
|
const [wasAborted, setWasAborted] = useState(false);
|
||||||
|
|
||||||
// Ref for the bridge result callback — set after useReplBridge initializes,
|
// Ref for the bridge result callback — set after useReplBridge initializes,
|
||||||
// read in the onQuery finally block to notify mobile clients that a turn ended.
|
// read in the onQuery finally block to notify mobile clients that a turn ended.
|
||||||
const sendBridgeResultRef = useRef<() => void>(() => {});
|
const sendBridgeResultRef = useRef<() => void>(() => {});
|
||||||
@@ -2220,6 +2229,16 @@ export function REPL({
|
|||||||
// cached name and write it to the wrong transcript on first message.
|
// cached name and write it to the wrong transcript on first message.
|
||||||
clearSessionMetadata();
|
clearSessionMetadata();
|
||||||
restoreSessionMetadata(log);
|
restoreSessionMetadata(log);
|
||||||
|
|
||||||
|
// Hydrate goal state from the resumed session's transcript
|
||||||
|
if (feature('GOAL') && log.goal) {
|
||||||
|
const { hydrateGoalFromTranscript } =
|
||||||
|
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js');
|
||||||
|
const goalsMap = new Map<UUID, import('../types/logs.js').GoalState>();
|
||||||
|
goalsMap.set(sessionId as UUID, log.goal);
|
||||||
|
hydrateGoalFromTranscript(goalsMap, sessionId as UUID);
|
||||||
|
}
|
||||||
|
|
||||||
// Resumed sessions shouldn't re-title from mid-conversation context
|
// Resumed sessions shouldn't re-title from mid-conversation context
|
||||||
// (same reasoning as the useRef seed), and the previous session's
|
// (same reasoning as the useRef seed), and the previous session's
|
||||||
// Haiku title shouldn't carry over.
|
// Haiku title shouldn't carry over.
|
||||||
@@ -2523,6 +2542,24 @@ export function REPL({
|
|||||||
proactiveModule?.pauseProactive();
|
proactiveModule?.pauseProactive();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+C during an active goal turn pauses the goal so the
|
||||||
|
// continuation loop stops. The user can /goal resume to continue later.
|
||||||
|
// Guard: only pause when a query is actually in flight. onCancel() is
|
||||||
|
// also called from the restore/edit flow (idle), and pausing then would
|
||||||
|
// incorrectly stop the next continuation.
|
||||||
|
if (feature('GOAL') && queryGuard.getSnapshot()) {
|
||||||
|
const { getGoal, pauseGoal } =
|
||||||
|
require('../services/goal/goalState.js') as typeof import('../services/goal/goalState.js');
|
||||||
|
const { persistCurrentGoal } =
|
||||||
|
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js');
|
||||||
|
const currentGoal = getGoal();
|
||||||
|
if (currentGoal?.status === 'active') {
|
||||||
|
pauseGoal();
|
||||||
|
persistCurrentGoal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setWasAborted(true);
|
||||||
|
|
||||||
queryGuard.forceEnd();
|
queryGuard.forceEnd();
|
||||||
skipIdleCheckRef.current = false;
|
skipIdleCheckRef.current = false;
|
||||||
|
|
||||||
@@ -3162,6 +3199,43 @@ export function REPL({
|
|||||||
proactiveModule?.setContextBlocked(false);
|
proactiveModule?.setContextBlocked(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Auto-pause active /goal when the turn failed due to connectivity.
|
||||||
|
// Continuing immediately after network failures usually burns turns
|
||||||
|
// without progress and can rapidly hit max-turn guards.
|
||||||
|
if (
|
||||||
|
feature('GOAL') &&
|
||||||
|
newMessage.type === 'assistant' &&
|
||||||
|
'isApiErrorMessage' in newMessage &&
|
||||||
|
newMessage.isApiErrorMessage
|
||||||
|
) {
|
||||||
|
const assistantText =
|
||||||
|
getContentText((newMessage.message?.content ?? '') as string | ContentBlockParam[]) ?? '';
|
||||||
|
const lowerText = assistantText.toLowerCase();
|
||||||
|
const isConnectivityFailure =
|
||||||
|
lowerText.includes('connection error') ||
|
||||||
|
lowerText.includes('fetch failed') ||
|
||||||
|
lowerText.includes('network error') ||
|
||||||
|
lowerText.includes('enotfound') ||
|
||||||
|
lowerText.includes('econnreset') ||
|
||||||
|
lowerText.includes('etimedout');
|
||||||
|
|
||||||
|
if (isConnectivityFailure) {
|
||||||
|
const { getGoal, pauseGoal } =
|
||||||
|
require('../services/goal/goalState.js') as typeof import('../services/goal/goalState.js');
|
||||||
|
const { persistCurrentGoal } =
|
||||||
|
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js');
|
||||||
|
const currentGoal = getGoal();
|
||||||
|
if (currentGoal?.status === 'active') {
|
||||||
|
pauseGoal();
|
||||||
|
persistCurrentGoal();
|
||||||
|
addNotification({
|
||||||
|
key: 'goal-auto-paused-connectivity-error',
|
||||||
|
text: 'Detected connection error. Active goal was auto-paused. Run /goal resume after network recovers.',
|
||||||
|
priority: 'immediate',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Relay assistant response to master when in slave mode.
|
// Relay assistant response to master when in slave mode.
|
||||||
if (feature('UDS_INBOX') && newMessage.type === 'assistant') {
|
if (feature('UDS_INBOX') && newMessage.type === 'assistant') {
|
||||||
// Extract text from content blocks (API format)
|
// Extract text from content blocks (API format)
|
||||||
@@ -3550,6 +3624,7 @@ export function REPL({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
pipeReturnHadErrorRef.current = false;
|
pipeReturnHadErrorRef.current = false;
|
||||||
|
setWasAborted(false);
|
||||||
// isLoading is derived from queryGuard — tryStart() above already
|
// isLoading is derived from queryGuard — tryStart() above already
|
||||||
// transitioned dispatching→running, so no setter call needed here.
|
// transitioned dispatching→running, so no setter call needed here.
|
||||||
resetTimingRefs();
|
resetTimingRefs();
|
||||||
@@ -3607,6 +3682,7 @@ export function REPL({
|
|||||||
// running→idle. Returns false if a newer query owns the guard
|
// running→idle. Returns false if a newer query owns the guard
|
||||||
// (cancel+resubmit race where the stale finally fires as a microtask).
|
// (cancel+resubmit race where the stale finally fires as a microtask).
|
||||||
if (queryGuard.end(thisGeneration)) {
|
if (queryGuard.end(thisGeneration)) {
|
||||||
|
setWasAborted(abortController.signal.aborted);
|
||||||
setLastQueryCompletionTime(Date.now());
|
setLastQueryCompletionTime(Date.now());
|
||||||
skipIdleCheckRef.current = false;
|
skipIdleCheckRef.current = false;
|
||||||
// Always reset loading state in finally - this ensures cleanup even
|
// Always reset loading state in finally - this ensures cleanup even
|
||||||
@@ -3960,6 +4036,7 @@ export function REPL({
|
|||||||
doneOptions?: {
|
doneOptions?: {
|
||||||
display?: CommandResultDisplay;
|
display?: CommandResultDisplay;
|
||||||
metaMessages?: string[];
|
metaMessages?: string[];
|
||||||
|
displayArgs?: string;
|
||||||
},
|
},
|
||||||
): void => {
|
): void => {
|
||||||
doneWasCalled = true;
|
doneWasCalled = true;
|
||||||
@@ -3983,8 +4060,9 @@ export function REPL({
|
|||||||
// doesn't change model context). Outside fullscreen the
|
// doesn't change model context). Outside fullscreen the
|
||||||
// transcript entry stays so scrollback shows what ran.
|
// transcript entry stays so scrollback shows what ran.
|
||||||
if (!isFullscreenEnvEnabled()) {
|
if (!isFullscreenEnvEnabled()) {
|
||||||
|
const breadcrumbArgs = doneOptions?.displayArgs ?? commandArgs;
|
||||||
newMessages.push(
|
newMessages.push(
|
||||||
createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), commandArgs)),
|
createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), breadcrumbArgs)),
|
||||||
createCommandInputMessage(
|
createCommandInputMessage(
|
||||||
`<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}</${LOCAL_COMMAND_STDOUT_TAG}>`,
|
`<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}</${LOCAL_COMMAND_STDOUT_TAG}>`,
|
||||||
),
|
),
|
||||||
@@ -5015,6 +5093,34 @@ export function REPL({
|
|||||||
onQueueTick: (command: QueuedCommand) => enqueue(command),
|
onQueueTick: (command: QueuedCommand) => enqueue(command),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Goal auto-continuation: enqueue a steering prompt when idle + active goal
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
useGoalContinuation?.({
|
||||||
|
isLoading: isLoading || initialMessage !== null,
|
||||||
|
wasAborted,
|
||||||
|
queuedCommandsLength: queuedCommands.length,
|
||||||
|
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
|
||||||
|
isInPlanMode: toolPermissionContext.mode === 'plan',
|
||||||
|
isQueryActiveNow: queryGuard.getSnapshot,
|
||||||
|
onContinuationEnqueued: ({ turn, objective }) => {
|
||||||
|
const visibleGoalTurnInput = `Goal auto-continue (${turn}/1): continue advancing "${objective}".`;
|
||||||
|
setMessages(oldMessages => [
|
||||||
|
...oldMessages,
|
||||||
|
createUserMessage({
|
||||||
|
content: visibleGoalTurnInput,
|
||||||
|
isVisibleInTranscriptOnly: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onMaxTurnsReached: () => {
|
||||||
|
addNotification({
|
||||||
|
key: 'goal-max-turns-reached',
|
||||||
|
text: 'Goal reached max continuation turns (1). Run /goal continue to reset turn counter and continue.',
|
||||||
|
priority: 'immediate',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!proactiveActive) {
|
if (!proactiveActive) {
|
||||||
notifyAutomationStateChanged(null);
|
notifyAutomationStateChanged(null);
|
||||||
@@ -5832,7 +5938,6 @@ export function REPL({
|
|||||||
!hasRunningTeammates &&
|
!hasRunningTeammates &&
|
||||||
isBriefOnly &&
|
isBriefOnly &&
|
||||||
!viewedAgentTask && <BriefIdleStatus />}
|
!viewedAgentTask && <BriefIdleStatus />}
|
||||||
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
bottom={
|
bottom={
|
||||||
@@ -5845,6 +5950,7 @@ export function REPL({
|
|||||||
<CompanionSprite />
|
<CompanionSprite />
|
||||||
) : null}
|
) : null}
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
|
||||||
{permissionStickyFooter}
|
{permissionStickyFooter}
|
||||||
{/* Immediate local-jsx commands (/btw, /sandbox, /assistant,
|
{/* Immediate local-jsx commands (/btw, /sandbox, /assistant,
|
||||||
/issue) render here, NOT inside scrollable. They stay mounted
|
/issue) render here, NOT inside scrollable. They stay mounted
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { feature } from 'bun:bundle';
|
import { feature } from 'bun:bundle';
|
||||||
|
import type { UUID } from 'crypto';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTerminalSize } from 'src/hooks/useTerminalSize.js';
|
import { useTerminalSize } from 'src/hooks/useTerminalSize.js';
|
||||||
@@ -296,6 +297,14 @@ export function ResumeConversation({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (feature('GOAL') && result.goal && result.sessionId) {
|
||||||
|
const { hydrateGoalFromTranscript } =
|
||||||
|
require('src/services/goal/goalStorage.js') as typeof import('src/services/goal/goalStorage.js');
|
||||||
|
const goalsMap = new Map<UUID, import('src/types/logs.js').GoalState>();
|
||||||
|
goalsMap.set(result.sessionId, result.goal);
|
||||||
|
hydrateGoalFromTranscript(goalsMap, result.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
if (feature('CONTEXT_COLLAPSE')) {
|
if (feature('CONTEXT_COLLAPSE')) {
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
(
|
(
|
||||||
|
|||||||
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
|
* Uses Map<string, GoalState> keyed by sessionId so concurrent
|
||||||
* PromptInputFooterLeftSide.tsx's require() can be resolved by Bun's
|
* sub-sessions (agents, worktrees) don't leak into each other.
|
||||||
* bundler (build.ts). At runtime, getGoal() returns null, so the
|
|
||||||
* GoalElapsedIndicator component renders nothing.
|
|
||||||
*
|
|
||||||
* When the goal feature is implemented, replace this stub with the
|
|
||||||
* real implementation.
|
|
||||||
*/
|
*/
|
||||||
|
import type { GoalState, GoalStatus } from '../../types/logs.js'
|
||||||
|
import { getSessionId } from '../../bootstrap/state.js'
|
||||||
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
|
|
||||||
export type GoalState = {
|
export const BLOCKED_CONSECUTIVE_THRESHOLD = 3
|
||||||
status:
|
export const MAX_GOAL_TURNS = 150
|
||||||
| 'active'
|
|
||||||
| 'paused'
|
const goals = new Map<string, GoalState>()
|
||||||
| 'budget_limited'
|
|
||||||
| 'usage_limited'
|
function goalLog(
|
||||||
| 'blocked'
|
tag: string,
|
||||||
| 'complete'
|
msg: string,
|
||||||
[key: string]: unknown
|
extra?: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const suffix = extra ? ` ${JSON.stringify(extra)}` : ''
|
||||||
|
logForDebugging(`[goal] ${tag}: ${msg}${suffix}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGoal(): GoalState | null {
|
function resolveSessionId(sessionId?: string): string {
|
||||||
return null
|
return sessionId ?? getSessionId()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveElapsedMs(_goal: GoalState): number {
|
export function setGoal(
|
||||||
return 0
|
objective: string,
|
||||||
|
options?: { tokenBudget?: number; sessionId?: string },
|
||||||
|
): GoalState {
|
||||||
|
const id = resolveSessionId(options?.sessionId)
|
||||||
|
const budget =
|
||||||
|
options?.tokenBudget !== undefined &&
|
||||||
|
Number.isFinite(options.tokenBudget) &&
|
||||||
|
options.tokenBudget > 0
|
||||||
|
? options.tokenBudget
|
||||||
|
: null
|
||||||
|
const now = Date.now()
|
||||||
|
const state: GoalState = {
|
||||||
|
objective,
|
||||||
|
status: 'active',
|
||||||
|
tokenBudget: budget,
|
||||||
|
tokensUsed: 0,
|
||||||
|
startTime: now,
|
||||||
|
pausedAt: null,
|
||||||
|
accumulatedActiveMs: 0,
|
||||||
|
blockedAttempts: 0,
|
||||||
|
lastBlockReason: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
turnsExecuted: 0,
|
||||||
|
}
|
||||||
|
goals.set(id, state)
|
||||||
|
goalLog('SET', `objective="${objective.slice(0, 80)}"`, {
|
||||||
|
tokenBudget: state.tokenBudget,
|
||||||
|
})
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGoal(sessionId?: string): GoalState | null {
|
||||||
|
return goals.get(resolveSessionId(sessionId)) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGoal(sessionId?: string): boolean {
|
||||||
|
const had = goals.has(resolveSessionId(sessionId))
|
||||||
|
const result = goals.delete(resolveSessionId(sessionId))
|
||||||
|
if (had) goalLog('CLEAR', 'goal removed')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseGoal(sessionId?: string): GoalState | null {
|
||||||
|
const id = resolveSessionId(sessionId)
|
||||||
|
const goal = goals.get(id)
|
||||||
|
if (!goal || goal.status !== 'active') return null
|
||||||
|
const now = Date.now()
|
||||||
|
goal.accumulatedActiveMs += now - goal.startTime
|
||||||
|
goal.pausedAt = now
|
||||||
|
goal.status = 'paused'
|
||||||
|
goal.updatedAt = now
|
||||||
|
goalLog(
|
||||||
|
'PAUSE',
|
||||||
|
`paused after ${Math.round(goal.accumulatedActiveMs / 1000)}s active`,
|
||||||
|
)
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeGoal(sessionId?: string): GoalState | null {
|
||||||
|
const id = resolveSessionId(sessionId)
|
||||||
|
const goal = goals.get(id)
|
||||||
|
if (!goal) return null
|
||||||
|
if (goal.status !== 'paused') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const now = Date.now()
|
||||||
|
goal.startTime = now
|
||||||
|
goal.pausedAt = null
|
||||||
|
goal.status = 'active'
|
||||||
|
goal.updatedAt = now
|
||||||
|
goalLog('RESUME', 'goal resumed, blockedAttempts reset')
|
||||||
|
goal.blockedAttempts = 0
|
||||||
|
goal.lastBlockReason = null
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition an active goal into max_turns once continuation cap is hit.
|
||||||
|
* Idempotent: repeated calls while already max_turns are no-ops.
|
||||||
|
*/
|
||||||
|
export function markGoalMaxTurnsReached(sessionId?: string): GoalState | null {
|
||||||
|
const goal = getGoal(sessionId)
|
||||||
|
if (!goal || goal.status !== 'active') return null
|
||||||
|
if (goal.turnsExecuted < MAX_GOAL_TURNS) return null
|
||||||
|
goal.status = 'max_turns'
|
||||||
|
goal.updatedAt = Date.now()
|
||||||
|
goalLog('MAX_TURNS', `reached ${MAX_GOAL_TURNS} turns`)
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset continuation turn counter after a max_turns stop and resume work.
|
||||||
|
* This is a deliberate user action (`/goal continue`) to prevent silent
|
||||||
|
* runaway loops.
|
||||||
|
*/
|
||||||
|
export function continueGoalFromMaxTurns(sessionId?: string): GoalState | null {
|
||||||
|
const goal = getGoal(sessionId)
|
||||||
|
if (!goal || goal.status !== 'max_turns') return null
|
||||||
|
const now = Date.now()
|
||||||
|
goal.turnsExecuted = 0
|
||||||
|
goal.status = 'active'
|
||||||
|
goal.startTime = now
|
||||||
|
goal.pausedAt = null
|
||||||
|
goal.blockedAttempts = 0
|
||||||
|
goal.lastBlockReason = null
|
||||||
|
goal.updatedAt = now
|
||||||
|
goalLog(
|
||||||
|
'CONTINUE',
|
||||||
|
`turn counter reset, status active (max=${MAX_GOAL_TURNS})`,
|
||||||
|
)
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeGoal(sessionId?: string): GoalState | null {
|
||||||
|
const id = resolveSessionId(sessionId)
|
||||||
|
const goal = goals.get(id)
|
||||||
|
if (!goal) return null
|
||||||
|
const now = Date.now()
|
||||||
|
if (goal.status === 'active' && goal.pausedAt === null) {
|
||||||
|
goal.accumulatedActiveMs += now - goal.startTime
|
||||||
|
}
|
||||||
|
goal.status = 'complete'
|
||||||
|
goal.updatedAt = now
|
||||||
|
goalLog('COMPLETE', `goal achieved`, {
|
||||||
|
tokensUsed: goal.tokensUsed,
|
||||||
|
turns: goal.turnsExecuted,
|
||||||
|
})
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGoalTokens(
|
||||||
|
delta: number,
|
||||||
|
sessionId?: string,
|
||||||
|
): GoalState | null {
|
||||||
|
const id = resolveSessionId(sessionId)
|
||||||
|
const goal = goals.get(id)
|
||||||
|
if (!goal) return null
|
||||||
|
if (goal.status !== 'active') return null
|
||||||
|
if (!Number.isFinite(delta) || delta <= 0) return goal
|
||||||
|
const sanitized = delta
|
||||||
|
goal.tokensUsed += sanitized
|
||||||
|
goal.updatedAt = Date.now()
|
||||||
|
if (goal.tokenBudget !== null && goal.tokensUsed >= goal.tokenBudget) {
|
||||||
|
goal.status = 'budget_limited'
|
||||||
|
goalLog(
|
||||||
|
'BUDGET_LIMITED',
|
||||||
|
`tokens ${goal.tokensUsed} >= budget ${goal.tokenBudget}`,
|
||||||
|
)
|
||||||
|
} else if (sanitized > 0) {
|
||||||
|
goalLog(
|
||||||
|
'TOKENS',
|
||||||
|
`+${sanitized} → total ${goal.tokensUsed}${goal.tokenBudget ? `/${goal.tokenBudget}` : ''}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markUsageLimited(sessionId?: string): GoalState | null {
|
||||||
|
const id = resolveSessionId(sessionId)
|
||||||
|
const goal = goals.get(id)
|
||||||
|
if (!goal || goal.status !== 'active') return null
|
||||||
|
goal.status = 'usage_limited'
|
||||||
|
goal.updatedAt = Date.now()
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incrementGoalTurns(sessionId?: string): number {
|
||||||
|
const id = resolveSessionId(sessionId)
|
||||||
|
const goal = goals.get(id)
|
||||||
|
if (!goal) return 0
|
||||||
|
goal.turnsExecuted += 1
|
||||||
|
goal.updatedAt = Date.now()
|
||||||
|
goalLog('TURN', `#${goal.turnsExecuted}/${MAX_GOAL_TURNS}`, {
|
||||||
|
status: goal.status,
|
||||||
|
tokensUsed: goal.tokensUsed,
|
||||||
|
})
|
||||||
|
return goal.turnsExecuted
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordBlockedAttempt(
|
||||||
|
reason: string,
|
||||||
|
sessionId?: string,
|
||||||
|
): { status: GoalStatus; attempts: number } | null {
|
||||||
|
const id = resolveSessionId(sessionId)
|
||||||
|
const goal = goals.get(id)
|
||||||
|
if (!goal || goal.status !== 'active') return null
|
||||||
|
const normalised = reason.trim().toLowerCase()
|
||||||
|
if (
|
||||||
|
goal.lastBlockReason !== null &&
|
||||||
|
goal.lastBlockReason.trim().toLowerCase() !== normalised
|
||||||
|
) {
|
||||||
|
goal.blockedAttempts = 0
|
||||||
|
}
|
||||||
|
goal.lastBlockReason = reason
|
||||||
|
goal.blockedAttempts += 1
|
||||||
|
goal.updatedAt = Date.now()
|
||||||
|
if (goal.blockedAttempts >= BLOCKED_CONSECUTIVE_THRESHOLD) {
|
||||||
|
goal.status = 'blocked'
|
||||||
|
goalLog('BLOCKED', `3-strike reached! reason="${normalised}"`)
|
||||||
|
} else {
|
||||||
|
goalLog(
|
||||||
|
'BLOCK_ATTEMPT',
|
||||||
|
`attempt ${goal.blockedAttempts}/${BLOCKED_CONSECUTIVE_THRESHOLD} reason="${normalised}"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { status: goal.status, attempts: goal.blockedAttempts }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock time the goal has been actively worked on (excludes
|
||||||
|
* paused intervals). Used by status displays and completion reports.
|
||||||
|
*/
|
||||||
|
export function getActiveElapsedMs(goal: GoalState): number {
|
||||||
|
const ongoing =
|
||||||
|
goal.status === 'active' && goal.pausedAt === null
|
||||||
|
? Date.now() - goal.startTime
|
||||||
|
: 0
|
||||||
|
return goal.accumulatedActiveMs + ongoing
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: wipe the in-memory map without touching disk. */
|
||||||
|
export function _clearAllGoalsForTesting(): void {
|
||||||
|
goals.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test/internal: hydrate the in-memory map from persisted state.
|
||||||
|
* Called by goalStorage on session resume.
|
||||||
|
*/
|
||||||
|
export function _setGoalFromPersistedState(
|
||||||
|
state: GoalState,
|
||||||
|
sessionId?: string,
|
||||||
|
): void {
|
||||||
|
goals.set(resolveSessionId(sessionId), state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format the elapsed time as "Xm Ys" / "Ys" for UI display. */
|
||||||
|
export function formatGoalElapsed(goal: GoalState): string {
|
||||||
|
const elapsedMs = getActiveElapsedMs(goal)
|
||||||
|
const seconds = Math.floor(elapsedMs / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes === 0) return `${seconds}s`
|
||||||
|
return `${minutes}m ${seconds % 60}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable status label for UI. */
|
||||||
|
export function formatGoalStatusLabel(status: GoalStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return 'Active'
|
||||||
|
case 'paused':
|
||||||
|
return 'Paused'
|
||||||
|
case 'blocked':
|
||||||
|
return 'Blocked'
|
||||||
|
case 'budget_limited':
|
||||||
|
return 'Budget Limited'
|
||||||
|
case 'usage_limited':
|
||||||
|
return 'Usage Limited'
|
||||||
|
case 'max_turns':
|
||||||
|
return 'Max Turns Reached'
|
||||||
|
case 'complete':
|
||||||
|
return 'Complete'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { EnterWorktreeTool } from '@claude-code-best/builtin-tools/tools/EnterWorktreeTool/EnterWorktreeTool.js'
|
||||||
import { ExitWorktreeTool } from '@claude-code-best/builtin-tools/tools/ExitWorktreeTool/ExitWorktreeTool.js'
|
import { ExitWorktreeTool } from '@claude-code-best/builtin-tools/tools/ExitWorktreeTool/ExitWorktreeTool.js'
|
||||||
import { ConfigTool } from '@claude-code-best/builtin-tools/tools/ConfigTool/ConfigTool.js'
|
import { ConfigTool } from '@claude-code-best/builtin-tools/tools/ConfigTool/ConfigTool.js'
|
||||||
|
const GoalTool = feature('GOAL')
|
||||||
|
? require('@claude-code-best/builtin-tools/tools/GoalTool/GoalTool.js')
|
||||||
|
.GoalTool
|
||||||
|
: null
|
||||||
import { LocalMemoryRecallTool } from '@claude-code-best/builtin-tools/tools/LocalMemoryRecallTool/LocalMemoryRecallTool.js'
|
import { LocalMemoryRecallTool } from '@claude-code-best/builtin-tools/tools/LocalMemoryRecallTool/LocalMemoryRecallTool.js'
|
||||||
import { VaultHttpFetchTool } from '@claude-code-best/builtin-tools/tools/VaultHttpFetchTool/VaultHttpFetchTool.js'
|
import { VaultHttpFetchTool } from '@claude-code-best/builtin-tools/tools/VaultHttpFetchTool/VaultHttpFetchTool.js'
|
||||||
import { TaskCreateTool } from '@claude-code-best/builtin-tools/tools/TaskCreateTool/TaskCreateTool.js'
|
import { TaskCreateTool } from '@claude-code-best/builtin-tools/tools/TaskCreateTool/TaskCreateTool.js'
|
||||||
@@ -238,6 +242,7 @@ export function getAllBaseTools(): Tools {
|
|||||||
LocalMemoryRecallTool,
|
LocalMemoryRecallTool,
|
||||||
VaultHttpFetchTool,
|
VaultHttpFetchTool,
|
||||||
...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),
|
...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),
|
||||||
|
...(GoalTool ? [GoalTool] : []),
|
||||||
...(process.env.USER_TYPE === 'ant' ? [TungstenTool] : []),
|
...(process.env.USER_TYPE === 'ant' ? [TungstenTool] : []),
|
||||||
...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []),
|
...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []),
|
||||||
...(WebBrowserTool ? [WebBrowserTool] : []),
|
...(WebBrowserTool ? [WebBrowserTool] : []),
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ export type LocalJSXCommandOnDone = (
|
|||||||
metaMessages?: string[]
|
metaMessages?: string[]
|
||||||
nextInput?: string
|
nextInput?: string
|
||||||
submitNextInput?: boolean
|
submitNextInput?: boolean
|
||||||
|
/** Override the args shown in the command breadcrumb (e.g. truncated). Full args still reach metaMessages. */
|
||||||
|
displayArgs?: string
|
||||||
},
|
},
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export type LogOption = {
|
|||||||
mode?: 'coordinator' | 'normal' // Session mode for coordinator/normal detection
|
mode?: 'coordinator' | 'normal' // Session mode for coordinator/normal detection
|
||||||
worktreeSession?: PersistedWorktreeSession | null // Worktree state at session end (null = exited, undefined = never entered)
|
worktreeSession?: PersistedWorktreeSession | null // Worktree state at session end (null = exited, undefined = never entered)
|
||||||
contentReplacements?: ContentReplacementRecord[] // Replacement decisions for resume reconstruction
|
contentReplacements?: ContentReplacementRecord[] // Replacement decisions for resume reconstruction
|
||||||
|
goal?: GoalState // Active goal state at session end (for resume)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SummaryMessage = {
|
export type SummaryMessage = {
|
||||||
@@ -140,6 +141,77 @@ export type ModeEntry = {
|
|||||||
mode: 'coordinator' | 'normal'
|
mode: 'coordinator' | 'normal'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle states for a persistent thread goal.
|
||||||
|
* - active: agent should auto-continue toward the objective
|
||||||
|
* - paused: user temporarily halted progress
|
||||||
|
* - blocked: model reported the same blocker for >=3 consecutive turns
|
||||||
|
* - budget_limited: tokensUsed >= tokenBudget (auto-transition)
|
||||||
|
* - usage_limited: provider rate/usage limit triggered (auto-transition)
|
||||||
|
* - max_turns: auto-continuation reached MAX_GOAL_TURNS safety cap
|
||||||
|
* - complete: model audit confirmed objective achieved
|
||||||
|
*/
|
||||||
|
export type GoalStatus =
|
||||||
|
| 'active'
|
||||||
|
| 'paused'
|
||||||
|
| 'blocked'
|
||||||
|
| 'budget_limited'
|
||||||
|
| 'usage_limited'
|
||||||
|
| 'max_turns'
|
||||||
|
| 'complete'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-session goal state. Persisted to the JSONL transcript as a `goal`
|
||||||
|
* entry on every mutation; last-wins on read.
|
||||||
|
*
|
||||||
|
* Timing fields handle pause correctly: `getActiveElapsedMs(state)`
|
||||||
|
* = accumulatedActiveMs + (now - startTime if active, else 0).
|
||||||
|
*
|
||||||
|
* `turnsExecuted` is a defensive upper bound for the auto-continuation
|
||||||
|
* loop so a runaway goal cannot spin indefinitely.
|
||||||
|
*
|
||||||
|
* `blockedAttempts` + `lastBlockReason` implement CODEX's "blocked
|
||||||
|
* only after 3 consecutive same-reason attempts" audit rule.
|
||||||
|
*/
|
||||||
|
export type GoalState = {
|
||||||
|
objective: string
|
||||||
|
status: GoalStatus
|
||||||
|
tokenBudget: number | null
|
||||||
|
tokensUsed: number
|
||||||
|
startTime: number
|
||||||
|
pausedAt: number | null
|
||||||
|
accumulatedActiveMs: number
|
||||||
|
blockedAttempts: number
|
||||||
|
lastBlockReason: string | null
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
turnsExecuted: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSONL entry representing a goal-state checkpoint. Written on every
|
||||||
|
* mutation (set / pause / resume / complete / token update). Readers
|
||||||
|
* use the latest entry by sessionId as the authoritative state.
|
||||||
|
*/
|
||||||
|
export type GoalMetadataEntry = {
|
||||||
|
type: 'goal'
|
||||||
|
sessionId: UUID
|
||||||
|
state: GoalState
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSONL entry signalling the user explicitly cleared the goal.
|
||||||
|
* Distinct from `complete` (which preserves the achievement). Readers
|
||||||
|
* encountering this entry after a `goal` entry should treat the goal
|
||||||
|
* as absent.
|
||||||
|
*/
|
||||||
|
export type GoalClearedEntry = {
|
||||||
|
type: 'goal-cleared'
|
||||||
|
sessionId: UUID
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Worktree session state persisted to the transcript for resume.
|
* Worktree session state persisted to the transcript for resume.
|
||||||
* Subset of WorktreeSession from utils/worktree.ts — excludes ephemeral
|
* Subset of WorktreeSession from utils/worktree.ts — excludes ephemeral
|
||||||
@@ -315,6 +387,8 @@ export type Entry =
|
|||||||
| ContentReplacementEntry
|
| ContentReplacementEntry
|
||||||
| ContextCollapseCommitEntry
|
| ContextCollapseCommitEntry
|
||||||
| ContextCollapseSnapshotEntry
|
| ContextCollapseSnapshotEntry
|
||||||
|
| GoalMetadataEntry
|
||||||
|
| GoalClearedEntry
|
||||||
|
|
||||||
export function sortLogs(logs: LogOption[]): LogOption[] {
|
export function sortLogs(logs: LogOption[]): LogOption[] {
|
||||||
return logs.sort((a, b) => {
|
return logs.sort((a, b) => {
|
||||||
|
|||||||
@@ -507,6 +507,8 @@ export async function loadConversationForResume(
|
|||||||
prRepository?: string
|
prRepository?: string
|
||||||
// Full path to the session file (for cross-directory resume)
|
// Full path to the session file (for cross-directory resume)
|
||||||
fullPath?: string
|
fullPath?: string
|
||||||
|
// Goal state for hydration on resume
|
||||||
|
goal?: import('../types/logs.js').GoalState
|
||||||
} | null> {
|
} | null> {
|
||||||
try {
|
try {
|
||||||
let log: LogOption | null = null
|
let log: LogOption | null = null
|
||||||
@@ -618,6 +620,8 @@ export async function loadConversationForResume(
|
|||||||
prRepository: log?.prRepository,
|
prRepository: log?.prRepository,
|
||||||
// Include full path for cross-directory resume
|
// Include full path for cross-directory resume
|
||||||
fullPath: log?.fullPath,
|
fullPath: log?.fullPath,
|
||||||
|
// Goal state for hydration on resume
|
||||||
|
goal: log?.goal,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error as Error)
|
logError(error as Error)
|
||||||
|
|||||||
@@ -740,6 +740,7 @@ async function getMessagesForSlashCommand(
|
|||||||
metaMessages?: string[];
|
metaMessages?: string[];
|
||||||
nextInput?: string;
|
nextInput?: string;
|
||||||
submitNextInput?: boolean;
|
submitNextInput?: boolean;
|
||||||
|
displayArgs?: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
doneWasCalled = true;
|
doneWasCalled = true;
|
||||||
@@ -773,20 +774,22 @@ async function getMessagesForSlashCommand(
|
|||||||
const skipTranscript =
|
const skipTranscript =
|
||||||
isFullscreenEnvEnabled() && typeof result === 'string' && result.endsWith(' dismissed');
|
isFullscreenEnvEnabled() && typeof result === 'string' && result.endsWith(' dismissed');
|
||||||
|
|
||||||
|
const breadcrumbArgs = options?.displayArgs ?? args;
|
||||||
|
|
||||||
void resolve({
|
void resolve({
|
||||||
messages:
|
messages:
|
||||||
options?.display === 'system'
|
options?.display === 'system'
|
||||||
? skipTranscript
|
? skipTranscript
|
||||||
? metaMessages
|
? metaMessages
|
||||||
: [
|
: [
|
||||||
createCommandInputMessage(formatCommandInput(command, args)),
|
createCommandInputMessage(formatCommandInput(command, breadcrumbArgs)),
|
||||||
createCommandInputMessage(`<local-command-stdout>${result}</local-command-stdout>`),
|
createCommandInputMessage(`<local-command-stdout>${result}</local-command-stdout>`),
|
||||||
...metaMessages,
|
...metaMessages,
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
createUserMessage({
|
createUserMessage({
|
||||||
content: prepareUserContent({
|
content: prepareUserContent({
|
||||||
inputString: formatCommandInput(command, args),
|
inputString: formatCommandInput(command, breadcrumbArgs),
|
||||||
precedingInputBlocks,
|
precedingInputBlocks,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -146,9 +146,10 @@ export async function processUserInput({
|
|||||||
}): Promise<ProcessUserInputBaseResult> {
|
}): Promise<ProcessUserInputBaseResult> {
|
||||||
const inputString = typeof input === 'string' ? input : null
|
const inputString = typeof input === 'string' ? input : null
|
||||||
// Immediately show the user input prompt while we are still processing the input.
|
// Immediately show the user input prompt while we are still processing the input.
|
||||||
// Skip for isMeta (system-generated prompts like scheduled tasks) — those
|
// Skip for isMeta (system-generated prompts like scheduled tasks) and slash
|
||||||
// should run invisibly.
|
// commands (they produce their own system message echo via createCommandInputMessage).
|
||||||
if (mode === 'prompt' && inputString !== null && !isMeta) {
|
const isSlashInput = inputString?.startsWith('/') && !skipSlashCommands
|
||||||
|
if (mode === 'prompt' && inputString !== null && !isMeta && !isSlashInput) {
|
||||||
setUserInputOnProcessing?.(inputString)
|
setUserInputOnProcessing?.(inputString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ type ResumeLoadResult = {
|
|||||||
prNumber?: number
|
prNumber?: number
|
||||||
prUrl?: string
|
prUrl?: string
|
||||||
prRepository?: string
|
prRepository?: string
|
||||||
|
goal?: import('../types/logs.js').GoalState
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -471,6 +472,18 @@ export async function processResumedConversation(
|
|||||||
opts.forkSession ? { ...result, worktreeSession: undefined } : result,
|
opts.forkSession ? { ...result, worktreeSession: undefined } : result,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (feature('GOAL') && result.goal) {
|
||||||
|
const { hydrateGoalFromTranscript } =
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js')
|
||||||
|
const goalsMap = new Map<UUID, import('../types/logs.js').GoalState>()
|
||||||
|
const sid = (opts.sessionIdOverride ??
|
||||||
|
result.sessionId ??
|
||||||
|
getSessionId()) as UUID
|
||||||
|
goalsMap.set(sid, result.goal)
|
||||||
|
hydrateGoalFromTranscript(goalsMap, sid)
|
||||||
|
}
|
||||||
|
|
||||||
if (!opts.forkSession) {
|
if (!opts.forkSession) {
|
||||||
// Cd back into the worktree the session was in when it last exited.
|
// Cd back into the worktree the session was in when it last exited.
|
||||||
// Done after restoreSessionMetadata (which caches the worktree state
|
// Done after restoreSessionMetadata (which caches the worktree state
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
type ContextCollapseSnapshotEntry,
|
type ContextCollapseSnapshotEntry,
|
||||||
type Entry,
|
type Entry,
|
||||||
type FileHistorySnapshotMessage,
|
type FileHistorySnapshotMessage,
|
||||||
|
type GoalState,
|
||||||
type LogOption,
|
type LogOption,
|
||||||
type PersistedWorktreeSession,
|
type PersistedWorktreeSession,
|
||||||
type SerializedMessage,
|
type SerializedMessage,
|
||||||
@@ -542,6 +543,7 @@ class Project {
|
|||||||
currentSessionLastPrompt: string | undefined
|
currentSessionLastPrompt: string | undefined
|
||||||
currentSessionAgentSetting: string | undefined
|
currentSessionAgentSetting: string | undefined
|
||||||
currentSessionMode: 'coordinator' | 'normal' | undefined
|
currentSessionMode: 'coordinator' | 'normal' | undefined
|
||||||
|
currentSessionGoal: GoalState | undefined
|
||||||
// Tri-state: undefined = never touched (don't write), null = exited worktree,
|
// Tri-state: undefined = never touched (don't write), null = exited worktree,
|
||||||
// object = currently in worktree. reAppendSessionMetadata writes null so
|
// object = currently in worktree. reAppendSessionMetadata writes null so
|
||||||
// --resume knows the session exited (vs. crashed while inside).
|
// --resume knows the session exited (vs. crashed while inside).
|
||||||
@@ -827,6 +829,14 @@ class Project {
|
|||||||
sessionId,
|
sessionId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (this.currentSessionGoal) {
|
||||||
|
appendEntryToFile(this.sessionFile, {
|
||||||
|
type: 'goal',
|
||||||
|
sessionId,
|
||||||
|
state: this.currentSessionGoal,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
if (this.currentSessionWorktree !== undefined) {
|
if (this.currentSessionWorktree !== undefined) {
|
||||||
appendEntryToFile(this.sessionFile, {
|
appendEntryToFile(this.sessionFile, {
|
||||||
type: 'worktree-state',
|
type: 'worktree-state',
|
||||||
@@ -1226,6 +1236,10 @@ class Project {
|
|||||||
} else if (entry.type === 'marble-origami-snapshot') {
|
} else if (entry.type === 'marble-origami-snapshot') {
|
||||||
// Always append. Last-wins on restore — later entries supersede.
|
// Always append. Last-wins on restore — later entries supersede.
|
||||||
void this.enqueueWrite(sessionFile, entry)
|
void this.enqueueWrite(sessionFile, entry)
|
||||||
|
} else if (entry.type === 'goal') {
|
||||||
|
void this.enqueueWrite(sessionFile, entry)
|
||||||
|
} else if (entry.type === 'goal-cleared') {
|
||||||
|
void this.enqueueWrite(sessionFile, entry)
|
||||||
} else {
|
} else {
|
||||||
const messageSet = await getSessionMessages(sessionId)
|
const messageSet = await getSessionMessages(sessionId)
|
||||||
if (entry.type === 'queue-operation') {
|
if (entry.type === 'queue-operation') {
|
||||||
@@ -2723,6 +2737,48 @@ export async function saveTag(sessionId: UUID, tag: string, fullPath?: string) {
|
|||||||
logEvent('tengu_session_tagged', {})
|
logEvent('tengu_session_tagged', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a goal-state checkpoint to the JSONL transcript. Called by
|
||||||
|
* src/services/goal/goalStorage.ts on every mutation. The latest entry
|
||||||
|
* wins on read; older entries are harmlessly ignored.
|
||||||
|
*
|
||||||
|
* Cached on Project so reAppendSessionMetadata can keep the goal alive
|
||||||
|
* past compaction's tail-read window.
|
||||||
|
*/
|
||||||
|
export function saveGoal(
|
||||||
|
sessionId: UUID,
|
||||||
|
state: GoalState,
|
||||||
|
fullPath?: string,
|
||||||
|
): void {
|
||||||
|
const resolvedPath = fullPath ?? getTranscriptPathForSession(sessionId)
|
||||||
|
appendEntryToFile(resolvedPath, {
|
||||||
|
type: 'goal',
|
||||||
|
sessionId,
|
||||||
|
state,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
if (sessionId === getSessionId()) {
|
||||||
|
getProject().currentSessionGoal = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a "goal cleared" tombstone so a future --resume cannot
|
||||||
|
* resurrect the goal from a prior `goal` entry. Also drops the
|
||||||
|
* in-memory cache for the current session.
|
||||||
|
*/
|
||||||
|
export function clearGoalEntry(sessionId: UUID, fullPath?: string): void {
|
||||||
|
const resolvedPath = fullPath ?? getTranscriptPathForSession(sessionId)
|
||||||
|
appendEntryToFile(resolvedPath, {
|
||||||
|
type: 'goal-cleared',
|
||||||
|
sessionId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
if (sessionId === getSessionId()) {
|
||||||
|
getProject().currentSessionGoal = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Link a session to a GitHub pull request.
|
* Link a session to a GitHub pull request.
|
||||||
* This stores the PR number, URL, and repository for tracking and navigation.
|
* This stores the PR number, URL, and repository for tracking and navigation.
|
||||||
@@ -2791,6 +2847,7 @@ export function restoreSessionMetadata(meta: {
|
|||||||
prNumber?: number
|
prNumber?: number
|
||||||
prUrl?: string
|
prUrl?: string
|
||||||
prRepository?: string
|
prRepository?: string
|
||||||
|
goal?: GoalState
|
||||||
}): void {
|
}): void {
|
||||||
const project = getProject()
|
const project = getProject()
|
||||||
// ??= so --name (cacheSessionTitle) wins over the resumed
|
// ??= so --name (cacheSessionTitle) wins over the resumed
|
||||||
@@ -2807,6 +2864,7 @@ export function restoreSessionMetadata(meta: {
|
|||||||
project.currentSessionPrNumber = meta.prNumber
|
project.currentSessionPrNumber = meta.prNumber
|
||||||
if (meta.prUrl) project.currentSessionPrUrl = meta.prUrl
|
if (meta.prUrl) project.currentSessionPrUrl = meta.prUrl
|
||||||
if (meta.prRepository) project.currentSessionPrRepository = meta.prRepository
|
if (meta.prRepository) project.currentSessionPrRepository = meta.prRepository
|
||||||
|
if (meta.goal) project.currentSessionGoal = meta.goal
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2823,6 +2881,7 @@ export function clearSessionMetadata(): void {
|
|||||||
project.currentSessionLastPrompt = undefined
|
project.currentSessionLastPrompt = undefined
|
||||||
project.currentSessionAgentSetting = undefined
|
project.currentSessionAgentSetting = undefined
|
||||||
project.currentSessionMode = undefined
|
project.currentSessionMode = undefined
|
||||||
|
project.currentSessionGoal = undefined
|
||||||
project.currentSessionWorktree = undefined
|
project.currentSessionWorktree = undefined
|
||||||
project.currentSessionPrNumber = undefined
|
project.currentSessionPrNumber = undefined
|
||||||
project.currentSessionPrUrl = undefined
|
project.currentSessionPrUrl = undefined
|
||||||
@@ -2997,6 +3056,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
|
|||||||
prRepositories,
|
prRepositories,
|
||||||
modes,
|
modes,
|
||||||
worktreeStates,
|
worktreeStates,
|
||||||
|
goals,
|
||||||
fileHistorySnapshots,
|
fileHistorySnapshots,
|
||||||
attributionSnapshots,
|
attributionSnapshots,
|
||||||
contentReplacements,
|
contentReplacements,
|
||||||
@@ -3006,7 +3066,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
|
|||||||
} = await loadTranscriptFile(sessionFile)
|
} = await loadTranscriptFile(sessionFile)
|
||||||
|
|
||||||
if (messages.size === 0) {
|
if (messages.size === 0) {
|
||||||
return log
|
const fallbackGoal = log.sessionId
|
||||||
|
? goals.get(log.sessionId as UUID)
|
||||||
|
: undefined
|
||||||
|
return fallbackGoal ? { ...log, goal: fallbackGoal } : log
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the most recent user/assistant leaf message from the transcript
|
// Find the most recent user/assistant leaf message from the transcript
|
||||||
@@ -3017,7 +3080,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
|
|||||||
(msg.type === 'user' || msg.type === 'assistant'),
|
(msg.type === 'user' || msg.type === 'assistant'),
|
||||||
)
|
)
|
||||||
if (!mostRecentLeaf) {
|
if (!mostRecentLeaf) {
|
||||||
return log
|
const fallbackGoal = log.sessionId
|
||||||
|
? goals.get(log.sessionId as UUID)
|
||||||
|
: undefined
|
||||||
|
return fallbackGoal ? { ...log, goal: fallbackGoal } : log
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the conversation chain from this leaf
|
// Build the conversation chain from this leaf
|
||||||
@@ -3043,6 +3109,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
|
|||||||
sessionId && worktreeStates.has(sessionId)
|
sessionId && worktreeStates.has(sessionId)
|
||||||
? worktreeStates.get(sessionId)
|
? worktreeStates.get(sessionId)
|
||||||
: log.worktreeSession,
|
: log.worktreeSession,
|
||||||
|
goal: sessionId ? goals.get(sessionId) : log.goal,
|
||||||
prNumber: sessionId ? prNumbers.get(sessionId) : log.prNumber,
|
prNumber: sessionId ? prNumbers.get(sessionId) : log.prNumber,
|
||||||
prUrl: sessionId ? prUrls.get(sessionId) : log.prUrl,
|
prUrl: sessionId ? prUrls.get(sessionId) : log.prUrl,
|
||||||
prRepository: sessionId
|
prRepository: sessionId
|
||||||
@@ -3144,6 +3211,8 @@ const METADATA_TYPE_MARKERS = [
|
|||||||
'"type":"agent-setting"',
|
'"type":"agent-setting"',
|
||||||
'"type":"mode"',
|
'"type":"mode"',
|
||||||
'"type":"worktree-state"',
|
'"type":"worktree-state"',
|
||||||
|
'"type":"goal"',
|
||||||
|
'"type":"goal-cleared"',
|
||||||
'"type":"pr-link"',
|
'"type":"pr-link"',
|
||||||
]
|
]
|
||||||
const METADATA_MARKER_BUFS = METADATA_TYPE_MARKERS.map(m => Buffer.from(m))
|
const METADATA_MARKER_BUFS = METADATA_TYPE_MARKERS.map(m => Buffer.from(m))
|
||||||
@@ -3510,6 +3579,7 @@ export async function loadTranscriptFile(
|
|||||||
prRepositories: Map<UUID, string>
|
prRepositories: Map<UUID, string>
|
||||||
modes: Map<UUID, string>
|
modes: Map<UUID, string>
|
||||||
worktreeStates: Map<UUID, PersistedWorktreeSession | null>
|
worktreeStates: Map<UUID, PersistedWorktreeSession | null>
|
||||||
|
goals: Map<UUID, GoalState>
|
||||||
fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage>
|
fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage>
|
||||||
attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
|
attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
|
||||||
contentReplacements: Map<UUID, ContentReplacementRecord[]>
|
contentReplacements: Map<UUID, ContentReplacementRecord[]>
|
||||||
@@ -3530,6 +3600,7 @@ export async function loadTranscriptFile(
|
|||||||
const prRepositories = new Map<UUID, string>()
|
const prRepositories = new Map<UUID, string>()
|
||||||
const modes = new Map<UUID, string>()
|
const modes = new Map<UUID, string>()
|
||||||
const worktreeStates = new Map<UUID, PersistedWorktreeSession | null>()
|
const worktreeStates = new Map<UUID, PersistedWorktreeSession | null>()
|
||||||
|
const goals = new Map<UUID, GoalState>()
|
||||||
const fileHistorySnapshots = new Map<UUID, FileHistorySnapshotMessage>()
|
const fileHistorySnapshots = new Map<UUID, FileHistorySnapshotMessage>()
|
||||||
const attributionSnapshots = new Map<UUID, AttributionSnapshotMessage>()
|
const attributionSnapshots = new Map<UUID, AttributionSnapshotMessage>()
|
||||||
const contentReplacements = new Map<UUID, ContentReplacementRecord[]>()
|
const contentReplacements = new Map<UUID, ContentReplacementRecord[]>()
|
||||||
@@ -3628,6 +3699,10 @@ export async function loadTranscriptFile(
|
|||||||
modes.set(entry.sessionId, entry.mode)
|
modes.set(entry.sessionId, entry.mode)
|
||||||
} else if (entry.type === 'worktree-state' && entry.sessionId) {
|
} else if (entry.type === 'worktree-state' && entry.sessionId) {
|
||||||
worktreeStates.set(entry.sessionId, entry.worktreeSession)
|
worktreeStates.set(entry.sessionId, entry.worktreeSession)
|
||||||
|
} else if (entry.type === 'goal' && entry.sessionId) {
|
||||||
|
goals.set(entry.sessionId, entry.state)
|
||||||
|
} else if (entry.type === 'goal-cleared' && entry.sessionId) {
|
||||||
|
goals.delete(entry.sessionId)
|
||||||
} else if (entry.type === 'pr-link' && entry.sessionId) {
|
} else if (entry.type === 'pr-link' && entry.sessionId) {
|
||||||
prNumbers.set(entry.sessionId, entry.prNumber)
|
prNumbers.set(entry.sessionId, entry.prNumber)
|
||||||
prUrls.set(entry.sessionId, entry.prUrl)
|
prUrls.set(entry.sessionId, entry.prUrl)
|
||||||
@@ -3696,6 +3771,10 @@ export async function loadTranscriptFile(
|
|||||||
modes.set(entry.sessionId, entry.mode)
|
modes.set(entry.sessionId, entry.mode)
|
||||||
} else if (entry.type === 'worktree-state' && entry.sessionId) {
|
} else if (entry.type === 'worktree-state' && entry.sessionId) {
|
||||||
worktreeStates.set(entry.sessionId, entry.worktreeSession)
|
worktreeStates.set(entry.sessionId, entry.worktreeSession)
|
||||||
|
} else if (entry.type === 'goal' && entry.sessionId) {
|
||||||
|
goals.set(entry.sessionId, entry.state)
|
||||||
|
} else if (entry.type === 'goal-cleared' && entry.sessionId) {
|
||||||
|
goals.delete(entry.sessionId)
|
||||||
} else if (entry.type === 'pr-link' && entry.sessionId) {
|
} else if (entry.type === 'pr-link' && entry.sessionId) {
|
||||||
prNumbers.set(entry.sessionId, entry.prNumber)
|
prNumbers.set(entry.sessionId, entry.prNumber)
|
||||||
prUrls.set(entry.sessionId, entry.prUrl)
|
prUrls.set(entry.sessionId, entry.prUrl)
|
||||||
@@ -3827,6 +3906,7 @@ export async function loadTranscriptFile(
|
|||||||
prRepositories,
|
prRepositories,
|
||||||
modes,
|
modes,
|
||||||
worktreeStates,
|
worktreeStates,
|
||||||
|
goals,
|
||||||
fileHistorySnapshots,
|
fileHistorySnapshots,
|
||||||
attributionSnapshots,
|
attributionSnapshots,
|
||||||
contentReplacements,
|
contentReplacements,
|
||||||
@@ -3847,6 +3927,7 @@ async function loadSessionFile(sessionId: UUID): Promise<{
|
|||||||
tags: Map<UUID, string>
|
tags: Map<UUID, string>
|
||||||
agentSettings: Map<UUID, string>
|
agentSettings: Map<UUID, string>
|
||||||
worktreeStates: Map<UUID, PersistedWorktreeSession | null>
|
worktreeStates: Map<UUID, PersistedWorktreeSession | null>
|
||||||
|
goals: Map<UUID, GoalState>
|
||||||
fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage>
|
fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage>
|
||||||
attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
|
attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
|
||||||
contentReplacements: Map<UUID, ContentReplacementRecord[]>
|
contentReplacements: Map<UUID, ContentReplacementRecord[]>
|
||||||
@@ -3905,6 +3986,7 @@ export async function getLastSessionLog(
|
|||||||
fileHistorySnapshots,
|
fileHistorySnapshots,
|
||||||
attributionSnapshots,
|
attributionSnapshots,
|
||||||
contentReplacements,
|
contentReplacements,
|
||||||
|
goals,
|
||||||
contextCollapseCommits,
|
contextCollapseCommits,
|
||||||
contextCollapseSnapshot,
|
contextCollapseSnapshot,
|
||||||
} = await loadSessionFile(sessionId)
|
} = await loadSessionFile(sessionId)
|
||||||
@@ -3946,6 +4028,7 @@ export async function getLastSessionLog(
|
|||||||
contentReplacements.get(sessionId) ?? [],
|
contentReplacements.get(sessionId) ?? [],
|
||||||
),
|
),
|
||||||
worktreeSession: worktreeStates.get(sessionId),
|
worktreeSession: worktreeStates.get(sessionId),
|
||||||
|
goal: goals.get(sessionId),
|
||||||
contextCollapseCommits: contextCollapseCommits.filter(
|
contextCollapseCommits: contextCollapseCommits.filter(
|
||||||
e => e.sessionId === sessionId,
|
e => e.sessionId === sessionId,
|
||||||
),
|
),
|
||||||
|
|||||||
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