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,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,
])
}