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..dadc06b1 100644 --- a/docs/plan-mode.md +++ b/docs/plan-mode.md @@ -92,8 +92,11 @@ 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"。 +已批准方案会成为干净会话的第一条用户消息。新会话重新加载当前配置与 AGENTS.md,并通过精简目录重新发现可用 skills,不复制规划对话或完整 skill 内容。工作区不变,源会话可恢复,文件历史可用时继承;两个会话分别标记为 `planned` 和 `implementation`。 + +你可以用数字键 `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..6344c55f 100644 --- a/docs/plan-mode_en.md +++ b/docs/plan-mode_en.md @@ -92,8 +92,11 @@ 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** | Start a fresh session with the approved plan | -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." +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." ## Plan Mode vs. UpdatePlan Tool 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/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/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 4f107fd6..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"; @@ -20,7 +21,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"; @@ -106,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); @@ -150,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"); @@ -157,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); @@ -171,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 @@ -337,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") { @@ -365,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); @@ -484,9 +517,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes refreshSessionsList, navigateToSubView, resetToWelcome, - resetStaticView, planMode, - projectRoot, + activateSessionView, ] ); @@ -561,12 +593,64 @@ 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") { + setPendingPlanImplementation(null); + return; + } + if (choice === "clearContext" && proposedPlan) { + const sourceSessionId = sessionManager.getActiveSessionId(); + if (!sourceSessionId) { + setErrorLine("No active session to derive from."); + return; + } + if (!claimPlanImplementation(planImplementationInFlightRef)) { + return; + } + setPendingPlanImplementation(null); + setBusy(true); + setErrorLine(null); + let derivedSessionId: string | null = null; + let implementationPrompt = ""; + try { + const result = await sessionManager.startPlanImplementationSession(sourceSessionId, proposedPlan); + derivedSessionId = result.sessionId; + implementationPrompt = result.implementationPrompt; + await activateSessionView(result.sessionId); + refreshSessionsList(); + } catch (error) { + if (derivedSessionId) { + sessionManager.deleteSession(derivedSessionId); + } + 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; } + setPendingPlanImplementation(null); setPlanMode(false); if (choice === "implement" && proposedPlan) { handleSubmit({ @@ -576,7 +660,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes }); } }, - [handleSubmit, pendingPlanImplementation] + [handleSubmit, handlePrompt, pendingPlanImplementation, sessionManager, activateSessionView, refreshSessionsList] ); const handleExitShortcut = useCallback(() => { @@ -592,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/PlanImplementationPrompt.tsx b/packages/cli/src/ui/views/PlanImplementationPrompt.tsx index e97b361a..c717a21b 100644 --- a/packages/cli/src/ui/views/PlanImplementationPrompt.tsx +++ b/packages/cli/src/ui/views/PlanImplementationPrompt.tsx @@ -1,9 +1,10 @@ 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"; -type PlanImplementationChoice = "implement" | "stay" | "default"; +export type PlanImplementationChoice = "implement" | "stay" | "default" | "clearContext"; type Props = { onSelect: (choice: PlanImplementationChoice) => void; @@ -13,16 +14,10 @@ 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. */ -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; @@ -37,7 +32,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 +76,7 @@ export function PlanImplementationPrompt({ onSelect }: Props): React.ReactElemen ))} - 1-3 select · ↑/↓ move · Enter select + 1-4 select · ↑/↓ move · Enter select ); 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": 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 b2c82bc3..e289e94b 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -108,6 +108,27 @@ const PLAN_MODE_FORCE_ASK_SCOPES = [ "mutate-git-log", ] as const satisfies readonly PermissionScope[]; +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 + ? "先前的一位智能体产出了以下方案以完成用户任务。请在全新上下文中实现该方案,把方案视为用户意图的来源,按需重新读取文件,并持续推进到实现与验证。" + : "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; @@ -168,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; @@ -291,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 = { @@ -385,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; }; @@ -432,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; @@ -1465,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, @@ -1483,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); + this.registerSessionEntry(entry); - const runtimeContextMessage = this.buildSystemMessage( - sessionId, - getRuntimeContext( - this.projectRoot, - promptToolOptions.model, - this.getResolvedSettings().permissions?.addWorkingDirs - ) - ); - this.appendSessionMessage(sessionId, runtimeContextMessage); - - 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)); @@ -1531,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); @@ -1551,7 +1551,6 @@ ${agentInstructions} ) ); - this.activeSessionId = sessionId; await this.activateSession(sessionId, controller); return sessionId; } @@ -2251,6 +2250,40 @@ ${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), + }); + } + } + + 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) { @@ -2294,29 +2327,130 @@ ${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); + this.registerSessionEntry(entry); + + 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 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. + */ + async startPlanImplementationSession( + sourceSessionId: string, + expectedPlan: string + ): Promise { + const source = this.getSession(sourceSessionId); + 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.`); + } + 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); + 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; } - 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 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."); } - return sessionId; + 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(); + 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, + derivedFrom: { + kind: "plan-implementation", + sessionId: sourceSessionId, + messageId: sourceMessage.id, + }, + }; + + const messages = this.buildTrustedSessionPrefix(sessionId); + if (skillCatalog.length > 0) { + messages.push( + this.buildSystemMessage(sessionId, buildSkillCatalogPrompt(skillCatalog), null, false, { skillCatalog }) + ); + } + + 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, + implementationPrompt: buildPlanImplementationHandoff(planText), + }; } /** @@ -2637,6 +2771,7 @@ ${agentInstructions} controller.abort(); } this.sessionControllers.delete(sessionId); + this.getFileHistory().deleteSession(sessionId); if (options.removeMessages) { this.removeSessionMessages([sessionId]); try { @@ -2898,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 { @@ -3070,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), @@ -3557,6 +3712,7 @@ ${agentInstructions} planMode: value.planMode === true, pluginRateLimitedTool: this.normalizePluginRateLimitedTool(value.pluginRateLimitedTool), forkedFrom: this.normalizeForkedFrom(value.forkedFrom), + derivedFrom: this.normalizeDerivedFrom(value.derivedFrom), }; } @@ -3569,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 { @@ -3583,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 eac53655..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,6 +4642,359 @@ test("SessionManager.forkSession copies conversation state with fresh usage and assert.equal(fileHistory.getCurrentCheckpointHash(sourceSessionId), sourceCheckpoint); }); +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; + } + + 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: "\nBuild a thing\nwith two steps.\n", + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + 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); + + 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, implementationPrompt } = await 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.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, 2); + assert.ok(messages.every((message) => message.role === "system")); + assert.ok(!messages.some((message) => message.id === "source-user-message" || message.id === "source-head-message")); + assert.equal( + 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", async () => { + 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"); + + await assert.rejects( + manager.startPlanImplementationSession(sourceSessionId, "Build a thing."), + /is not in Plan Mode/ + ); +}); + +test("SessionManager.startPlanImplementationSession rejects an empty plan text", async () => { + 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); + + 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", () => { const workspace = createTempDir("deepcode-fork-lineage-workspace-"); const home = createTempDir("deepcode-fork-lineage-home-"); @@ -4633,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-");