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

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

View File

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