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

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

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

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

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

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

* fix: 防止goal状态丢失

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

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

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

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

* fix: apply biome formatting to pass CI lint check

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

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

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

---------

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

View File

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

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

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

View File

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