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

@@ -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,
),