From 2f8b1fa929d91658ba847edfad47a3bc578c231f Mon Sep 17 00:00:00 2001 From: Yi-Fan Wang Date: Tue, 1 Sep 2026 21:42:20 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(core):=20=E6=96=B0=E5=A2=9E=20startPla?= =?UTF-8?q?nImplementationSession=20=E4=BB=A5=E5=9C=A8=E5=85=A8=E6=96=B0?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E4=B8=AD=E5=AE=9E=E6=96=BD=E5=B7=B2?= =?UTF-8?q?=E6=89=B9=E5=87=86=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan Mode 批准方案后,若直接在同一会话中实现,规划阶段的探索记录 (文件读取、工具输出、来回问答、被否定的思路)会全部留在上下文里, 导致执行阶段 token 消耗高、噪声大。 新增 SessionManager.startPlanImplementationSession,以源会话为基准派生一个 干净的新会话:仅携带系统提示、运行上下文、AGENTS.md 指令与 全文;磁盘产物与文件历史通过 GitFileHistory.forkSession 指向源会话 checkpoint 保持 /undo 可追溯,原规划会话保留在 sessions-index.json 供 /resume 回看。 Co-authored-by: Claude Code --- packages/core/src/session.ts | 95 +++++++++++++++++++++++++ packages/core/src/tests/session.test.ts | 81 +++++++++++++++++++++ 2 files changed, 176 insertions(+) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b2c82bc3..48d76a0b 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -2319,6 +2319,101 @@ ${agentInstructions} return sessionId; } + /** + * Derive a clean implementation session from a completed Plan Mode session. + * Unlike forkSession (which copies the full conversation history), this builds a + * fresh message list carrying only the system prompt, runtime context, AGENTS.md + * instructions, and the approved plan — so implementation starts from a clean + * context while file history stays traceable to the source session's checkpoint. + */ + startPlanImplementationSession(sourceSessionId: string, planText: string): string { + const source = this.getSession(sourceSessionId); + if (!source) { + throw new Error(`No saved session found with ID "${sourceSessionId}".`); + } + + const sourceMessages = this.listSessionMessages(sourceSessionId); + const sourceMessage = sourceMessages.at(-1); + if (!sourceMessage || typeof sourceMessage.id !== "string" || !sourceMessage.id) { + throw new Error(`Session "${sourceSessionId}" has no messages to derive from.`); + } + + const sessionId = crypto.randomUUID(); + const now = new Date().toISOString(); + const entry: SessionEntry = { + id: sessionId, + summary: source.summary, + assistantReply: null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + usage: null, + usagePerModel: null, + activeTokens: 0, + createTime: now, + updateTime: now, + processes: null, + planMode: false, + forkedFrom: { + sessionId: sourceSessionId, + messageId: sourceMessage.id, + }, + }; + + const promptToolOptions = this.getPromptToolOptions(); + const messages: SessionMessage[] = [ + this.buildSystemMessage(sessionId, getSystemPrompt(this.projectRoot, promptToolOptions)), + this.buildSystemMessage( + sessionId, + getRuntimeContext( + this.projectRoot, + promptToolOptions.model, + this.getResolvedSettings().permissions?.addWorkingDirs + ) + ), + ]; + + const agentInstructions = this.loadAgentInstructions(); + if (agentInstructions) { + messages.push(this.buildSystemMessage(sessionId, agentInstructions)); + } + + messages.push( + this.buildSystemMessage(sessionId, `\n${planText}\n`, null, false, { + isSummary: true, + }) + ); + + this.saveSessionMessages(sessionId, messages); + this.getFileHistory().forkSession(sourceSessionId, sessionId); + + const index = this.loadSessionsIndex(); + index.entries.push(entry); + const sortedEntries = index.entries.slice().sort((a, b) => { + const aTime = Date.parse(a.updateTime); + const bTime = Date.parse(b.updateTime); + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { + return b.updateTime.localeCompare(a.updateTime); + } + return bTime - aTime; + }); + const keptEntries = sortedEntries.slice(0, MAX_SESSION_ENTRIES); + const keptIds = new Set(keptEntries.map((item) => item.id)); + const droppedEntries = sortedEntries.filter((item) => !keptIds.has(item.id)); + index.entries = keptEntries; + this.saveSessionsIndex(index); + for (const dropped of droppedEntries) { + this.cleanupSessionResources(dropped.id, { + removeMessages: true, + processIds: this.getProcessIds(dropped.processes ?? null), + }); + } + + return sessionId; + } + /** * Delete a session by its ID. * Removes the session entry from the index and cleans up associated resources diff --git a/packages/core/src/tests/session.test.ts b/packages/core/src/tests/session.test.ts index eac53655..85cd0cb8 100644 --- a/packages/core/src/tests/session.test.ts +++ b/packages/core/src/tests/session.test.ts @@ -4618,6 +4618,87 @@ test("SessionManager.forkSession copies conversation state with fresh usage and assert.equal(fileHistory.getCurrentCheckpointHash(sourceSessionId), sourceCheckpoint); }); +test("SessionManager.startPlanImplementationSession derives a clean context with the approved plan", () => { + if (!hasGit()) { + return; + } + + const workspace = createTempDir("deepcode-plan-impl-workspace-"); + const home = createTempDir("deepcode-plan-impl-home-"); + setHomeDir(home); + const manager = createSessionManager(workspace, "machine-id-plan-impl"); + const sourceSessionId = createSessionAndMessages(manager, "source-session", "Plan source"); + const now = "2026-01-01T00:00:00.000Z"; + const index = (manager as any).loadSessionsIndex(); + index.entries[0] = { + ...index.entries[0], + summary: "Plan source", + planMode: true, + }; + (manager as any).saveSessionsIndex(index); + + const sourceMessages: SessionMessage[] = [ + { + id: "source-user-message", + sessionId: sourceSessionId, + role: "user", + content: "Plan source", + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + }, + { + id: "source-head-message", + sessionId: sourceSessionId, + role: "assistant", + content: "\nOld plan\n", + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + }, + ]; + (manager as any).saveSessionMessages(sourceSessionId, sourceMessages); + + const trackedPath = path.join(workspace, "tracked.txt"); + fs.writeFileSync(trackedPath, "source", "utf8"); + const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace)); + const sourceCheckpoint = fileHistory.recordCheckpoint(sourceSessionId, [trackedPath], "source checkpoint"); + assert.ok(sourceCheckpoint); + + const planText = "Build a thing\nwith two steps."; + const sessionId = manager.startPlanImplementationSession(sourceSessionId, planText); + const derived = manager.getSession(sessionId); + assert.ok(derived); + assert.equal(derived.summary, "Plan source"); + assert.equal(derived.planMode, false); + assert.equal(derived.usage, null); + assert.equal(derived.usagePerModel, null); + assert.equal(derived.activeTokens, 0); + assert.equal(derived.status, "completed"); + assert.deepEqual(derived.forkedFrom, { + sessionId: sourceSessionId, + messageId: "source-head-message", + }); + + const messages = manager.listSessionMessages(sessionId); + assert.equal(messages.length, 3); + assert.ok(messages.every((message) => message.role === "system")); + assert.ok(!messages.some((message) => message.id === "source-user-message" || message.id === "source-head-message")); + const planMessage = messages.at(-1)!; + assert.equal(planMessage.visible, false); + assert.equal(planMessage.meta?.isSummary, true); + assert.equal(planMessage.content, `\n${planText}\n`); + + assert.equal(fileHistory.getCurrentCheckpointHash(sessionId), sourceCheckpoint); + assert.deepEqual(manager.listSessionMessages(sourceSessionId), sourceMessages); +}); + test("SessionManager ignores malformed fork lineage in persisted entries", () => { const workspace = createTempDir("deepcode-fork-lineage-workspace-"); const home = createTempDir("deepcode-fork-lineage-home-"); From c3a605f71ed3b97fdd0536a1bef0a610d5d0c549 Mon Sep 17 00:00:00 2001 From: Yi-Fan Wang Date: Tue, 1 Sep 2026 21:42:33 +0200 Subject: [PATCH 2/9] =?UTF-8?q?feat(cli):=20Plan=20=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E6=96=B0=E5=A2=9E"=E6=B8=85=E9=99=A4=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E5=B9=B6=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92"?= =?UTF-8?q?=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 批准弹窗原仅 3 个选项,选择 "implement this plan" 会在同一会话继续, 规划阶段的上下文噪声无法消除。新增第 4 项 "clear context and implement this plan":调用 startPlanImplementationSession 派生干净的新会话后发送 同一实现指令,并将源会话标题追加"(规划)"后缀以便在会话列表区分。 数字键选择与底部提示由 1-3 扩展为 1-4。 Co-authored-by: Claude Code --- .../cli/src/tests/prompt-input-keys.test.ts | 12 +++++ packages/cli/src/ui/views/App.tsx | 53 +++++++++++++++++-- .../src/ui/views/PlanImplementationPrompt.tsx | 7 +-- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/tests/prompt-input-keys.test.ts b/packages/cli/src/tests/prompt-input-keys.test.ts index 07e03acb..af17ad66 100644 --- a/packages/cli/src/tests/prompt-input-keys.test.ts +++ b/packages/cli/src/tests/prompt-input-keys.test.ts @@ -185,6 +185,18 @@ test("getPlanImplementationChoice treats escape as staying in Plan Mode", () => assert.equal(getPlanImplementationChoice("", { escape: true, return: false }, 0), "stay"); }); +test("getPlanImplementationChoice maps digit keys 1-4 to the four choices", () => { + assert.equal(getPlanImplementationChoice("1", { escape: false, return: false }, 0), "implement"); + assert.equal(getPlanImplementationChoice("2", { escape: false, return: false }, 0), "stay"); + assert.equal(getPlanImplementationChoice("3", { escape: false, return: false }, 0), "default"); + assert.equal(getPlanImplementationChoice("4", { escape: false, return: false }, 0), "clearContext"); +}); + +test("getPlanImplementationChoice selects the choice at the cursor on return", () => { + assert.equal(getPlanImplementationChoice("", { escape: false, return: true }, 0), "implement"); + assert.equal(getPlanImplementationChoice("", { escape: false, return: true }, 3), "clearContext"); +}); + test("prompt return key action submits on plain enter", () => { const { key } = parseTerminalInput("\r"); assert.equal(getPromptReturnKeyAction(key), "submit"); diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 4f107fd6..7dc5b7ac 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -20,7 +20,12 @@ import { formatAskUserQuestionAnswers, } from "../core/ask-user-question"; import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt"; -import { PlanImplementationPrompt, extractProposedPlan, getImplementationPrompt } from "./PlanImplementationPrompt"; +import { + PlanImplementationPrompt, + extractProposedPlan, + getImplementationPrompt, + type PlanImplementationChoice, +} from "./PlanImplementationPrompt"; import { buildExitSummaryText, buildPluginRateLimitHintText, buildResumeHintText } from "../exit-summary"; import { RawMode, useRawModeContext } from "../contexts"; import { renderMessageToStdout } from "../components/MessageView/utils"; @@ -561,12 +566,46 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes ); const handlePlanImplementationChoice = useCallback( - (choice: "implement" | "stay" | "default") => { + async (choice: PlanImplementationChoice) => { const proposedPlan = pendingPlanImplementation; setPendingPlanImplementation(null); if (choice === "stay") { return; } + if (choice === "clearContext" && proposedPlan) { + const sourceSessionId = sessionManager.getActiveSessionId(); + if (!sourceSessionId) { + setErrorLine("No active session to derive from."); + return; + } + try { + const sessionId = sessionManager.startPlanImplementationSession(sourceSessionId, proposedPlan); + sessionManager.setActiveSessionId(sessionId); + await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); + const session = sessionManager.getSession(sessionId); + setStatusLine(session ? buildStatusLine(session, resolveCurrentSettings(projectRoot)) : ""); + setRunningProcesses(null); + setActiveStatus(session?.status ?? null); + setActiveAskPermissions(undefined); + setPlanMode(false); + setPendingPermissionReply(null); + setErrorLine(null); + const source = sessionManager.getSession(sourceSessionId); + if (source?.summary && !source.summary.includes("(规划)")) { + sessionManager.renameSession(sourceSessionId, `${source.summary}(规划)`); + } + refreshSessionsList(); + await refreshSkills(sessionId); + handleSubmit({ + text: getImplementationPrompt(proposedPlan), + imageUrls: [], + planMode: false, + }); + } catch (error) { + setErrorLine(error instanceof Error ? error.message : String(error)); + } + return; + } setPlanMode(false); if (choice === "implement" && proposedPlan) { handleSubmit({ @@ -576,7 +615,15 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes }); } }, - [handleSubmit, pendingPlanImplementation] + [ + handleSubmit, + pendingPlanImplementation, + sessionManager, + resetStaticView, + refreshSessionsList, + refreshSkills, + projectRoot, + ] ); const handleExitShortcut = useCallback(() => { diff --git a/packages/cli/src/ui/views/PlanImplementationPrompt.tsx b/packages/cli/src/ui/views/PlanImplementationPrompt.tsx index e97b361a..e8d6240e 100644 --- a/packages/cli/src/ui/views/PlanImplementationPrompt.tsx +++ b/packages/cli/src/ui/views/PlanImplementationPrompt.tsx @@ -3,7 +3,7 @@ import { Box, Text } from "ink"; import { useTerminalInput } from "../hooks"; import type { InputKey } from "../hooks"; -type PlanImplementationChoice = "implement" | "stay" | "default"; +export type PlanImplementationChoice = "implement" | "stay" | "default" | "clearContext"; type Props = { onSelect: (choice: PlanImplementationChoice) => void; @@ -13,6 +13,7 @@ const CHOICES: Array<{ value: PlanImplementationChoice; label: string }> = [ { value: "implement", label: "implement this plan" }, { value: "stay", label: "stay in Plan mode" }, { value: "default", label: "switch to Default mode" }, + { value: "clearContext", label: "clear context and implement this plan" }, ]; /** Return only a complete proposed plan, so historical or partial tags cannot trigger the chooser. */ @@ -37,7 +38,7 @@ export function getPlanImplementationChoice( if (key.escape) { return "stay"; } - if (input && /^[1-3]$/.test(input)) { + if (input && /^[1-4]$/.test(input)) { return CHOICES[Number(input) - 1]!.value; } return key.return ? CHOICES[cursor]!.value : null; @@ -81,7 +82,7 @@ export function PlanImplementationPrompt({ onSelect }: Props): React.ReactElemen ))} - 1-3 select · ↑/↓ move · Enter select + 1-4 select · ↑/↓ move · Enter select ); From f6354260133f8f1f0bc88440230eb05b854d9076 Mon Sep 17 00:00:00 2001 From: Yi-Fan Wang Date: Tue, 1 Sep 2026 21:42:40 +0200 Subject: [PATCH 3/9] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20Plan=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=96=87=E6=A1=A3=E5=B9=B6=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=20README=20=E6=96=9C=E6=9D=A0=E5=91=BD=E4=BB=A4=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan Mode 批准弹窗新增 "clear context and implement this plan" 选项后, 同步更新 docs/plan-mode*.md 的选项表(数字键提示 1-3 → 1-4),并在三份 README 的斜杠命令表中补上此前缺失的 /plan 行。 Co-authored-by: Claude Code --- README-en.md | 1 + README-zh_CN.md | 1 + README.md | 1 + docs/plan-mode.md | 3 ++- docs/plan-mode_en.md | 3 ++- 5 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README-en.md b/README-en.md index f55b9d99..e598571d 100644 --- a/README-en.md +++ b/README-en.md @@ -78,6 +78,7 @@ Skills are discovered from these locations, in priority order: | `/fork` | Fork the current conversation | | `/continue` | Continue the active conversation or pick one to resume | | `/model` | Switch model, thinking mode, and reasoning effort | +| `/plan` | Switch the input to Plan Mode | | `/raw` | Toggle display mode (Normal / Lite / Raw scrollback) | | `/init` | Initialize an AGENTS.md file (LLM project instructions) | | `/skills` | List available skills | diff --git a/README-zh_CN.md b/README-zh_CN.md index 933b6faf..0ef14942 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -77,6 +77,7 @@ Skills 会按以下优先级扫描: | `/fork` | 从当前对话创建独立的新会话 | | `/continue` | 继续当前对话,或选择历史对话恢复 | | `/model` | 切换模型、思考模式和推理强度 | +| `/plan` | 切换到规划模式(Plan Mode) | | `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | | `/init` | 初始化 AGENTS.md 文件 | | `/skills` | 列出可用 skills | diff --git a/README.md b/README.md index 0a7515e5..4db0bc1e 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Skills 会按以下优先级扫描: | `/fork` | 从当前对话创建独立的新会话 | | `/continue` | 继续当前对话,或选择历史对话恢复 | | `/model` | 切换模型、思考模式和推理强度 | +| `/plan` | 切换到规划模式(Plan Mode) | | `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | | `/init` | 初始化 AGENTS.md 文件 | | `/skills` | 列出可用 skills | diff --git a/docs/plan-mode.md b/docs/plan-mode.md index c1ece359..e953cbd0 100644 --- a/docs/plan-mode.md +++ b/docs/plan-mode.md @@ -92,8 +92,9 @@ Plan Mode 的核心规则是**只规划,不动手**。例如以下操作是** | **1. implement this plan** | 退出 Plan Mode,自动发送实现指令,让 AI 开始按方案写代码 | | **2. stay in Plan mode** | 保持在 Plan Mode,继续修改或完善方案 | | **3. switch to Default mode** | 退出 Plan Mode,回到默认模式(不自动开始实现) | +| **4. clear context and implement this plan** | 派生一个干净的新会话(仅携带系统提示与方案),在全新上下文中开始实现 | -你可以用数字键 `1-3` 直接选择,也可以用 `↑/↓` 移动光标后按 `Enter` 确认。按 `Esc` 等同于选择 "stay in Plan mode"。 +你可以用数字键 `1-4` 直接选择,也可以用 `↑/↓` 移动光标后按 `Enter` 确认。按 `Esc` 等同于选择 "stay in Plan mode"。 ## Plan Mode 与 UpdatePlan 工具的区别 diff --git a/docs/plan-mode_en.md b/docs/plan-mode_en.md index 5b53c801..3ba30252 100644 --- a/docs/plan-mode_en.md +++ b/docs/plan-mode_en.md @@ -92,8 +92,9 @@ After the plan is output, Deep Code automatically shows a choice dialog—no ext | **1. implement this plan** | Leave Plan Mode and automatically send an implementation prompt so the AI starts coding | | **2. stay in Plan mode** | Stay in Plan Mode to continue refining the plan | | **3. switch to Default mode** | Leave Plan Mode and return to Default mode without starting implementation | +| **4. clear context and implement this plan** | Derive a clean new session (carrying only the system prompt and plan) to implement in a fresh context | -You can press `1-3` to select directly, or use `↑/↓` to move the cursor and `Enter` to confirm. Pressing `Esc` is equivalent to choosing "stay in Plan mode." +You can press `1-4` to select directly, or use `↑/↓` to move the cursor and `Enter` to confirm. Pressing `Esc` is equivalent to choosing "stay in Plan mode." ## Plan Mode vs. UpdatePlan Tool From 01ab3266aaab53668cfc33069f82ce589a16b7c9 Mon Sep 17 00:00:00 2001 From: Yi-Fan Wang Date: Tue, 1 Sep 2026 23:42:59 +0200 Subject: [PATCH 4/9] =?UTF-8?q?feat(core):=20=E4=B8=BA=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E6=96=B9=E6=A1=88=E6=B3=A8=E5=85=A5=E6=9D=A5=E6=BA=90=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E5=B9=B6=E5=A2=9E=E5=BC=BA=E6=B4=BE=E7=94=9F=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 clear-context 实施方案注入来源说明,并补齐派生会话校验。 原方案消息仅是一段裸的 ,实现模型缺少方案来源与角色的 说明;startPlanImplementationSession 也未校验源会话是否处于 Plan Mode、 方案文本是否为空。本次以中英文指令包装方案,新增 planMode 与非空校验, 以 isPlan 标记替代 isSummary,并提取 registerSessionEntry 复用 forkSession 的注册/排序/截断逻辑。 Co-authored-by: deepseek-v4-pro --- packages/core/src/session.ts | 90 +++++++++++++------------ packages/core/src/tests/session.test.ts | 41 ++++++++++- 2 files changed, 85 insertions(+), 46 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 48d76a0b..7088484f 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -108,6 +108,15 @@ const PLAN_MODE_FORCE_ASK_SCOPES = [ "mutate-git-log", ] as const satisfies readonly PermissionScope[]; +function buildPlanImplementationMessage(planText: string): string { + const fullWidthPunctuationCount = (planText.match(/[,、;。]/g) ?? []).length; + const directive = + fullWidthPunctuationCount > 5 + ? "先前的一位智能体产出了以下方案以完成用户任务。请在全新上下文中实现该方案,把方案视为用户意图的来源,按需重新读取文件,并持续推进到实现与验证。" + : "A previous agent produced the plan below to accomplish the user's task. Implement the plan in a fresh context. Treat the plan as the source of user intent, re-read files as needed, and carry the work through implementation and verification."; + return `${directive}\n\n\n${planText}\n`; +} + type ChatCompletionDebugOptions = { enabled?: boolean; location: string; @@ -308,6 +317,7 @@ export type MessageMeta = { asThinking?: boolean; isAnswers?: boolean; isSummary?: boolean; + isPlan?: boolean; isModelChange?: boolean; skill?: SkillInfo; skillCatalog?: Array<{ name: string; description: string }>; @@ -2251,6 +2261,30 @@ ${agentInstructions} return index.entries.find((entry) => entry.id === sessionId) ?? null; } + private registerSessionEntry(entry: SessionEntry): void { + const index = this.loadSessionsIndex(); + index.entries.push(entry); + const sortedEntries = index.entries.slice().sort((a, b) => { + const aTime = Date.parse(a.updateTime); + const bTime = Date.parse(b.updateTime); + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { + return b.updateTime.localeCompare(a.updateTime); + } + return bTime - aTime; + }); + const keptEntries = sortedEntries.slice(0, MAX_SESSION_ENTRIES); + const keptIds = new Set(keptEntries.map((item) => item.id)); + const droppedEntries = sortedEntries.filter((item) => !keptIds.has(item.id)); + index.entries = keptEntries; + this.saveSessionsIndex(index); + for (const dropped of droppedEntries) { + this.cleanupSessionResources(dropped.id, { + removeMessages: true, + processIds: this.getProcessIds(dropped.processes ?? null), + }); + } + } + forkSession(sourceSessionId: string): string { const source = this.getSession(sourceSessionId); if (!source) { @@ -2294,27 +2328,7 @@ ${agentInstructions} this.saveSessionMessages(sessionId, forkedMessages); this.getFileHistory().forkSession(sourceSessionId, sessionId); - const index = this.loadSessionsIndex(); - index.entries.push(entry); - const sortedEntries = index.entries.slice().sort((a, b) => { - const aTime = Date.parse(a.updateTime); - const bTime = Date.parse(b.updateTime); - if (Number.isNaN(aTime) || Number.isNaN(bTime)) { - return b.updateTime.localeCompare(a.updateTime); - } - return bTime - aTime; - }); - const keptEntries = sortedEntries.slice(0, MAX_SESSION_ENTRIES); - const keptIds = new Set(keptEntries.map((item) => item.id)); - const droppedEntries = sortedEntries.filter((item) => !keptIds.has(item.id)); - index.entries = keptEntries; - this.saveSessionsIndex(index); - for (const dropped of droppedEntries) { - this.cleanupSessionResources(dropped.id, { - removeMessages: true, - processIds: this.getProcessIds(dropped.processes ?? null), - }); - } + this.registerSessionEntry(entry); return sessionId; } @@ -2331,6 +2345,14 @@ ${agentInstructions} if (!source) { throw new Error(`No saved session found with ID "${sourceSessionId}".`); } + if (source.planMode !== true) { + throw new Error(`Session "${sourceSessionId}" is not in Plan Mode.`); + } + + const trimmedPlanText = planText.trim(); + if (!trimmedPlanText) { + throw new Error("The approved plan text must not be empty."); + } const sourceMessages = this.listSessionMessages(sourceSessionId); const sourceMessage = sourceMessages.at(-1); @@ -2381,35 +2403,15 @@ ${agentInstructions} } messages.push( - this.buildSystemMessage(sessionId, `\n${planText}\n`, null, false, { - isSummary: true, + this.buildSystemMessage(sessionId, buildPlanImplementationMessage(trimmedPlanText), null, false, { + isPlan: true, }) ); this.saveSessionMessages(sessionId, messages); this.getFileHistory().forkSession(sourceSessionId, sessionId); - const index = this.loadSessionsIndex(); - index.entries.push(entry); - const sortedEntries = index.entries.slice().sort((a, b) => { - const aTime = Date.parse(a.updateTime); - const bTime = Date.parse(b.updateTime); - if (Number.isNaN(aTime) || Number.isNaN(bTime)) { - return b.updateTime.localeCompare(a.updateTime); - } - return bTime - aTime; - }); - const keptEntries = sortedEntries.slice(0, MAX_SESSION_ENTRIES); - const keptIds = new Set(keptEntries.map((item) => item.id)); - const droppedEntries = sortedEntries.filter((item) => !keptIds.has(item.id)); - index.entries = keptEntries; - this.saveSessionsIndex(index); - for (const dropped of droppedEntries) { - this.cleanupSessionResources(dropped.id, { - removeMessages: true, - processIds: this.getProcessIds(dropped.processes ?? null), - }); - } + this.registerSessionEntry(entry); return sessionId; } diff --git a/packages/core/src/tests/session.test.ts b/packages/core/src/tests/session.test.ts index 85cd0cb8..bf50e07e 100644 --- a/packages/core/src/tests/session.test.ts +++ b/packages/core/src/tests/session.test.ts @@ -4692,13 +4692,50 @@ test("SessionManager.startPlanImplementationSession derives a clean context with assert.ok(!messages.some((message) => message.id === "source-user-message" || message.id === "source-head-message")); const planMessage = messages.at(-1)!; assert.equal(planMessage.visible, false); - assert.equal(planMessage.meta?.isSummary, true); - assert.equal(planMessage.content, `\n${planText}\n`); + assert.equal(planMessage.meta?.isPlan, true); + assert.equal( + planMessage.content, + `A previous agent produced the plan below to accomplish the user's task. Implement the plan in a fresh context. Treat the plan as the source of user intent, re-read files as needed, and carry the work through implementation and verification.\n\n\n${planText}\n` + ); assert.equal(fileHistory.getCurrentCheckpointHash(sessionId), sourceCheckpoint); assert.deepEqual(manager.listSessionMessages(sourceSessionId), sourceMessages); }); +test("SessionManager.startPlanImplementationSession rejects a source session that is not in Plan Mode", () => { + if (!hasGit()) { + return; + } + + const workspace = createTempDir("deepcode-plan-impl-nonplan-workspace-"); + const home = createTempDir("deepcode-plan-impl-nonplan-home-"); + setHomeDir(home); + const manager = createSessionManager(workspace, "machine-id-plan-impl-nonplan"); + const sourceSessionId = createSessionAndMessages(manager, "source-session", "Not a plan session"); + + assert.throws(() => manager.startPlanImplementationSession(sourceSessionId, "Build a thing."), /is not in Plan Mode/); +}); + +test("SessionManager.startPlanImplementationSession rejects an empty plan text", () => { + if (!hasGit()) { + return; + } + + const workspace = createTempDir("deepcode-plan-impl-empty-workspace-"); + const home = createTempDir("deepcode-plan-impl-empty-home-"); + setHomeDir(home); + const manager = createSessionManager(workspace, "machine-id-plan-impl-empty"); + const sourceSessionId = createSessionAndMessages(manager, "source-session", "Plan source"); + const index = (manager as any).loadSessionsIndex(); + index.entries[0] = { + ...index.entries[0], + planMode: true, + }; + (manager as any).saveSessionsIndex(index); + + assert.throws(() => manager.startPlanImplementationSession(sourceSessionId, " \n"), /must not be empty/); +}); + test("SessionManager ignores malformed fork lineage in persisted entries", () => { const workspace = createTempDir("deepcode-fork-lineage-workspace-"); const home = createTempDir("deepcode-fork-lineage-home-"); From ee06bb71e29eaf2cc646f491afce51c3f30bdb91 Mon Sep 17 00:00:00 2001 From: Yi-Fan Wang Date: Tue, 1 Sep 2026 23:43:04 +0200 Subject: [PATCH 5/9] =?UTF-8?q?fix(cli):=20=E8=A7=84=E5=88=92=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=A0=87=E9=A2=98=E4=BD=BF=E7=94=A8=E8=8B=B1=E6=96=87?= =?UTF-8?q?=E5=90=8E=E7=BC=80=E5=B9=B6=E5=9C=A8=E5=A4=B1=E8=B4=A5=E6=97=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=96=B9=E6=A1=88=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清除上下文派生失败时不再提前关闭方案弹窗,供用户重新选择。 源会话标题后缀由中文改为英文 " (planned)",与英文选择界面保持一致; 派生成功后才清除 pendingPlanImplementation,失败时在 catch 中恢复。 Co-authored-by: deepseek-v4-pro --- packages/cli/src/ui/views/App.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 7dc5b7ac..891ee7b3 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -568,8 +568,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes const handlePlanImplementationChoice = useCallback( async (choice: PlanImplementationChoice) => { const proposedPlan = pendingPlanImplementation; - setPendingPlanImplementation(null); if (choice === "stay") { + setPendingPlanImplementation(null); return; } if (choice === "clearContext" && proposedPlan) { @@ -581,6 +581,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes try { const sessionId = sessionManager.startPlanImplementationSession(sourceSessionId, proposedPlan); sessionManager.setActiveSessionId(sessionId); + setPendingPlanImplementation(null); await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); const session = sessionManager.getSession(sessionId); setStatusLine(session ? buildStatusLine(session, resolveCurrentSettings(projectRoot)) : ""); @@ -591,8 +592,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes setPendingPermissionReply(null); setErrorLine(null); const source = sessionManager.getSession(sourceSessionId); - if (source?.summary && !source.summary.includes("(规划)")) { - sessionManager.renameSession(sourceSessionId, `${source.summary}(规划)`); + if (source?.summary && !source.summary.includes(" (planned)")) { + sessionManager.renameSession(sourceSessionId, `${source.summary} (planned)`); } refreshSessionsList(); await refreshSkills(sessionId); @@ -603,9 +604,11 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes }); } catch (error) { setErrorLine(error instanceof Error ? error.message : String(error)); + setPendingPlanImplementation(proposedPlan); } return; } + setPendingPlanImplementation(null); setPlanMode(false); if (choice === "implement" && proposedPlan) { handleSubmit({ From 6f9f13c77542ae45d5cea52eeb42ae42e947076f Mon Sep 17 00:00:00 2001 From: Yi-Fan Wang Date: Tue, 1 Sep 2026 23:43:05 +0200 Subject: [PATCH 6/9] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20Plan=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E9=80=89=E9=A1=B9=204=20=E7=9A=84=E6=90=BA?= =?UTF-8?q?=E5=B8=A6=E5=86=85=E5=AE=B9=E4=B8=8E=20skills=20=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确选项 4 派生会话携带系统提示、运行上下文、AGENTS.md 指令与方案, 并说明规划阶段加载的 skills 不会带入实现会话。 Co-authored-by: deepseek-v4-pro --- docs/plan-mode.md | 4 +++- docs/plan-mode_en.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/plan-mode.md b/docs/plan-mode.md index e953cbd0..dfa636b6 100644 --- a/docs/plan-mode.md +++ b/docs/plan-mode.md @@ -92,7 +92,9 @@ Plan Mode 的核心规则是**只规划,不动手**。例如以下操作是** | **1. implement this plan** | 退出 Plan Mode,自动发送实现指令,让 AI 开始按方案写代码 | | **2. stay in Plan mode** | 保持在 Plan Mode,继续修改或完善方案 | | **3. switch to Default mode** | 退出 Plan Mode,回到默认模式(不自动开始实现) | -| **4. clear context and implement this plan** | 派生一个干净的新会话(仅携带系统提示与方案),在全新上下文中开始实现 | +| **4. clear context and implement this plan** | 派生一个干净的新会话(仅携带系统提示、运行上下文、AGENTS.md 指令与方案),在全新上下文中开始实现 | + +注意:该选项不会把规划阶段加载的 skills 带入新会话;实现时可按需通过 skill 工具重新加载。 你可以用数字键 `1-4` 直接选择,也可以用 `↑/↓` 移动光标后按 `Enter` 确认。按 `Esc` 等同于选择 "stay in Plan mode"。 diff --git a/docs/plan-mode_en.md b/docs/plan-mode_en.md index 3ba30252..98740f11 100644 --- a/docs/plan-mode_en.md +++ b/docs/plan-mode_en.md @@ -92,7 +92,9 @@ After the plan is output, Deep Code automatically shows a choice dialog—no ext | **1. implement this plan** | Leave Plan Mode and automatically send an implementation prompt so the AI starts coding | | **2. stay in Plan mode** | Stay in Plan Mode to continue refining the plan | | **3. switch to Default mode** | Leave Plan Mode and return to Default mode without starting implementation | -| **4. clear context and implement this plan** | Derive a clean new session (carrying only the system prompt and plan) to implement in a fresh context | +| **4. clear context and implement this plan** | Derive a clean new session (carrying only the system prompt, runtime context, AGENTS.md instructions, and the plan) to implement in a fresh context | + +Note: skills loaded during planning are not carried over; implementation can reload them via the skill tool as needed. You can press `1-4` to select directly, or use `↑/↓` to move the cursor and `Enter` to confirm. Pressing `Esc` is equivalent to choosing "stay in Plan mode." From 0d65ea6bdf6e182455965ad5fde00ee53386156b Mon Sep 17 00:00:00 2001 From: Yi-Fan Wang Date: Wed, 2 Sep 2026 10:39:27 +0200 Subject: [PATCH 7/9] =?UTF-8?q?fix(core):=20=E4=BF=AE=E6=AD=A3=E5=B9=B2?= =?UTF-8?q?=E5=87=80=E8=AE=A1=E5=88=92=E4=BA=A4=E6=8E=A5=E7=9A=84=E6=8C=87?= =?UTF-8?q?=E4=BB=A4=E5=B1=82=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧实现直接信任调用方传入的方案,并将其作为系统消息注入;源会话记录的 skill 信息也可能过期。这会改变方案原本的用户指令层级,并使干净会话中的 skill 重新发现不可靠。 现在只允许从已完成的 Plan Mode 会话派生,要求方案与最近一条完整的 完全一致,并记录其确切消息来源。派生会话通过共享逻辑重建当前可信前缀,按名称或路径重新解析精简 skill 目录,再把交接指令与方案作为首条用户消息正常提交;创建失败时清理未使用的会话与文件历史。 未采用“通用实现指令加隐藏系统方案”,因为它会绕过正常的 skill 匹配并提升方案权限。 验证:git diff --check、npm run check 和 npm test 均通过。 Co-authored-by: Codex --- packages/cli/src/ui/views/App.tsx | 38 ++- .../src/ui/views/PlanImplementationPrompt.tsx | 10 +- packages/core/src/common/file-history.ts | 13 + packages/core/src/index.ts | 9 +- packages/core/src/session.ts | 258 +++++++++------ packages/core/src/tests/session.test.ts | 306 +++++++++++++++++- 6 files changed, 508 insertions(+), 126 deletions(-) diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 891ee7b3..f5511a01 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -578,10 +578,16 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes setErrorLine("No active session to derive from."); return; } + let derivedSessionId: string | null = null; + let submissionStarted = false; try { - const sessionId = sessionManager.startPlanImplementationSession(sourceSessionId, proposedPlan); + const { sessionId, implementationPrompt } = await sessionManager.startPlanImplementationSession( + sourceSessionId, + proposedPlan + ); + derivedSessionId = sessionId; sessionManager.setActiveSessionId(sessionId); - setPendingPlanImplementation(null); + processStdoutRef.current.clear(); await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); const session = sessionManager.getSession(sessionId); setStatusLine(session ? buildStatusLine(session, resolveCurrentSettings(projectRoot)) : ""); @@ -591,20 +597,35 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes setPlanMode(false); setPendingPermissionReply(null); setErrorLine(null); - const source = sessionManager.getSession(sourceSessionId); - if (source?.summary && !source.summary.includes(" (planned)")) { - sessionManager.renameSession(sourceSessionId, `${source.summary} (planned)`); - } refreshSessionsList(); await refreshSkills(sessionId); - handleSubmit({ - text: getImplementationPrompt(proposedPlan), + setPendingPlanImplementation(null); + submissionStarted = true; + await handlePrompt({ + text: implementationPrompt, imageUrls: [], planMode: false, }); } catch (error) { + if (submissionStarted) { + setErrorLine(error instanceof Error ? error.message : String(error)); + return; + } + if (derivedSessionId) { + sessionManager.deleteSession(derivedSessionId); + } + sessionManager.setActiveSessionId(sourceSessionId); + processStdoutRef.current.clear(); + await resetStaticView(loadVisibleMessages(sessionManager, sourceSessionId), { clearScreen: true }); + const source = sessionManager.getSession(sourceSessionId); + setStatusLine(source ? buildStatusLine(source, resolveCurrentSettings(projectRoot)) : ""); + setRunningProcesses(source?.processes ?? null); + setActiveStatus(source?.status ?? null); + setActiveAskPermissions(source?.askPermissions); + setPlanMode(true); setErrorLine(error instanceof Error ? error.message : String(error)); setPendingPlanImplementation(proposedPlan); + refreshSessionsList(); } return; } @@ -620,6 +641,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes }, [ handleSubmit, + handlePrompt, pendingPlanImplementation, sessionManager, resetStaticView, diff --git a/packages/cli/src/ui/views/PlanImplementationPrompt.tsx b/packages/cli/src/ui/views/PlanImplementationPrompt.tsx index e8d6240e..c717a21b 100644 --- a/packages/cli/src/ui/views/PlanImplementationPrompt.tsx +++ b/packages/cli/src/ui/views/PlanImplementationPrompt.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; import { Box, Text } from "ink"; +import { extractProposedPlan } from "@vegamo/deepcode-core"; import { useTerminalInput } from "../hooks"; import type { InputKey } from "../hooks"; @@ -16,14 +17,7 @@ const CHOICES: Array<{ value: PlanImplementationChoice; label: string }> = [ { value: "clearContext", label: "clear context and implement this plan" }, ]; -/** Return only a complete proposed plan, so historical or partial tags cannot trigger the chooser. */ -export function extractProposedPlan(reply: string | null): string | null { - if (!reply) { - return null; - } - const match = reply.match(/\s*([\s\S]*?\S[\s\S]*?)\s*<\/proposed_plan>/); - return match?.[1] ?? null; -} +export { extractProposedPlan }; export function getImplementationPrompt(plan: string): string { const fullWidthPunctuationCount = (plan.match(/[,、;。]/g) ?? []).length; diff --git a/packages/core/src/common/file-history.ts b/packages/core/src/common/file-history.ts index 43d08d4b..2b2eaca1 100644 --- a/packages/core/src/common/file-history.ts +++ b/packages/core/src/common/file-history.ts @@ -87,6 +87,19 @@ export class GitFileHistory { } } + deleteSession(sessionId: string): void { + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef || !fs.existsSync(this.gitDir)) { + return; + } + + try { + this.runGit(["update-ref", "-d", branchRef]); + } catch { + // File history is best effort and must not block session cleanup. + } + } + recordCheckpoint(sessionId: string, filePaths: string[], message: string): string | undefined { const branchRef = this.getSessionBranchRef(sessionId); if (!branchRef) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5fb2c01f..2df5eb12 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,7 +41,13 @@ export type { } from "./settings"; // Session -export { SessionManager, getProjectCode, getCompactPromptTokenThreshold } from "./session"; +export { + SessionManager, + buildPlanImplementationHandoff, + extractProposedPlan, + getProjectCode, + getCompactPromptTokenThreshold, +} from "./session"; export type { SessionMessage, SessionEntry, @@ -58,6 +64,7 @@ export type { LlmStreamProgress, LlmRetryEvent, SessionManagerOptions, + PlanImplementationSessionResult, } from "./session"; // Prompt utilities diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 7088484f..e289e94b 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -108,7 +108,19 @@ const PLAN_MODE_FORCE_ASK_SCOPES = [ "mutate-git-log", ] as const satisfies readonly PermissionScope[]; -function buildPlanImplementationMessage(planText: string): string { +export function extractProposedPlan(content: string | null): string | null { + if (!content) { + return null; + } + + let latestPlan: string | null = null; + for (const match of content.matchAll(/\s*([\s\S]*?\S[\s\S]*?)\s*<\/proposed_plan>/g)) { + latestPlan = match[1] ?? null; + } + return latestPlan; +} + +export function buildPlanImplementationHandoff(planText: string): string { const fullWidthPunctuationCount = (planText.match(/[,、;。]/g) ?? []).length; const directive = fullWidthPunctuationCount > 5 @@ -177,6 +189,10 @@ function isUsageRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + function summarizeCompletionOptions(options?: Record): Record | undefined { if (!options) { return undefined; @@ -300,6 +316,16 @@ export type SessionEntry = { sessionId: string; messageId: string; }; + derivedFrom?: { + kind: "plan-implementation"; + sessionId: string; + messageId: string; + }; +}; + +export type PlanImplementationSessionResult = { + sessionId: string; + implementationPrompt: string; }; export type SessionsIndex = { @@ -317,7 +343,6 @@ export type MessageMeta = { asThinking?: boolean; isAnswers?: boolean; isSummary?: boolean; - isPlan?: boolean; isModelChange?: boolean; skill?: SkillInfo; skillCatalog?: Array<{ name: string; description: string }>; @@ -395,7 +420,7 @@ export type SessionManagerOptions = { onLlmStreamProgress?: (progress: LlmStreamProgress) => void; onLlmRetry?: (event: LlmRetryEvent) => void; onMcpStatusChanged?: () => void; - onProcessStdout?: (pid: number, chunk: string) => void; + onProcessStdout?: (pid: number, chunk: string, sessionId: string) => void; loadSharp?: SharpLoader; nonInteractive?: boolean; }; @@ -442,7 +467,7 @@ export class SessionManager { private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void; private readonly onLlmRetry?: (event: LlmRetryEvent) => void; private readonly onMcpStatusChanged?: () => void; - private readonly onProcessStdout?: (pid: number, chunk: string) => void; + private readonly onProcessStdout?: (pid: number, chunk: string, sessionId: string) => void; private readonly nonInteractive: boolean; private activeSessionId: string | null = null; private activePromptController: AbortController | null = null; @@ -1475,7 +1500,6 @@ ${agentInstructions} userPrompt = this.preparePromptImages(sessionId, userPrompt); this.ensureFileHistorySession(sessionId); const now = new Date().toISOString(); - const index = this.loadSessionsIndex(); const entry: SessionEntry = { id: sessionId, summary: originalSummary, @@ -1493,46 +1517,10 @@ ${agentInstructions} processes: null, planMode: Boolean(userPrompt.planMode), }; - index.entries.push(entry); - const sortedEntries = index.entries.slice().sort((a, b) => { - const aTime = Date.parse(a.updateTime); - const bTime = Date.parse(b.updateTime); - if (Number.isNaN(aTime) || Number.isNaN(bTime)) { - return b.updateTime.localeCompare(a.updateTime); - } - return bTime - aTime; - }); - const keptEntries = sortedEntries.slice(0, MAX_SESSION_ENTRIES); - const keptIds = new Set(keptEntries.map((item) => item.id)); - const droppedEntries = sortedEntries.filter((item) => !keptIds.has(item.id)); - index.entries = keptEntries; - this.saveSessionsIndex(index); - for (const dropped of droppedEntries) { - this.cleanupSessionResources(dropped.id, { - removeMessages: true, - processIds: this.getProcessIds(dropped.processes ?? null), - }); - } - - const promptToolOptions = this.getPromptToolOptions(); - const systemPrompt = getSystemPrompt(this.projectRoot, promptToolOptions); - const systemMessage = this.buildSystemMessage(sessionId, systemPrompt); - this.appendSessionMessage(sessionId, systemMessage); - - const runtimeContextMessage = this.buildSystemMessage( - sessionId, - getRuntimeContext( - this.projectRoot, - promptToolOptions.model, - this.getResolvedSettings().permissions?.addWorkingDirs - ) - ); - this.appendSessionMessage(sessionId, runtimeContextMessage); + this.registerSessionEntry(entry); - const agentInstructions = this.loadAgentInstructions(); - if (agentInstructions) { - const instructionsMessage = this.buildSystemMessage(sessionId, agentInstructions); - this.appendSessionMessage(sessionId, instructionsMessage); + for (const message of this.buildTrustedSessionPrefix(sessionId)) { + this.appendSessionMessage(sessionId, message); } this.appendPlanModeTransitionMessages(sessionId, false, Boolean(userPrompt.planMode)); @@ -1541,15 +1529,17 @@ ${agentInstructions} const userMessage = this.buildUserMessage(sessionId, userPrompt); this.appendSessionMessage(sessionId, userMessage); + this.activeSessionId = sessionId; + let matchedSkills: SkillInfo[] = []; if (userPrompt.text) { - const skills = await this.listSkills(); - const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal }); + const skills = await this.listSkills(sessionId); + const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal, sessionId }); this.throwIfAborted(signal); const skillSet = new Set(skillNames); matchedSkills = skills.filter((skill) => skillSet.has(skill.name)); } - userPrompt.skills = await this.normalizeSkills(userPrompt.skills); + userPrompt.skills = await this.normalizeSkills(userPrompt.skills, sessionId); this.throwIfAborted(signal); this.appendSkillMessages(sessionId, userPrompt.skills); @@ -1561,7 +1551,6 @@ ${agentInstructions} ) ); - this.activeSessionId = sessionId; await this.activateSession(sessionId, controller); return sessionId; } @@ -2285,6 +2274,16 @@ ${agentInstructions} } } + private removeSessionEntryBestEffort(sessionId: string): void { + try { + const index = this.loadSessionsIndex(); + index.entries = index.entries.filter((entry) => entry.id !== sessionId); + this.saveSessionsIndex(index); + } catch { + // Preserve the original creation error; cleanup is best effort. + } + } + forkSession(sourceSessionId: string): string { const source = this.getSession(sourceSessionId); if (!source) { @@ -2336,11 +2335,15 @@ ${agentInstructions} /** * Derive a clean implementation session from a completed Plan Mode session. * Unlike forkSession (which copies the full conversation history), this builds a - * fresh message list carrying only the system prompt, runtime context, AGENTS.md - * instructions, and the approved plan — so implementation starts from a clean - * context while file history stays traceable to the source session's checkpoint. + * fresh trusted prefix carrying the current system prompt, runtime context, + * AGENTS.md instructions, and a compact current skill catalog. The caller submits + * the returned user-role handoff so implementation starts from a clean context + * while file history stays traceable to the source session's checkpoint. */ - startPlanImplementationSession(sourceSessionId: string, planText: string): string { + async startPlanImplementationSession( + sourceSessionId: string, + expectedPlan: string + ): Promise { const source = this.getSession(sourceSessionId); if (!source) { throw new Error(`No saved session found with ID "${sourceSessionId}".`); @@ -2348,17 +2351,59 @@ ${agentInstructions} if (source.planMode !== true) { throw new Error(`Session "${sourceSessionId}" is not in Plan Mode.`); } - - const trimmedPlanText = planText.trim(); - if (!trimmedPlanText) { + if (source.status !== "completed") { + throw new Error(`Session "${sourceSessionId}" is not completed.`); + } + if (!expectedPlan.trim()) { throw new Error("The approved plan text must not be empty."); } const sourceMessages = this.listSessionMessages(sourceSessionId); - const sourceMessage = sourceMessages.at(-1); - if (!sourceMessage || typeof sourceMessage.id !== "string" || !sourceMessage.id) { - throw new Error(`Session "${sourceSessionId}" has no messages to derive from.`); + let sourceMessage: SessionMessage | undefined; + let planText: string | null = null; + for (let index = sourceMessages.length - 1; index >= 0; index -= 1) { + const message = sourceMessages[index]; + if (message?.role !== "assistant") { + continue; + } + const proposedPlan = extractProposedPlan(message.content); + if (proposedPlan !== null) { + sourceMessage = message; + planText = proposedPlan; + break; + } + } + if (!sourceMessage || planText === null) { + throw new Error(`Session "${sourceSessionId}" has no complete proposed plan to implement.`); + } + if (!isNonEmptyString(sourceMessage.id)) { + throw new Error(`Session "${sourceSessionId}" has a proposed plan without a valid message ID.`); + } + if (expectedPlan !== planText) { + throw new Error("The approved plan no longer matches the latest proposed plan in the source session."); + } + + const advertisedSkillNames = new Set(); + const advertisedSkillPaths = new Set(); + for (const message of sourceMessages) { + if (typeof message.meta?.skill?.name === "string" && message.meta.skill.name) { + advertisedSkillNames.add(message.meta.skill.name); + } + if (typeof message.meta?.skill?.path === "string" && message.meta.skill.path) { + advertisedSkillPaths.add(message.meta.skill.path); + } + if (Array.isArray(message.meta?.skillCatalog)) { + for (const skill of message.meta.skillCatalog) { + if (typeof skill?.name === "string" && skill.name) { + advertisedSkillNames.add(skill.name); + } + } + } } + const currentSkills = await this.listSkills(); + const skillCatalog = currentSkills + .filter((skill) => advertisedSkillNames.has(skill.name) || advertisedSkillPaths.has(skill.path)) + .map((skill) => ({ name: skill.name, description: skill.description })); const sessionId = crypto.randomUUID(); const now = new Date().toISOString(); @@ -2378,42 +2423,34 @@ ${agentInstructions} updateTime: now, processes: null, planMode: false, - forkedFrom: { + derivedFrom: { + kind: "plan-implementation", sessionId: sourceSessionId, messageId: sourceMessage.id, }, }; - const promptToolOptions = this.getPromptToolOptions(); - const messages: SessionMessage[] = [ - this.buildSystemMessage(sessionId, getSystemPrompt(this.projectRoot, promptToolOptions)), - this.buildSystemMessage( - sessionId, - getRuntimeContext( - this.projectRoot, - promptToolOptions.model, - this.getResolvedSettings().permissions?.addWorkingDirs - ) - ), - ]; - - const agentInstructions = this.loadAgentInstructions(); - if (agentInstructions) { - messages.push(this.buildSystemMessage(sessionId, agentInstructions)); + const messages = this.buildTrustedSessionPrefix(sessionId); + if (skillCatalog.length > 0) { + messages.push( + this.buildSystemMessage(sessionId, buildSkillCatalogPrompt(skillCatalog), null, false, { skillCatalog }) + ); } - messages.push( - this.buildSystemMessage(sessionId, buildPlanImplementationMessage(trimmedPlanText), null, false, { - isPlan: true, - }) - ); - - this.saveSessionMessages(sessionId, messages); - this.getFileHistory().forkSession(sourceSessionId, sessionId); - - this.registerSessionEntry(entry); + try { + this.saveSessionMessages(sessionId, messages); + this.getFileHistory().forkSession(sourceSessionId, sessionId); + this.registerSessionEntry(entry); + } catch (error) { + this.removeSessionEntryBestEffort(sessionId); + this.cleanupSessionResources(sessionId, { removeMessages: true }); + throw error; + } - return sessionId; + return { + sessionId, + implementationPrompt: buildPlanImplementationHandoff(planText), + }; } /** @@ -2734,6 +2771,7 @@ ${agentInstructions} controller.abort(); } this.sessionControllers.delete(sessionId); + this.getFileHistory().deleteSession(sessionId); if (options.removeMessages) { this.removeSessionMessages([sessionId]); try { @@ -2995,6 +3033,26 @@ ${agentInstructions} }; } + private buildTrustedSessionPrefix(sessionId: string): SessionMessage[] { + const promptToolOptions = this.getPromptToolOptions(); + const messages = [ + this.buildSystemMessage(sessionId, getSystemPrompt(this.projectRoot, promptToolOptions)), + this.buildSystemMessage( + sessionId, + getRuntimeContext( + this.projectRoot, + promptToolOptions.model, + this.getResolvedSettings().permissions?.addWorkingDirs + ) + ), + ]; + const agentInstructions = this.loadAgentInstructions(); + if (agentInstructions) { + messages.push(this.buildSystemMessage(sessionId, agentInstructions)); + } + return messages; + } + private buildFollowUpMessage(sessionId: string, message: ToolExecutionFollowUpMessage): SessionMessage { const now = new Date().toISOString(); return { @@ -3167,7 +3225,7 @@ ${agentInstructions} const hooks: ToolExecutionHooks = { onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), - onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk), + onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk, sessionId), onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control), onBackgroundProcessComplete: (completion) => this.addBackgroundProcessCompletionMessage(sessionId, completion), onBeforeFileMutation: (filePath) => this.prepareFileMutationCheckpoint(sessionId, filePath), @@ -3654,6 +3712,7 @@ ${agentInstructions} planMode: value.planMode === true, pluginRateLimitedTool: this.normalizePluginRateLimitedTool(value.pluginRateLimitedTool), forkedFrom: this.normalizeForkedFrom(value.forkedFrom), + derivedFrom: this.normalizeDerivedFrom(value.derivedFrom), }; } @@ -3666,12 +3725,7 @@ ${agentInstructions} return undefined; } const forkedFrom = value as Record; - if ( - typeof forkedFrom.sessionId !== "string" || - !forkedFrom.sessionId || - typeof forkedFrom.messageId !== "string" || - !forkedFrom.messageId - ) { + if (!isNonEmptyString(forkedFrom.sessionId) || !isNonEmptyString(forkedFrom.messageId)) { return undefined; } return { @@ -3680,6 +3734,24 @@ ${agentInstructions} }; } + private normalizeDerivedFrom(value: unknown): SessionEntry["derivedFrom"] { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const derivedFrom = value as Record; + if (derivedFrom.kind !== "plan-implementation") { + return undefined; + } + const lineage = this.normalizeForkedFrom(value); + if (!lineage) { + return undefined; + } + return { + kind: "plan-implementation", + ...lineage, + }; + } + private normalizeSessionStatus(status: unknown): SessionStatus { if ( status === "failed" || diff --git a/packages/core/src/tests/session.test.ts b/packages/core/src/tests/session.test.ts index bf50e07e..fac5796c 100644 --- a/packages/core/src/tests/session.test.ts +++ b/packages/core/src/tests/session.test.ts @@ -9,7 +9,13 @@ import sharp from "sharp"; import { GitFileHistory } from "../common/file-history"; import { clearSessionState } from "../common/state"; import { getSystemPrompt } from "../prompt"; -import { getProjectCode, SessionManager, type SessionMessage } from "../session"; +import { + buildPlanImplementationHandoff, + extractProposedPlan, + getProjectCode, + SessionManager, + type SessionMessage, +} from "../session"; import type { MultimodalMode } from "../common/model-capabilities"; const originalFetch = globalThis.fetch; @@ -3891,10 +3897,10 @@ test("SessionManager persists session and user message before skill matching is await manager.handleUserPrompt({ text: "please use demo" }); - // Session and user message are persisted before skill matching triggers an abort. + // The new session is active and persisted before skill matching triggers an abort. assert.equal(manager.listSessions().length, 1); const [session] = manager.listSessions(); - assert.equal(session?.status, "pending"); + assert.equal(session?.status, "interrupted"); const messages = manager.listSessionMessages(session!.id); const userMessage = messages.find((m) => m.role === "user"); assert.equal(userMessage?.content, "please use demo"); @@ -4082,6 +4088,24 @@ test("SessionManager.deleteSession removes the messages file", () => { assert.equal(fs.existsSync(messagePath), false); }); +test("SessionManager.deleteSession removes the file history reference", () => { + if (!hasGit()) { + return; + } + + const workspace = createTempDir("deepcode-delete-history-workspace-"); + const home = createTempDir("deepcode-delete-history-home-"); + setHomeDir(home); + const manager = createSessionManager(workspace, "machine-id-delete-history"); + const sessionId = createSessionAndMessages(manager, "session-delete-history", "Test session"); + const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace)); + assert.ok(fileHistory.ensureSession(sessionId)); + + manager.deleteSession(sessionId); + + assert.equal(fileHistory.getCurrentCheckpointHash(sessionId), undefined); +}); + test("sessions persist pasted images as file URLs without changing user content", async () => { const workspace = createTempDir("deepcode-session-image-workspace-"); const home = createTempDir("deepcode-session-image-home-"); @@ -4618,7 +4642,17 @@ test("SessionManager.forkSession copies conversation state with fresh usage and assert.equal(fileHistory.getCurrentCheckpointHash(sourceSessionId), sourceCheckpoint); }); -test("SessionManager.startPlanImplementationSession derives a clean context with the approved plan", () => { +test("plan handoff helpers preserve the latest complete proposed plan", () => { + assert.equal( + extractProposedPlan("First\n\nSecond plan\n"), + "Second plan" + ); + assert.equal(extractProposedPlan("Incomplete"), null); + assert.equal(extractProposedPlan("\n"), null); + assert.match(buildPlanImplementationHandoff("Build it"), /\nBuild it\n<\/proposed_plan>$/); +}); + +test("SessionManager.startPlanImplementationSession derives a trusted clean context with exact provenance", async () => { if (!hasGit()) { return; } @@ -4654,7 +4688,7 @@ test("SessionManager.startPlanImplementationSession derives a clean context with id: "source-head-message", sessionId: sourceSessionId, role: "assistant", - content: "\nOld plan\n", + content: "\nBuild a thing\nwith two steps.\n", contentParams: null, messageParams: null, compacted: false, @@ -4662,6 +4696,18 @@ test("SessionManager.startPlanImplementationSession derives a clean context with createTime: now, updateTime: now, }, + { + id: "trailing-tool-message", + sessionId: sourceSessionId, + role: "system", + content: "Later tool output", + contentParams: null, + messageParams: null, + compacted: false, + visible: false, + createTime: now, + updateTime: now, + }, ]; (manager as any).saveSessionMessages(sourceSessionId, sourceMessages); @@ -4672,7 +4718,7 @@ test("SessionManager.startPlanImplementationSession derives a clean context with assert.ok(sourceCheckpoint); const planText = "Build a thing\nwith two steps."; - const sessionId = manager.startPlanImplementationSession(sourceSessionId, planText); + const { sessionId, implementationPrompt } = await manager.startPlanImplementationSession(sourceSessionId, planText); const derived = manager.getSession(sessionId); assert.ok(derived); assert.equal(derived.summary, "Plan source"); @@ -4681,28 +4727,33 @@ test("SessionManager.startPlanImplementationSession derives a clean context with assert.equal(derived.usagePerModel, null); assert.equal(derived.activeTokens, 0); assert.equal(derived.status, "completed"); - assert.deepEqual(derived.forkedFrom, { + assert.equal(derived.forkedFrom, undefined); + assert.deepEqual(derived.derivedFrom, { + kind: "plan-implementation", sessionId: sourceSessionId, messageId: "source-head-message", }); const messages = manager.listSessionMessages(sessionId); - assert.equal(messages.length, 3); + assert.equal(messages.length, 2); assert.ok(messages.every((message) => message.role === "system")); assert.ok(!messages.some((message) => message.id === "source-user-message" || message.id === "source-head-message")); - const planMessage = messages.at(-1)!; - assert.equal(planMessage.visible, false); - assert.equal(planMessage.meta?.isPlan, true); assert.equal( - planMessage.content, + implementationPrompt, `A previous agent produced the plan below to accomplish the user's task. Implement the plan in a fresh context. Treat the plan as the source of user intent, re-read files as needed, and carry the work through implementation and verification.\n\n\n${planText}\n` ); + assert.equal(messages.filter((message) => message.content?.includes(planText)).length, 0); assert.equal(fileHistory.getCurrentCheckpointHash(sessionId), sourceCheckpoint); assert.deepEqual(manager.listSessionMessages(sourceSessionId), sourceMessages); + + const repeated = await manager.startPlanImplementationSession(sourceSessionId, planText); + assert.notEqual(repeated.sessionId, sessionId); + assert.equal(manager.getSession(sourceSessionId)?.summary, "Plan source"); + assert.deepEqual(manager.getSession(repeated.sessionId)?.derivedFrom, derived.derivedFrom); }); -test("SessionManager.startPlanImplementationSession rejects a source session that is not in Plan Mode", () => { +test("SessionManager.startPlanImplementationSession rejects a source session that is not in Plan Mode", async () => { if (!hasGit()) { return; } @@ -4713,10 +4764,13 @@ test("SessionManager.startPlanImplementationSession rejects a source session tha const manager = createSessionManager(workspace, "machine-id-plan-impl-nonplan"); const sourceSessionId = createSessionAndMessages(manager, "source-session", "Not a plan session"); - assert.throws(() => manager.startPlanImplementationSession(sourceSessionId, "Build a thing."), /is not in Plan Mode/); + await assert.rejects( + manager.startPlanImplementationSession(sourceSessionId, "Build a thing."), + /is not in Plan Mode/ + ); }); -test("SessionManager.startPlanImplementationSession rejects an empty plan text", () => { +test("SessionManager.startPlanImplementationSession rejects an empty plan text", async () => { if (!hasGit()) { return; } @@ -4733,7 +4787,212 @@ test("SessionManager.startPlanImplementationSession rejects an empty plan text", }; (manager as any).saveSessionsIndex(index); - assert.throws(() => manager.startPlanImplementationSession(sourceSessionId, " \n"), /must not be empty/); + await assert.rejects(manager.startPlanImplementationSession(sourceSessionId, " \n"), /must not be empty/); +}); + +test("SessionManager.startPlanImplementationSession rejects incomplete sources and stale plans", async () => { + const workspace = createTempDir("deepcode-plan-impl-validation-workspace-"); + const home = createTempDir("deepcode-plan-impl-validation-home-"); + setHomeDir(home); + const manager = createSessionManager(workspace, "machine-id-plan-impl-validation"); + await assert.rejects(manager.startPlanImplementationSession("missing-session", "Plan"), /No saved session/); + const sourceSessionId = createSessionAndMessages(manager, "source-session", "Plan source"); + const index = (manager as any).loadSessionsIndex(); + index.entries[0] = { ...index.entries[0], planMode: true, status: "processing" }; + (manager as any).saveSessionsIndex(index); + + await assert.rejects(manager.startPlanImplementationSession(sourceSessionId, "Plan"), /is not completed/); + index.entries[0].status = "completed"; + (manager as any).saveSessionsIndex(index); + await assert.rejects(manager.startPlanImplementationSession(sourceSessionId, "Plan"), /no complete proposed plan/); + + const messages = manager.listSessionMessages(sourceSessionId); + messages.push({ + ...messages.at(-1)!, + id: "approved-plan-message", + role: "assistant", + content: "Current plan", + }); + (manager as any).saveSessionMessages(sourceSessionId, messages); + await assert.rejects(manager.startPlanImplementationSession(sourceSessionId, "Stale plan"), /no longer matches/); +}); + +test("SessionManager.startPlanImplementationSession rejects proposed plans without a valid message ID", async () => { + const workspace = createTempDir("deepcode-plan-impl-message-id-workspace-"); + const home = createTempDir("deepcode-plan-impl-message-id-home-"); + setHomeDir(home); + const manager = createSessionManager(workspace, "machine-id-plan-impl-message-id"); + const sourceSessionId = createSessionAndMessages(manager, "source-session", "Plan source"); + const index = (manager as any).loadSessionsIndex(); + index.entries[0] = { ...index.entries[0], planMode: true, status: "completed" }; + (manager as any).saveSessionsIndex(index); + const baseMessage = manager.listSessionMessages(sourceSessionId).at(-1)!; + + for (const id of [undefined, null, "", " ", 42]) { + (manager as any).saveSessionMessages(sourceSessionId, [ + { + ...baseMessage, + id, + role: "assistant", + content: "Current plan", + }, + ]); + await assert.rejects( + manager.startPlanImplementationSession(sourceSessionId, "Current plan"), + /without a valid message ID/ + ); + } +}); + +test("SessionManager.startPlanImplementationSession re-resolves a compact skill catalog", async () => { + const workspace = createTempDir("deepcode-plan-impl-skills-workspace-"); + const home = createTempDir("deepcode-plan-impl-skills-home-"); + setHomeDir(home); + const skillDir = path.join(workspace, ".agents", "skills", "deploy-skill"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + "---\nname: renamed-deploy-skill\ndescription: Current deployment guidance\n---\n# Secret full instructions\n", + "utf8" + ); + const manager = createSessionManager(workspace, "machine-id-plan-impl-skills"); + const sourceSessionId = createSessionAndMessages(manager, "source-session", "Plan source"); + const index = (manager as any).loadSessionsIndex(); + index.entries[0] = { ...index.entries[0], planMode: true, status: "completed" }; + (manager as any).saveSessionsIndex(index); + const now = "2026-01-01T00:00:00.000Z"; + const baseMessage = { + sessionId: sourceSessionId, + contentParams: null, + messageParams: null, + compacted: false, + visible: false, + createTime: now, + updateTime: now, + }; + const sourceMessages: SessionMessage[] = [ + { + ...baseMessage, + id: "old-catalog", + role: "system", + content: "Old catalog", + meta: { + skillCatalog: [ + { name: "deploy-skill", description: "Stale description" }, + { name: "removed-skill", description: "No longer installed" }, + ], + }, + }, + { + ...baseMessage, + id: "loaded-skill", + role: "tool", + content: "FULL SKILL BODY MUST NOT COPY", + meta: { + skill: { + name: "deploy-skill", + path: "./.agents/skills/deploy-skill/SKILL.md", + description: "Stale description", + isLoaded: true, + }, + }, + }, + { + ...baseMessage, + id: "malformed-catalog", + role: "system", + content: "Malformed catalog", + meta: { skillCatalog: { name: "not-an-array" } as any }, + }, + { + ...baseMessage, + id: "approved-plan", + role: "assistant", + content: "Deploy with deploy-skill", + visible: true, + }, + ]; + (manager as any).saveSessionMessages(sourceSessionId, sourceMessages); + + const result = await manager.startPlanImplementationSession(sourceSessionId, "Deploy with deploy-skill"); + const derivedMessages = manager.listSessionMessages(result.sessionId); + const catalog = derivedMessages.find((message) => message.meta?.skillCatalog)?.meta?.skillCatalog; + assert.deepEqual(catalog, [{ name: "renamed-deploy-skill", description: "Current deployment guidance" }]); + assert.doesNotMatch(derivedMessages.map((message) => message.content).join("\n"), /FULL SKILL BODY|Secret full/); + + let matchedPrompt = ""; + manager.identifyMatchingSkillNames = async (_skills, prompt) => { + matchedPrompt = prompt; + return ["renamed-deploy-skill"]; + }; + await manager.replySession(result.sessionId, { text: result.implementationPrompt, planMode: false }); + assert.equal(matchedPrompt, result.implementationPrompt); + const submittedMessages = manager.listSessionMessages(result.sessionId); + assert.equal( + submittedMessages.filter( + (message) => message.role === "user" && message.content === result.implementationPrompt && message.visible + ).length, + 1 + ); +}); + +test("SessionManager.startPlanImplementationSession removes partial state when creation fails", async () => { + if (!hasGit()) { + return; + } + + const workspace = createTempDir("deepcode-plan-impl-cleanup-workspace-"); + const home = createTempDir("deepcode-plan-impl-cleanup-home-"); + setHomeDir(home); + const manager = createSessionManager(workspace, "machine-id-plan-impl-cleanup"); + const sourceSessionId = createSessionAndMessages(manager, "source-session", "Plan source"); + const index = (manager as any).loadSessionsIndex(); + index.entries[0] = { ...index.entries[0], planMode: true, status: "completed" }; + (manager as any).saveSessionsIndex(index); + const sourceMessages = manager.listSessionMessages(sourceSessionId); + (manager as any).saveSessionMessages(sourceSessionId, [ + ...sourceMessages, + { + ...sourceMessages.at(-1)!, + id: "approved-plan", + role: "assistant", + content: "Current plan", + }, + ]); + const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace)); + fileHistory.ensureSession(sourceSessionId); + let derivedSessionId = ""; + (manager as any).registerSessionEntry = (entry: { id: string }) => { + derivedSessionId = entry.id; + throw new Error("index write failed"); + }; + + await assert.rejects(manager.startPlanImplementationSession(sourceSessionId, "Current plan"), /index write failed/); + + const projectDir = path.join(home, ".deepcode", "projects", getProjectCode(workspace)); + assert.ok(derivedSessionId); + assert.equal(manager.getSession(derivedSessionId), null); + assert.equal(fs.existsSync(path.join(projectDir, `${derivedSessionId}.jsonl`)), false); + assert.equal(fileHistory.getCurrentCheckpointHash(derivedSessionId), undefined); + assert.ok(fileHistory.getCurrentCheckpointHash(sourceSessionId)); +}); + +test("SessionManager.createSession binds the active session before asynchronous skill matching", async () => { + const workspace = createTempDir("deepcode-create-session-active-workspace-"); + const home = createTempDir("deepcode-create-session-active-home-"); + setHomeDir(home); + const manager = createSessionManager(workspace, "machine-id-create-session-active"); + (manager as any).activateSession = async () => {}; + let matchingSessionId: string | undefined; + manager.identifyMatchingSkillNames = async (_skills, _prompt, options) => { + matchingSessionId = options?.sessionId; + assert.equal(manager.getActiveSessionId(), matchingSessionId); + return []; + }; + + const sessionId = await manager.createSession({ text: "Use a matching skill" }); + + assert.equal(matchingSessionId, sessionId); }); test("SessionManager ignores malformed fork lineage in persisted entries", () => { @@ -4751,6 +5010,21 @@ test("SessionManager ignores malformed fork lineage in persisted entries", () => assert.equal(manager.getSession(sessionId)?.forkedFrom, undefined); }); +test("SessionManager tolerates malformed plan derivation metadata in persisted entries", () => { + const workspace = createTempDir("deepcode-plan-lineage-workspace-"); + const home = createTempDir("deepcode-plan-lineage-home-"); + setHomeDir(home); + const manager = createSessionManager(workspace, "machine-id-plan-lineage"); + const sessionId = createSessionAndMessages(manager, "lineage-session", "Lineage"); + const projectDir = (manager as any).getProjectStorage().projectDir; + const indexPath = path.join(projectDir, "sessions-index.json"); + const persisted = JSON.parse(fs.readFileSync(indexPath, "utf8")); + persisted.entries[0].derivedFrom = { kind: "other", sessionId }; + fs.writeFileSync(indexPath, JSON.stringify(persisted), "utf8"); + + assert.equal(manager.getSession(sessionId)?.derivedFrom, undefined); +}); + test("SessionManager persists plugin rate limits with UnderstandImage priority and does not copy them to forks", () => { const workspace = createTempDir("deepcode-plugin-rate-limit-workspace-"); const home = createTempDir("deepcode-plugin-rate-limit-home-"); From 3b0c246177c273720a24c7b9a6d53e1f71bc5b2d Mon Sep 17 00:00:00 2001 From: Yi-Fan Wang Date: Wed, 2 Sep 2026 10:39:48 +0200 Subject: [PATCH 8/9] =?UTF-8?q?fix(cli):=20=E9=9A=94=E7=A6=BB=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E6=B4=BE=E7=94=9F=E4=BC=9A=E8=AF=9D=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 可恢复的规划会话在后台继续运行时,其消息、状态、重试、权限和进程输出可能污染当前实现会话。选项 4 还可能被重复触发,固定长度标题则会把来源徽章拆到下一行。 现在为所有相关事件保留来源会话 ID,严格筛选活动会话更新,并统一会话视图切换与失败恢复。派生操作在首次选择后进入单次执行状态,标题、来源徽章和状态也在各自布局区域渲染。 未停止源会话的后台进程,因为源会话必须保持可恢复,并持久化后台任务的最终结果。 验证:git diff --check、npm run check 和 npm test 均通过。 Co-authored-by: Codex --- packages/cli/src/tests/exec-runner.test.ts | 2 +- packages/cli/src/tests/session-list.test.ts | 73 +++++++++- packages/cli/src/ui/core/session-events.ts | 11 ++ packages/cli/src/ui/views/App.tsx | 150 ++++++++++---------- packages/cli/src/ui/views/SessionList.tsx | 50 +++++-- 5 files changed, 199 insertions(+), 87 deletions(-) create mode 100644 packages/cli/src/ui/core/session-events.ts diff --git a/packages/cli/src/tests/exec-runner.test.ts b/packages/cli/src/tests/exec-runner.test.ts index 5b86b575..e43d83ae 100644 --- a/packages/cli/src/tests/exec-runner.test.ts +++ b/packages/cli/src/tests/exec-runner.test.ts @@ -147,7 +147,7 @@ function createHarness(scenario: ManagerScenario = {}) { }, true ); - options.onProcessStdout?.(123, "process output\n"); + options.onProcessStdout?.(123, "process output\n", activeId); scenario.duringPrompt?.(); entry = createEntry(activeId, scenario.finalStatus ?? "completed", { assistantReply: scenario.finalReply === undefined ? "final answer" : scenario.finalReply, diff --git a/packages/cli/src/tests/session-list.test.ts b/packages/cli/src/tests/session-list.test.ts index 654b4152..afda9d23 100644 --- a/packages/cli/src/tests/session-list.test.ts +++ b/packages/cli/src/tests/session-list.test.ts @@ -1,6 +1,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { formatSessionTitle, filterSessions, formatSessionStatus } from "../ui"; +import React from "react"; +import { renderToString } from "ink"; +import { formatSessionTitle, filterSessions, formatSessionStatus, getSessionBadges } from "../ui/views/SessionList"; +import { SessionList } from "../ui/views/SessionList"; +import { claimPlanImplementation, isActiveSessionEvent } from "../ui/core/session-events"; import type { SessionEntry } from "@vegamo/deepcode-core"; test("formatSessionTitle replaces newlines with spaces", () => { @@ -11,6 +15,69 @@ test("formatSessionTitle truncates after normalizing whitespace", () => { assert.equal(formatSessionTitle("one\n two three", 10), "one two th…"); }); +test("plan derivation badges remain separate from long truncated titles", () => { + const [source, implementation, fork] = buildSessions([ + { id: "source", summary: "A very long plan title that must be independently truncated" }, + { + id: "implementation", + summary: "A very long plan title that must be independently truncated", + derivedFrom: { kind: "plan-implementation", sessionId: "source", messageId: "plan-message" }, + }, + { id: "fork", forkedFrom: { sessionId: "source", messageId: "plan-message" } }, + ]); + + assert.equal(formatSessionTitle(source!.summary!, 20), "A very long plan tit…"); + assert.deepEqual(getSessionBadges(source!, [source!, implementation!, fork!]), ["planned"]); + assert.deepEqual(getSessionBadges(implementation!, [source!, implementation!, fork!]), ["implementation"]); + assert.deepEqual(getSessionBadges(fork!, [source!, implementation!, fork!]), []); +}); + +test("session callbacks ignore events from inactive sessions", () => { + assert.equal(isActiveSessionEvent("implementation", "source"), false); + assert.equal(isActiveSessionEvent("implementation", "implementation"), true); + assert.equal(isActiveSessionEvent("implementation", undefined), false); + assert.equal(isActiveSessionEvent(null, "source"), false); + assert.equal(isActiveSessionEvent(null, undefined), false); +}); + +test("plan implementation can only be claimed once while preparation is in flight", () => { + const inFlight = { current: false }; + + assert.equal(claimPlanImplementation(inFlight), true); + assert.equal(claimPlanImplementation(inFlight), false); +}); + +test("long session titles keep lineage badges and status on the first line at 80 columns", () => { + const sessions = buildSessions([ + { id: "source", summary: "A very long plan title ".repeat(8) }, + { + id: "implementation", + summary: "A very long implementation title ".repeat(8), + derivedFrom: { kind: "plan-implementation", sessionId: "source", messageId: "plan-message" }, + }, + ]); + const output = renderToString( + React.createElement(SessionList, { + sessions, + onSelect: () => {}, + onCancel: () => {}, + }), + { columns: 80 } + ); + const lines = output.split("\n"); + const titleLine = lines.find((line) => line.includes("[planned]")); + const implementationLine = lines.find((line) => line.includes("[implementation]")); + + assert.match(titleLine ?? "", /… +\[planned\] \(done\) │$/); + assert.match(implementationLine ?? "", /… +\[implementation\] \(done\) │$/); + const plannedTimeLine = lines[lines.indexOf(titleLine ?? "") + 1] ?? ""; + const implementationTimeLine = lines[lines.indexOf(implementationLine ?? "") + 1] ?? ""; + assert.match(plannedTimeLine, /2026/); + assert.doesNotMatch(plannedTimeLine, /\[planned\]|\(done\)/); + assert.match(implementationTimeLine, /2026/); + assert.doesNotMatch(implementationTimeLine, /\[implementation\]|\(done\)/); +}); + test("formatSessionStatus maps status values to display labels", () => { assert.equal(formatSessionStatus("completed"), "done"); assert.equal(formatSessionStatus("processing"), "running"); @@ -101,7 +168,7 @@ test("filterSessions handles sessions with null fields", () => { function buildSessions(overrides: Array>): SessionEntry[] { return overrides.map((override, i) => ({ - id: `session-${i}`, + id: override.id ?? `session-${i}`, summary: override.summary ?? null, assistantReply: override.assistantReply ?? null, assistantThinking: null, @@ -115,5 +182,7 @@ function buildSessions(overrides: Array>): SessionEntry[] createTime: new Date().toISOString(), updateTime: new Date().toISOString(), processes: null, + forkedFrom: override.forkedFrom, + derivedFrom: override.derivedFrom, })); } diff --git a/packages/cli/src/ui/core/session-events.ts b/packages/cli/src/ui/core/session-events.ts new file mode 100644 index 00000000..a15619c3 --- /dev/null +++ b/packages/cli/src/ui/core/session-events.ts @@ -0,0 +1,11 @@ +export function isActiveSessionEvent(activeSessionId: string | null, eventSessionId?: string): boolean { + return activeSessionId !== null && eventSessionId === activeSessionId; +} + +export function claimPlanImplementation(inFlight: { current: boolean }): boolean { + if (inFlight.current) { + return false; + } + inFlight.current = true; + return true; +} diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index f5511a01..cf3a9294 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -10,6 +10,7 @@ import { SessionList } from "./SessionList"; import { type UndoRestoreMode, UndoSelector } from "./UndoSelector"; import { buildLoadingText } from "../core/loading-text"; import { findExpandedThinkingId } from "../core/thinking-state"; +import { claimPlanImplementation, isActiveSessionEvent } from "../core/session-events"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; import { McpStatusList } from "./McpStatusList"; @@ -111,6 +112,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes const resumeSessionIdRef = useRef(false); const startupDoneRef = useRef(false); const processStdoutRef = useRef>(new Map()); + const planImplementationInFlightRef = useRef(false); const rawModeRef = useRef(mode); const writeRef = useRef(write); const lastRenderedColumnsRef = useRef(null); @@ -155,6 +157,9 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes getResolvedSettings: () => resolveCurrentSettings(projectRoot), renderMarkdown: (text) => text, onAssistantMessage: (message: SessionMessage) => { + if (!isActiveSessionEvent(sessionManager.getActiveSessionId(), message.sessionId)) { + return; + } setMessages((prev) => [...prev, message]); if (rawModeRef.current === RawMode.Raw) { writeStdoutLine("\n"); @@ -162,12 +167,19 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes } }, onSessionEntryUpdated: (entry) => { + setSessions(sessionManager.listSessions()); + if (!isActiveSessionEvent(sessionManager.getActiveSessionId(), entry.id)) { + return; + } setStatusLine(buildStatusLine(entry, resolveCurrentSettings(projectRoot))); setRunningProcesses(entry.processes); setActiveStatus(entry.status); setActiveAskPermissions(entry.askPermissions); }, onLlmStreamProgress: (progress) => { + if (!isActiveSessionEvent(sessionManager.getActiveSessionId(), progress.sessionId)) { + return; + } setRetryEvent(null); if (progress.phase === "end") { setStreamProgress(null); @@ -176,13 +188,19 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes setStreamProgress(progress); }, onLlmRetry: (event) => { + if (!isActiveSessionEvent(sessionManager.getActiveSessionId(), event.sessionId)) { + return; + } setRetryEvent(event); }, onMcpStatusChanged: () => { // 当 MCP 状态变更时,如果当前正在查看 MCP 状态页面,则更新显示 setMcpStatuses(sessionManager.getMcpStatus()); }, - onProcessStdout: (pid, chunk) => { + onProcessStdout: (pid, chunk, sessionId) => { + if (!isActiveSessionEvent(sessionManager.getActiveSessionId(), sessionId)) { + return; + } const buf = processStdoutRef.current; const current = buf.get(pid) ?? ""; // Cap at 1 MB per process to avoid unbounded memory growth @@ -342,6 +360,25 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes [exit, sessionManager] ); + const activateSessionView = useCallback( + async (sessionId: string): Promise => { + sessionManager.setActiveSessionId(sessionId); + processStdoutRef.current.clear(); + await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); + const session = sessionManager.getSession(sessionId); + setStatusLine(session ? buildStatusLine(session, resolveCurrentSettings(projectRoot)) : ""); + setRunningProcesses(session?.processes ?? null); + setActiveStatus(session?.status ?? null); + setActiveAskPermissions(session?.askPermissions); + setPlanMode(session?.planMode === true); + setPendingPlanImplementation(null); + if (pendingPermissionReply && pendingPermissionReply.sessionId !== sessionId) { + setPendingPermissionReply(null); + } + }, + [pendingPermissionReply, projectRoot, resetStaticView, sessionManager] + ); + const handlePrompt = useCallback( async (submission: PromptSubmission) => { if (submission.command === "exit") { @@ -370,16 +407,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes } try { const sessionId = sessionManager.forkSession(sourceSessionId); - sessionManager.setActiveSessionId(sessionId); - await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); - const session = sessionManager.getSession(sessionId); - setStatusLine(session ? buildStatusLine(session, resolveCurrentSettings(projectRoot)) : ""); - setRunningProcesses(null); - setActiveStatus(session?.status ?? null); - setActiveAskPermissions(undefined); - setPlanMode(session?.planMode === true); - setPendingPlanImplementation(null); - setPendingPermissionReply(null); + await activateSessionView(sessionId); setErrorLine(null); refreshSessionsList(); await refreshSkills(sessionId); @@ -489,9 +517,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes refreshSessionsList, navigateToSubView, resetToWelcome, - resetStaticView, planMode, - projectRoot, + activateSessionView, ] ); @@ -578,54 +605,48 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes setErrorLine("No active session to derive from."); return; } + if (!claimPlanImplementation(planImplementationInFlightRef)) { + return; + } + setPendingPlanImplementation(null); + setBusy(true); + setErrorLine(null); let derivedSessionId: string | null = null; - let submissionStarted = false; + let implementationPrompt = ""; try { - const { sessionId, implementationPrompt } = await sessionManager.startPlanImplementationSession( - sourceSessionId, - proposedPlan - ); - derivedSessionId = sessionId; - sessionManager.setActiveSessionId(sessionId); - processStdoutRef.current.clear(); - await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); - const session = sessionManager.getSession(sessionId); - setStatusLine(session ? buildStatusLine(session, resolveCurrentSettings(projectRoot)) : ""); - setRunningProcesses(null); - setActiveStatus(session?.status ?? null); - setActiveAskPermissions(undefined); - setPlanMode(false); - setPendingPermissionReply(null); - setErrorLine(null); + const result = await sessionManager.startPlanImplementationSession(sourceSessionId, proposedPlan); + derivedSessionId = result.sessionId; + implementationPrompt = result.implementationPrompt; + await activateSessionView(result.sessionId); refreshSessionsList(); - await refreshSkills(sessionId); - setPendingPlanImplementation(null); - submissionStarted = true; - await handlePrompt({ - text: implementationPrompt, - imageUrls: [], - planMode: false, - }); } catch (error) { - if (submissionStarted) { - setErrorLine(error instanceof Error ? error.message : String(error)); - return; - } if (derivedSessionId) { sessionManager.deleteSession(derivedSessionId); } - sessionManager.setActiveSessionId(sourceSessionId); - processStdoutRef.current.clear(); - await resetStaticView(loadVisibleMessages(sessionManager, sourceSessionId), { clearScreen: true }); - const source = sessionManager.getSession(sourceSessionId); - setStatusLine(source ? buildStatusLine(source, resolveCurrentSettings(projectRoot)) : ""); - setRunningProcesses(source?.processes ?? null); - setActiveStatus(source?.status ?? null); - setActiveAskPermissions(source?.askPermissions); - setPlanMode(true); + try { + await activateSessionView(sourceSessionId); + } catch { + sessionManager.setActiveSessionId(sourceSessionId); + } setErrorLine(error instanceof Error ? error.message : String(error)); setPendingPlanImplementation(proposedPlan); refreshSessionsList(); + setBusy(false); + planImplementationInFlightRef.current = false; + return; + } + + try { + await handlePrompt({ + text: implementationPrompt, + imageUrls: [], + planMode: false, + }); + } catch (error) { + setErrorLine(error instanceof Error ? error.message : String(error)); + setBusy(false); + } finally { + planImplementationInFlightRef.current = false; } return; } @@ -639,16 +660,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes }); } }, - [ - handleSubmit, - handlePrompt, - pendingPlanImplementation, - sessionManager, - resetStaticView, - refreshSessionsList, - refreshSkills, - projectRoot, - ] + [handleSubmit, handlePrompt, pendingPlanImplementation, sessionManager, activateSessionView, refreshSessionsList] ); const handleExitShortcut = useCallback(() => { @@ -664,22 +676,10 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes const handleSelectSession = useCallback( async (sessionId: string) => { - sessionManager.setActiveSessionId(sessionId); - // Clear first so resets its index to 0. - await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); - const session = sessionManager.getSession(sessionId); - setStatusLine(session ? buildStatusLine(session, resolveCurrentSettings(projectRoot)) : ""); - setRunningProcesses(session?.processes ?? null); - setActiveStatus(session?.status ?? null); - setActiveAskPermissions(session?.askPermissions); - setPlanMode(session?.planMode === true); - setPendingPlanImplementation(null); - if (pendingPermissionReply && pendingPermissionReply.sessionId !== sessionId) { - setPendingPermissionReply(null); - } + await activateSessionView(sessionId); await refreshSkills(sessionId); }, - [sessionManager, resetStaticView, pendingPermissionReply, projectRoot, refreshSkills] + [activateSessionView, refreshSkills] ); /** diff --git a/packages/cli/src/ui/views/SessionList.tsx b/packages/cli/src/ui/views/SessionList.tsx index a41cae3a..e4e73363 100644 --- a/packages/cli/src/ui/views/SessionList.tsx +++ b/packages/cli/src/ui/views/SessionList.tsx @@ -312,13 +312,14 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete, onRename } const isSelected = actualIndex === safeIndex; const isConfirming = confirmDeleteSessionId === session.id; const isRenaming = renameSessionId === session.id; + const badges = getSessionBadges(session, sessions); return ( {isSelected ? "> " : " "} - + {isRenaming ? ( Rename: {renameValue.slice(0, renameCursor)} @@ -326,15 +327,30 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete, onRename } {renameValue.slice(renameCursor)} ) : ( - - {formatSessionTitle(session.summary || "Untitled")} - - )} - {isConfirming ? ( - [Delete? Enter=yes, Esc=no] - ) : isRenaming ? null : ( - ({formatSessionStatus(session.status)}) + + + {formatSessionTitle(session.summary || "Untitled")} + + )} + {!isRenaming ? ( + + {badges.map((badge) => ( + + {` [${badge}]`} + + ))} + {isConfirming ? ( + [Delete? Enter=yes, Esc=no] + ) : ( + ({formatSessionStatus(session.status)}) + )} + + ) : null} {formatTimestamp(session.updateTime)} @@ -413,6 +429,22 @@ export function formatSessionTitle(value: string, max = 70): string { return truncate(value.replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim(), max); } +export function getSessionBadges(session: SessionEntry, sessions: SessionEntry[]): Array<"planned" | "implementation"> { + const badges: Array<"planned" | "implementation"> = []; + if ( + sessions.some( + (candidate) => + candidate.derivedFrom?.kind === "plan-implementation" && candidate.derivedFrom.sessionId === session.id + ) + ) { + badges.push("planned"); + } + if (session.derivedFrom?.kind === "plan-implementation") { + badges.push("implementation"); + } + return badges; +} + export function formatSessionStatus(status: SessionStatus): string { switch (status) { case "completed": From 70c98e8f6cbe857bfe9cc0ef92423d18a6f359b6 Mon Sep 17 00:00:00 2001 From: Yi-Fan Wang Date: Wed, 2 Sep 2026 10:39:49 +0200 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20=E7=B2=BE=E7=AE=80=E5=B9=B2?= =?UTF-8?q?=E5=87=80=E4=B8=8A=E4=B8=8B=E6=96=87=E7=9A=84=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原说明包含过多消息构造、skill 筛选和 checkpoint 回退细节,不利于用户快速理解选项 4。 中英文文档现在只保留用户可观察的行为:方案以用户消息交接,当前配置、AGENTS.md 和可用 skills 会重新加载,规划历史不会复制,工作区保持不变,源会话可恢复,并尽力继承文件历史。 未继续展开内部验证和消息顺序,因为这些细节由实现与测试保证,不影响用户选择。 验证:已对照实际流程核对中英文版本;git diff --check、npm run check 和 npm test 均通过。 Co-authored-by: Codex --- docs/plan-mode.md | 4 ++-- docs/plan-mode_en.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plan-mode.md b/docs/plan-mode.md index dfa636b6..dadc06b1 100644 --- a/docs/plan-mode.md +++ b/docs/plan-mode.md @@ -92,9 +92,9 @@ Plan Mode 的核心规则是**只规划,不动手**。例如以下操作是** | **1. implement this plan** | 退出 Plan Mode,自动发送实现指令,让 AI 开始按方案写代码 | | **2. stay in Plan mode** | 保持在 Plan Mode,继续修改或完善方案 | | **3. switch to Default mode** | 退出 Plan Mode,回到默认模式(不自动开始实现) | -| **4. clear context and implement this plan** | 派生一个干净的新会话(仅携带系统提示、运行上下文、AGENTS.md 指令与方案),在全新上下文中开始实现 | +| **4. clear context and implement this plan** | 以已批准方案启动干净的新会话 | -注意:该选项不会把规划阶段加载的 skills 带入新会话;实现时可按需通过 skill 工具重新加载。 +已批准方案会成为干净会话的第一条用户消息。新会话重新加载当前配置与 AGENTS.md,并通过精简目录重新发现可用 skills,不复制规划对话或完整 skill 内容。工作区不变,源会话可恢复,文件历史可用时继承;两个会话分别标记为 `planned` 和 `implementation`。 你可以用数字键 `1-4` 直接选择,也可以用 `↑/↓` 移动光标后按 `Enter` 确认。按 `Esc` 等同于选择 "stay in Plan mode"。 diff --git a/docs/plan-mode_en.md b/docs/plan-mode_en.md index 98740f11..6344c55f 100644 --- a/docs/plan-mode_en.md +++ b/docs/plan-mode_en.md @@ -92,9 +92,9 @@ After the plan is output, Deep Code automatically shows a choice dialog—no ext | **1. implement this plan** | Leave Plan Mode and automatically send an implementation prompt so the AI starts coding | | **2. stay in Plan mode** | Stay in Plan Mode to continue refining the plan | | **3. switch to Default mode** | Leave Plan Mode and return to Default mode without starting implementation | -| **4. clear context and implement this plan** | Derive a clean new session (carrying only the system prompt, runtime context, AGENTS.md instructions, and the plan) to implement in a fresh context | +| **4. clear context and implement this plan** | Start a fresh session with the approved plan | -Note: skills loaded during planning are not carried over; implementation can reload them via the skill tool as needed. +The approved plan becomes the fresh session’s first user message. It reloads current configuration and AGENTS.md and rediscovers available skills from a compact catalog, without copying planning dialogue or skill bodies. The workspace stays unchanged, the source remains resumable, file history is inherited when available, and the sessions are marked `planned` and `implementation`. You can press `1-4` to select directly, or use `↑/↓` to move the cursor and `Enter` to confirm. Pressing `Esc` is equivalent to choosing "stay in Plan mode."