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

@@ -507,6 +507,8 @@ export async function loadConversationForResume(
prRepository?: string
// Full path to the session file (for cross-directory resume)
fullPath?: string
// Goal state for hydration on resume
goal?: import('../types/logs.js').GoalState
} | null> {
try {
let log: LogOption | null = null
@@ -618,6 +620,8 @@ export async function loadConversationForResume(
prRepository: log?.prRepository,
// Include full path for cross-directory resume
fullPath: log?.fullPath,
// Goal state for hydration on resume
goal: log?.goal,
}
} catch (error) {
logError(error as Error)

View File

@@ -740,6 +740,7 @@ async function getMessagesForSlashCommand(
metaMessages?: string[];
nextInput?: string;
submitNextInput?: boolean;
displayArgs?: string;
},
) => {
doneWasCalled = true;
@@ -773,20 +774,22 @@ async function getMessagesForSlashCommand(
const skipTranscript =
isFullscreenEnvEnabled() && typeof result === 'string' && result.endsWith(' dismissed');
const breadcrumbArgs = options?.displayArgs ?? args;
void resolve({
messages:
options?.display === 'system'
? skipTranscript
? metaMessages
: [
createCommandInputMessage(formatCommandInput(command, args)),
createCommandInputMessage(formatCommandInput(command, breadcrumbArgs)),
createCommandInputMessage(`<local-command-stdout>${result}</local-command-stdout>`),
...metaMessages,
]
: [
createUserMessage({
content: prepareUserContent({
inputString: formatCommandInput(command, args),
inputString: formatCommandInput(command, breadcrumbArgs),
precedingInputBlocks,
}),
}),

View File

@@ -146,9 +146,10 @@ export async function processUserInput({
}): Promise<ProcessUserInputBaseResult> {
const inputString = typeof input === 'string' ? input : null
// Immediately show the user input prompt while we are still processing the input.
// Skip for isMeta (system-generated prompts like scheduled tasks) — those
// should run invisibly.
if (mode === 'prompt' && inputString !== null && !isMeta) {
// Skip for isMeta (system-generated prompts like scheduled tasks) and slash
// commands (they produce their own system message echo via createCommandInputMessage).
const isSlashInput = inputString?.startsWith('/') && !skipSlashCommands
if (mode === 'prompt' && inputString !== null && !isMeta && !isSlashInput) {
setUserInputOnProcessing?.(inputString)
}

View File

@@ -312,6 +312,7 @@ type ResumeLoadResult = {
prNumber?: number
prUrl?: string
prRepository?: string
goal?: import('../types/logs.js').GoalState
}
/**
@@ -471,6 +472,18 @@ export async function processResumedConversation(
opts.forkSession ? { ...result, worktreeSession: undefined } : result,
)
if (feature('GOAL') && result.goal) {
const { hydrateGoalFromTranscript } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js')
const goalsMap = new Map<UUID, import('../types/logs.js').GoalState>()
const sid = (opts.sessionIdOverride ??
result.sessionId ??
getSessionId()) as UUID
goalsMap.set(sid, result.goal)
hydrateGoalFromTranscript(goalsMap, sid)
}
if (!opts.forkSession) {
// Cd back into the worktree the session was in when it last exited.
// Done after restoreSessionMetadata (which caches the worktree state

View File

@@ -48,6 +48,7 @@ import {
type ContextCollapseSnapshotEntry,
type Entry,
type FileHistorySnapshotMessage,
type GoalState,
type LogOption,
type PersistedWorktreeSession,
type SerializedMessage,
@@ -542,6 +543,7 @@ class Project {
currentSessionLastPrompt: string | undefined
currentSessionAgentSetting: string | undefined
currentSessionMode: 'coordinator' | 'normal' | undefined
currentSessionGoal: GoalState | undefined
// Tri-state: undefined = never touched (don't write), null = exited worktree,
// object = currently in worktree. reAppendSessionMetadata writes null so
// --resume knows the session exited (vs. crashed while inside).
@@ -827,6 +829,14 @@ class Project {
sessionId,
})
}
if (this.currentSessionGoal) {
appendEntryToFile(this.sessionFile, {
type: 'goal',
sessionId,
state: this.currentSessionGoal,
timestamp: new Date().toISOString(),
})
}
if (this.currentSessionWorktree !== undefined) {
appendEntryToFile(this.sessionFile, {
type: 'worktree-state',
@@ -1226,6 +1236,10 @@ class Project {
} else if (entry.type === 'marble-origami-snapshot') {
// Always append. Last-wins on restore — later entries supersede.
void this.enqueueWrite(sessionFile, entry)
} else if (entry.type === 'goal') {
void this.enqueueWrite(sessionFile, entry)
} else if (entry.type === 'goal-cleared') {
void this.enqueueWrite(sessionFile, entry)
} else {
const messageSet = await getSessionMessages(sessionId)
if (entry.type === 'queue-operation') {
@@ -2723,6 +2737,48 @@ export async function saveTag(sessionId: UUID, tag: string, fullPath?: string) {
logEvent('tengu_session_tagged', {})
}
/**
* Persist a goal-state checkpoint to the JSONL transcript. Called by
* src/services/goal/goalStorage.ts on every mutation. The latest entry
* wins on read; older entries are harmlessly ignored.
*
* Cached on Project so reAppendSessionMetadata can keep the goal alive
* past compaction's tail-read window.
*/
export function saveGoal(
sessionId: UUID,
state: GoalState,
fullPath?: string,
): void {
const resolvedPath = fullPath ?? getTranscriptPathForSession(sessionId)
appendEntryToFile(resolvedPath, {
type: 'goal',
sessionId,
state,
timestamp: new Date().toISOString(),
})
if (sessionId === getSessionId()) {
getProject().currentSessionGoal = state
}
}
/**
* Persist a "goal cleared" tombstone so a future --resume cannot
* resurrect the goal from a prior `goal` entry. Also drops the
* in-memory cache for the current session.
*/
export function clearGoalEntry(sessionId: UUID, fullPath?: string): void {
const resolvedPath = fullPath ?? getTranscriptPathForSession(sessionId)
appendEntryToFile(resolvedPath, {
type: 'goal-cleared',
sessionId,
timestamp: new Date().toISOString(),
})
if (sessionId === getSessionId()) {
getProject().currentSessionGoal = undefined
}
}
/**
* Link a session to a GitHub pull request.
* This stores the PR number, URL, and repository for tracking and navigation.
@@ -2791,6 +2847,7 @@ export function restoreSessionMetadata(meta: {
prNumber?: number
prUrl?: string
prRepository?: string
goal?: GoalState
}): void {
const project = getProject()
// ??= so --name (cacheSessionTitle) wins over the resumed
@@ -2807,6 +2864,7 @@ export function restoreSessionMetadata(meta: {
project.currentSessionPrNumber = meta.prNumber
if (meta.prUrl) project.currentSessionPrUrl = meta.prUrl
if (meta.prRepository) project.currentSessionPrRepository = meta.prRepository
if (meta.goal) project.currentSessionGoal = meta.goal
}
/**
@@ -2823,6 +2881,7 @@ export function clearSessionMetadata(): void {
project.currentSessionLastPrompt = undefined
project.currentSessionAgentSetting = undefined
project.currentSessionMode = undefined
project.currentSessionGoal = undefined
project.currentSessionWorktree = undefined
project.currentSessionPrNumber = undefined
project.currentSessionPrUrl = undefined
@@ -2997,6 +3056,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
prRepositories,
modes,
worktreeStates,
goals,
fileHistorySnapshots,
attributionSnapshots,
contentReplacements,
@@ -3006,7 +3066,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
} = await loadTranscriptFile(sessionFile)
if (messages.size === 0) {
return log
const fallbackGoal = log.sessionId
? goals.get(log.sessionId as UUID)
: undefined
return fallbackGoal ? { ...log, goal: fallbackGoal } : log
}
// Find the most recent user/assistant leaf message from the transcript
@@ -3017,7 +3080,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
(msg.type === 'user' || msg.type === 'assistant'),
)
if (!mostRecentLeaf) {
return log
const fallbackGoal = log.sessionId
? goals.get(log.sessionId as UUID)
: undefined
return fallbackGoal ? { ...log, goal: fallbackGoal } : log
}
// Build the conversation chain from this leaf
@@ -3043,6 +3109,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
sessionId && worktreeStates.has(sessionId)
? worktreeStates.get(sessionId)
: log.worktreeSession,
goal: sessionId ? goals.get(sessionId) : log.goal,
prNumber: sessionId ? prNumbers.get(sessionId) : log.prNumber,
prUrl: sessionId ? prUrls.get(sessionId) : log.prUrl,
prRepository: sessionId
@@ -3144,6 +3211,8 @@ const METADATA_TYPE_MARKERS = [
'"type":"agent-setting"',
'"type":"mode"',
'"type":"worktree-state"',
'"type":"goal"',
'"type":"goal-cleared"',
'"type":"pr-link"',
]
const METADATA_MARKER_BUFS = METADATA_TYPE_MARKERS.map(m => Buffer.from(m))
@@ -3510,6 +3579,7 @@ export async function loadTranscriptFile(
prRepositories: Map<UUID, string>
modes: Map<UUID, string>
worktreeStates: Map<UUID, PersistedWorktreeSession | null>
goals: Map<UUID, GoalState>
fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage>
attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
contentReplacements: Map<UUID, ContentReplacementRecord[]>
@@ -3530,6 +3600,7 @@ export async function loadTranscriptFile(
const prRepositories = new Map<UUID, string>()
const modes = new Map<UUID, string>()
const worktreeStates = new Map<UUID, PersistedWorktreeSession | null>()
const goals = new Map<UUID, GoalState>()
const fileHistorySnapshots = new Map<UUID, FileHistorySnapshotMessage>()
const attributionSnapshots = new Map<UUID, AttributionSnapshotMessage>()
const contentReplacements = new Map<UUID, ContentReplacementRecord[]>()
@@ -3628,6 +3699,10 @@ export async function loadTranscriptFile(
modes.set(entry.sessionId, entry.mode)
} else if (entry.type === 'worktree-state' && entry.sessionId) {
worktreeStates.set(entry.sessionId, entry.worktreeSession)
} else if (entry.type === 'goal' && entry.sessionId) {
goals.set(entry.sessionId, entry.state)
} else if (entry.type === 'goal-cleared' && entry.sessionId) {
goals.delete(entry.sessionId)
} else if (entry.type === 'pr-link' && entry.sessionId) {
prNumbers.set(entry.sessionId, entry.prNumber)
prUrls.set(entry.sessionId, entry.prUrl)
@@ -3696,6 +3771,10 @@ export async function loadTranscriptFile(
modes.set(entry.sessionId, entry.mode)
} else if (entry.type === 'worktree-state' && entry.sessionId) {
worktreeStates.set(entry.sessionId, entry.worktreeSession)
} else if (entry.type === 'goal' && entry.sessionId) {
goals.set(entry.sessionId, entry.state)
} else if (entry.type === 'goal-cleared' && entry.sessionId) {
goals.delete(entry.sessionId)
} else if (entry.type === 'pr-link' && entry.sessionId) {
prNumbers.set(entry.sessionId, entry.prNumber)
prUrls.set(entry.sessionId, entry.prUrl)
@@ -3827,6 +3906,7 @@ export async function loadTranscriptFile(
prRepositories,
modes,
worktreeStates,
goals,
fileHistorySnapshots,
attributionSnapshots,
contentReplacements,
@@ -3847,6 +3927,7 @@ async function loadSessionFile(sessionId: UUID): Promise<{
tags: Map<UUID, string>
agentSettings: Map<UUID, string>
worktreeStates: Map<UUID, PersistedWorktreeSession | null>
goals: Map<UUID, GoalState>
fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage>
attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
contentReplacements: Map<UUID, ContentReplacementRecord[]>
@@ -3905,6 +3986,7 @@ export async function getLastSessionLog(
fileHistorySnapshots,
attributionSnapshots,
contentReplacements,
goals,
contextCollapseCommits,
contextCollapseSnapshot,
} = await loadSessionFile(sessionId)
@@ -3946,6 +4028,7 @@ export async function getLastSessionLog(
contentReplacements.get(sessionId) ?? [],
),
worktreeSession: worktreeStates.get(sessionId),
goal: goals.get(sessionId),
contextCollapseCommits: contextCollapseCommits.filter(
e => e.sessionId === sessionId,
),