From bbd02c9f09c72fb1f950e314112e4558913475c1 Mon Sep 17 00:00:00 2001 From: JiuXiang <904085642@qq.com> Date: Mon, 7 Sep 2026 23:27:48 +0800 Subject: [PATCH 1/2] feat(plan): enforce recoverable runtime plan state machine --- .github/workflows/ci.yml | 12 +- .gitignore | 3 +- README.md | 7 +- README.zh-CN.md | 7 +- apps/desktop/src/renderer/App.test.tsx | 203 ++- apps/desktop/src/renderer/App.tsx | 225 ++-- apps/desktop/src/renderer/styles.css | 22 + apps/desktop/src/shared/types.ts | 38 +- ...01-centralize-collaboration-mode-policy.md | 71 + docs/specs/plan-mode.md | 193 +++ packages/app/src/coding_agent/cli/args.py | 35 +- packages/app/src/coding_agent/cli/main.py | 53 +- .../src/coding_agent/core/agent_session.py | 541 +++++--- .../app/src/coding_agent/core/plan_mode.py | 1161 ++++++++++++----- .../app/src/coding_agent/desktop/runtime.py | 186 ++- .../modes/interactive/components/footer.py | 37 +- .../interactive/components/plan_actions.py | 84 +- .../modes/interactive/interactive_mode.py | 262 +++- packages/app/src/coding_agent/search/tool.py | 2 + .../app/tests/test_agent_session_memory.py | 23 +- .../app/tests/test_cli_runtime_options.py | 55 + packages/app/tests/test_desktop_protocol.py | 95 ++ packages/app/tests/test_footer.py | 40 +- packages/app/tests/test_plan_actions.py | 25 +- packages/app/tests/test_plan_lifecycle.py | 506 +++++++ packages/app/tests/test_plan_mode.py | 162 ++- packages/app/tests/test_plan_tui_state.py | 166 +++ packages/app/tests/test_web_search.py | 27 + packages/core/src/agent_core/__init__.py | 7 + packages/core/src/agent_core/agent.py | 1 + packages/core/src/agent_core/agent_loop.py | 55 +- .../src/agent_core/session/session_manager.py | 103 +- .../core/src/agent_core/session/storage.py | 120 +- packages/core/src/agent_core/session/types.py | 7 + .../core/src/agent_core/tools/__init__.py | 18 + packages/core/src/agent_core/tools/bash.py | 3 +- packages/core/src/agent_core/tools/edit.py | 3 +- packages/core/src/agent_core/tools/find.py | 16 +- packages/core/src/agent_core/tools/git.py | 379 ++++++ packages/core/src/agent_core/tools/grep.py | 27 +- packages/core/src/agent_core/tools/ls.py | 3 +- packages/core/src/agent_core/tools/read.py | 3 +- packages/core/src/agent_core/tools/write.py | 3 +- packages/core/src/agent_core/types.py | 12 + packages/core/tests/test_plan_tool_access.py | 78 ++ packages/core/tests/test_tools_git.py | 223 ++++ 46 files changed, 4598 insertions(+), 704 deletions(-) create mode 100644 docs/adr/0001-centralize-collaboration-mode-policy.md create mode 100644 docs/specs/plan-mode.md create mode 100644 packages/app/tests/test_plan_lifecycle.py create mode 100644 packages/app/tests/test_plan_tui_state.py create mode 100644 packages/core/src/agent_core/tools/git.py create mode 100644 packages/core/tests/test_plan_tool_access.py create mode 100644 packages/core/tests/test_tools_git.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c872a88..6a51727 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,11 @@ jobs: run: uv run coding-agent --help build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - name: 安装 uv 和 Python @@ -47,7 +51,11 @@ jobs: run: uv run python scripts/check_versions.py desktop: - runs-on: windows-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} defaults: run: working-directory: apps/desktop diff --git a/.gitignore b/.gitignore index aa9b9ed..4f2c96c 100644 --- a/.gitignore +++ b/.gitignore @@ -68,5 +68,4 @@ Desktop.ini .zcode/ .codex/ -# 本地开发文档 -/docs/ +# Plan Mode 规范和架构决策属于产品契约,随代码版本化。 diff --git a/README.md b/README.md index 0f8df69..c41a933 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ It can read and modify project files, search code, execute shell commands, and s - **Project context**: discovers `AGENTS.md`, `CLAUDE.md`, skills, and prompt templates. - **Terminal UI**: renders Markdown, streaming content, tool cards, model selection, and line-based differential updates. - **Desktop MVP**: supports workspace selection, session history, streaming messages, tool approval, model switching, and a slash-command palette. -- **Cross-client Plan Mode**: provides read-only exploration, structured questions, immutable plan revisions, and explicit execution confirmation shared by CLI and desktop. +- **Cross-client Plan Mode**: provides runtime-enforced observation tools, structured questions, immutable plan revisions, recovery states, and explicit execution confirmation shared by CLI and desktop. ## Interfaces @@ -164,10 +164,11 @@ For resumable non-interactive workflows: uv run coding-agent --agent-mode plan -p "Plan the requested change" uv run coding-agent --session --answer-plan-question "answer" uv run coding-agent --session --execute-plan +uv run coding-agent --session --handoff-plan [revision] uv run coding-agent --session --cancel-plan ``` -Plan State is stored in JSONL v4 and can be resumed by either the CLI or desktop client. When a revision is ready, the interactive clients ask whether to execute it or supplement ideas; supplemental text returns the episode to drafting and never authorizes execution. See [Plan Mode Specification](docs/specs/plan-mode.md). +Plan State is reduced from the active JSONL v4 branch and can be resumed by either the CLI or desktop client. A bare `/plan` restores state-aware controls for drafting, pending questions, ready revisions, active execution, or recovery. Ready plans can be executed in place or handed to a clean child session for a second review; supplemental text returns the episode to drafting and never authorizes execution. `settled` means the Agent turn ended, not that its result was verified, and cancelling/stopping does not roll back effects already performed. See the [Plan Mode Specification](docs/specs/plan-mode.md) and [architecture decision](docs/adr/0001-centralize-collaboration-mode-policy.md). ### Provider and model selection @@ -248,7 +249,7 @@ Tools expose their name, description, JSON Schema parameters, and asynchronous e | `/model` | Select a model from configured providers | | `/login`, `/logout` | Manage provider credentials | | `/new` | Start a new session | -| `/plan`, `/cancel-plan`, `/execute-plan` | Enter, cancel, or explicitly execute Plan Mode | +| `/plan`, `/cancel-plan`, `/execute-plan` | Open state-aware Plan controls, cancel, or explicitly execute | | `/session` | Show session information and statistics | | `/tree` | Inspect and switch session branches | | `/compact` | Compact context manually | diff --git a/README.zh-CN.md b/README.zh-CN.md index 80b1771..fa11ad6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -19,7 +19,7 @@ Coding Agent 是一个面向本地开发工作的编程 Agent。项目以 Python - **项目上下文**:支持发现 `AGENTS.md`、`CLAUDE.md`、Skills 和提示词模板。 - **终端界面**:支持 Markdown、流式内容、工具卡片、模型选择和按行差分渲染。 - **桌面端 MVP**:支持项目选择、会话列表、流式消息、工具审批、模型切换和斜杠命令面板。 -- **跨端 Plan Mode**:CLI 与桌面端共享只读探索、结构化问题、不可变计划 revision 和显式执行确认。 +- **跨端 Plan Mode**:CLI 与桌面端共享 Runtime 强制的观察工具、结构化问题、不可变 revision、恢复状态和显式执行确认。 ## 界面形态 @@ -164,10 +164,11 @@ uv run coding-agent --no-session uv run coding-agent --agent-mode plan -p "规划这项改动" uv run coding-agent --session <会话ID> --answer-plan-question <问题ID> "回答" uv run coding-agent --session <会话ID> --execute-plan +uv run coding-agent --session <会话ID> --handoff-plan [revision] uv run coding-agent --session <会话ID> --cancel-plan ``` -Plan State 使用 JSONL v4 持久化,可由 CLI 或桌面端交叉恢复。revision 就绪后,交互端会要求选择“执行方案”或“补充想法”;补充内容会回到 drafting,且绝不会构成执行授权。完整约束见 [Plan Mode 规范](docs/specs/plan-mode.md)。 +Plan State 从活动 JSONL v4 分支归约,可由 CLI 或桌面端交叉恢复。裸 `/plan` 会按 drafting、待回答问题、ready、执行中或恢复异常重新显示对应控件。ready 方案既可在当前会话执行,也可交接到干净子会话再次复核;补充内容会回到 drafting,且绝不会构成执行授权。`settled` 仅表示 Agent 回合结束,不代表结果已经验证;取消或停止也不会回滚已经发生的副作用。完整约束见 [Plan Mode 规范](docs/specs/plan-mode.md)及[架构决策](docs/adr/0001-centralize-collaboration-mode-policy.md)。 ### 选择 Provider 和模型 @@ -248,7 +249,7 @@ uv run coding-agent --provider zhipu --model glm-5v-turbo ` | `/model` | 选择已配置 Provider 的模型 | | `/login`、`/logout` | 管理 Provider 凭据 | | `/new` | 创建新会话 | -| `/plan`、`/cancel-plan`、`/execute-plan` | 进入、取消或显式执行 Plan Mode | +| `/plan`、`/cancel-plan`、`/execute-plan` | 打开状态化 Plan 控件、取消或显式执行 | | `/session` | 查看会话信息和统计数据 | | `/tree` | 查看并切换会话分支 | | `/compact` | 手动压缩上下文 | diff --git a/apps/desktop/src/renderer/App.test.tsx b/apps/desktop/src/renderer/App.test.tsx index cbd24ab..b0feca7 100644 --- a/apps/desktop/src/renderer/App.test.tsx +++ b/apps/desktop/src/renderer/App.test.tsx @@ -72,23 +72,24 @@ describe("desktop Plan Mode", () => { renderApp(); await screen.findByTestId("plan-card"); const decision = await screen.findByTestId("plan-decision"); - expect(decision).toHaveTextContent("计划已完成,下一步怎么做?"); + expect(decision).toHaveTextContent("计划已提交,下一步怎么做?"); fireEvent.click(screen.getByRole("button", { name: /执行方案/ })); await waitFor(() => expect(requests).toHaveBeenCalledWith("plan.execute", { planId: "plan-1", revision: 2, digest: "abcdef0123456789", })); + await waitFor(() => expect(requests).toHaveBeenCalledWith("session.snapshot")); }); it("lets the user supplement the ready plan from the composer", async () => { renderApp(); await screen.findByTestId("plan-decision"); - fireEvent.click(screen.getByRole("button", { name: /补充想法/ })); + fireEvent.click(screen.getByRole("button", { name: /继续修改/ })); const composer = screen.getByPlaceholderText("补充你的想法或修改要求…"); await waitFor(() => expect(composer).toHaveFocus()); expect(requests).not.toHaveBeenCalledWith("plan.execute", expect.anything()); }); - it("shows the decision selector when a live plan_ready event arrives", async () => { + it("uses a complete plan state snapshot from the live state event", async () => { requests.mockImplementation(async (method: string) => { if (method === "workspace.open") return workspace({ planState: { @@ -106,11 +107,138 @@ describe("desktop Plan Mode", () => { eventListener?.({ v: 1, type: "event", seq: 1, timestamp: Date.now(), sessionId: "session-1", runId: "run-1", - event: { type: "plan_ready", payload: { plan: latestPlan } }, + event: { + type: "plan.stateChanged", + payload: { + sessionId: "session-1", + state: { + mode: "plan", + phase: "ready", + activePlanId: "plan-1", + latestRevision: latestPlan, + pendingQuestion: null, + latestRun: null, + recoveryError: null, + handoffTargetSessionId: null, + }, + }, + }, }); expect(await screen.findByTestId("plan-decision")).toHaveTextContent("执行方案"); - expect(screen.getByTestId("plan-decision")).toHaveTextContent("补充想法"); + expect(screen.getByTestId("plan-decision")).toHaveTextContent("继续修改"); + }); + + it("ignores a delayed plan snapshot from another session", async () => { + render(); + await screen.findByTestId("plan-decision"); + + eventListener?.({ + v: 1, type: "event", seq: 2, timestamp: Date.now(), sessionId: "session-old", runId: null, + event: { + type: "plan.stateChanged", + payload: { + sessionId: "session-old", + state: { + mode: "default", + phase: "uncertain", + activePlanId: "plan-old", + latestRevision: null, + pendingQuestion: null, + }, + }, + }, + }); + + await waitFor(() => expect(screen.getByTestId("plan-decision")).toHaveTextContent("执行方案")); + expect(screen.queryByText("执行状态不确定")).not.toBeInTheDocument(); + }); + + it("keeps the authoritative ready state and rehydrates it when execute RPC fails", async () => { + let rejectExecute: ((reason: Error) => void) | undefined; + const executeResult = new Promise((_, reject) => { + rejectExecute = reject; + }); + requests.mockImplementation(async (method: string) => { + if (method === "workspace.open") return workspace(); + if (method === "session.list" || method === "command.list") return []; + if (method === "plan.execute") return executeResult; + if (method === "session.snapshot") return { + sessionId: "session-1", + messages: [], + stats: {}, + collaborationMode: "plan", + planState: workspace().planState, + }; + return {}; + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /执行方案/ })); + + expect(screen.getByTestId("plan-decision")).toHaveTextContent("正在确认…"); + expect(screen.getByTestId("plan-card")).toHaveTextContent("PLAN READY"); + rejectExecute?.(new Error("revision rejected")); + await waitFor(() => expect(requests).toHaveBeenCalledWith("session.snapshot")); + expect(await screen.findByText("revision rejected")).toBeInTheDocument(); + expect(screen.getByTestId("plan-decision")).toHaveTextContent("执行方案"); + expect(screen.getByTestId("plan-card")).toHaveTextContent("Plan title"); + }); + + it("hands the exact ready revision to a fresh review session", async () => { + const childPlan = { ...latestPlan, title: "Fresh review" }; + requests.mockImplementation(async (method: string) => { + if (method === "workspace.open") return workspace(); + if (method === "session.list" || method === "command.list") return []; + if (method === "plan.handoff") return workspace({ + sessionId: "session-2", + planState: { + mode: "plan", + phase: "ready", + activePlanId: "plan-1", + latestRevision: childPlan, + pendingQuestion: null, + }, + }); + return {}; + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /新会话复核/ })); + + await waitFor(() => expect(requests).toHaveBeenCalledWith("plan.handoff", { + planId: "plan-1", revision: 2, digest: "abcdef0123456789", + })); + expect(await screen.findByTestId("plan-card")).toHaveTextContent("Fresh review"); + expect(screen.getByTestId("plan-decision")).toHaveTextContent("新会话复核"); + }); + + it.each([ + ["uncertain", "执行状态不确定"], + ["recovery_error", "计划恢复失败"], + ] as const)("shows a clear %s recovery state", async (phase, heading) => { + requests.mockImplementation(async (method: string) => { + if (method === "workspace.open") return workspace({ + collaborationMode: phase === "recovery_error" ? "plan" : "default", + planState: { + mode: phase === "recovery_error" ? "plan" : "default", + phase, + activePlanId: "plan-1", + latestRevision: latestPlan, + pendingQuestion: null, + recoveryError: phase === "recovery_error" + ? { code: "PLAN_DIGEST_MISMATCH", message: "digest mismatch", entryId: "entry-1" } + : null, + }, + }); + if (method === "session.list" || method === "command.list") return []; + return {}; + }); + + render(); + + expect(await screen.findByTestId("plan-status")).toHaveTextContent(heading); + expect(await screen.findByTestId("plan-card")).toHaveTextContent("Plan title"); }); it("renders a structured question and submits the selected answer", async () => { @@ -140,6 +268,71 @@ describe("desktop Plan Mode", () => { })); }); + it.each(["uncertain", "recovery_error"] as const)("can explicitly replan from %s", async (phase) => { + requests.mockImplementation(async (method: string) => { + if (method === "workspace.open") return workspace({ + collaborationMode: phase === "uncertain" ? "default" : "plan", + planState: { phase, activePlanId: null, latestRevision: null, pendingQuestion: null }, + }); + if (method === "mode.enterPlan") return workspace({ + planState: { phase: "drafting", activePlanId: "new-plan", latestRevision: null, pendingQuestion: null }, + }); + if (method === "session.list" || method === "command.list") return []; + return {}; + }); + render(); + await screen.findByTestId("plan-status"); + expect(screen.getByRole("textbox")).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "重新规划" })); + await waitFor(() => expect(requests).toHaveBeenCalledWith("mode.enterPlan")); + await waitFor(() => expect(screen.queryByTestId("plan-status")).not.toBeInTheDocument()); + expect(requests).not.toHaveBeenCalledWith("plan.execute", expect.anything()); + }); + + it("can cancel corrupt state without a recoverable plan ID", async () => { + requests.mockImplementation(async (method: string) => { + if (method === "workspace.open") return workspace({ + planState: { phase: "recovery_error", activePlanId: null, latestRevision: null, pendingQuestion: null }, + }); + if (method === "plan.cancel") return workspace({ + collaborationMode: "default", + planState: { phase: "cancelled", activePlanId: null, latestRevision: null, pendingQuestion: null }, + }); + if (method === "session.list" || method === "command.list") return []; + return {}; + }); + render(); + fireEvent.click(await screen.findByRole("button", { name: "取消规划" })); + await waitFor(() => expect(requests).toHaveBeenCalledWith("plan.cancel", { planId: null })); + await waitFor(() => expect(screen.queryByTestId("plan-status")).not.toBeInTheDocument()); + }); + + it("identifies legacy prose as a candidate that cannot execute", async () => { + requests.mockImplementation(async (method: string) => { + if (method === "workspace.open") return workspace({ + planState: { phase: "drafting", activePlanId: "old", latestRevision: null, + pendingQuestion: null, legacyCandidate: true }, + }); + if (method === "session.list" || method === "command.list") return []; + return {}; + }); + render(); + expect(await screen.findByTestId("plan-legacy")).toHaveTextContent("submit_plan"); + expect(screen.queryByTestId("plan-decision")).not.toBeInTheDocument(); + }); + + it("ignores granular Plan state events even before a live full snapshot", async () => { + render(); + await screen.findByTestId("plan-decision"); + await screen.findByTestId("plan-card"); + eventListener?.({ + v: 1, type: "event", seq: 1, timestamp: Date.now(), sessionId: "session-1", runId: "old-run", + event: { type: "plan_execution_started", payload: {} }, + }); + expect(screen.getByTestId("plan-decision")).toHaveTextContent("执行方案"); + expect(screen.getByTestId("plan-card")).toHaveTextContent("PLAN READY"); + }); + it("disables Plan actions while a run is active", async () => { renderApp(); await screen.findByTestId("plan-card"); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index e9f4998..bb8b473 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -4,8 +4,9 @@ import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import remarkGfm from "remark-gfm"; import type { RuntimeEvent, - PlanQuestionPayload, PlanRevisionPayload, + PlanStatePayload, + SessionSnapshotPayload, SessionInfo, WorkspacePayload, } from "../shared/types"; @@ -37,6 +38,8 @@ interface CommandOption { description: string; } +type PendingPlanAction = "execute" | "handoff" | null; + const COMMAND_ICONS: Record = { help: "?", new: "+", @@ -122,11 +125,11 @@ const TimelineCompactionCard = memo(function TimelineCompactionCard({ ); }); -const TimelinePlanCard = memo(function TimelinePlanCard({ plan }: { plan: PlanRevisionPayload }) { +const TimelinePlanCard = memo(function TimelinePlanCard({ plan, badge }: { plan: PlanRevisionPayload; badge: string }) { return (
- PLAN + {badge} {plan.title} revision {plan.revision} · {plan.digest.slice(0, 12)}
@@ -235,6 +238,7 @@ export function App() { const [customPlanAnswer, setCustomPlanAnswer] = useState(""); const [supplementingPlanDigest, setSupplementingPlanDigest] = useState(null); const virtuosoRef = useRef(null); + const [pendingPlanAction, setPendingPlanAction] = useState(null); const composerRef = useRef(null); const didBootstrap = useRef(false); const rafQueue = useRef([]); @@ -242,6 +246,7 @@ export function App() { const localItemSequence = useRef(0); const activeCompactionId = useRef(null); const compactingRef = useRef(false); + const workspaceSessionIdRef = useRef(null); const refreshSessions = async () => { try { @@ -260,6 +265,7 @@ export function App() { }; const applyWorkspace = (payload: WorkspacePayload, restoreTimeline = true) => { + workspaceSessionIdRef.current = payload.sessionId; setWorkspace(payload); if (restoreTimeline) { setTimelineState(createTimelineStateFromMessages(payload.messages, payload.sessionId)); @@ -268,6 +274,7 @@ export function App() { setModelPickerOpen(false); setCustomPlanAnswer(""); setSupplementingPlanDigest(null); + setPendingPlanAction(null); activeCompactionId.current = null; compactingRef.current = false; setCompacting(false); @@ -277,6 +284,26 @@ export function App() { void refreshCommands(); }; + const applySessionSnapshot = (snapshot: SessionSnapshotPayload) => { + if (workspaceSessionIdRef.current !== snapshot.sessionId) return; + setWorkspace((current) => { + if (!current) return current; + return { + ...current, + collaborationMode: snapshot.collaborationMode, + planState: snapshot.planState, + messages: snapshot.messages, + }; + }); + setTimelineState(createTimelineStateFromMessages(snapshot.messages, snapshot.sessionId)); + }; + + const refreshSessionSnapshot = async () => { + const snapshot = await window.agent.request("session.snapshot"); + applySessionSnapshot(snapshot); + return snapshot; + }; + const beginCompaction = (reason: string): string => { if (compactingRef.current && activeCompactionId.current) { return activeCompactionId.current; @@ -383,10 +410,12 @@ export function App() { const { type, payload } = envelope.event; if (type === "run.started") { setRunning(true); + setPendingPlanAction(null); return; } if (type === "run.completed" || type === "run.cancelled" || type === "run.failed") { setRunning(false); + setPendingPlanAction(null); if (type === "run.failed") setError(String(payload.message ?? "运行失败")); void refreshSessions(); return; @@ -422,57 +451,27 @@ export function App() { memory: { ...current.memory, ...(payload as unknown as WorkspacePayload["memory"]) }, } : current); } - if (type === "collaboration_mode_changed") { - const mode = payload.mode === "plan" ? "plan" : "default"; - setWorkspace((current) => current ? { - ...current, - collaborationMode: mode, - planState: { - ...current.planState, - phase: String(payload.phase ?? (mode === "plan" ? "drafting" : "cancelled")) as WorkspacePayload["planState"]["phase"], - activePlanId: String(payload.plan_id ?? current.planState.activePlanId ?? "") || null, - pendingQuestion: mode === "default" ? null : current.planState.pendingQuestion, - }, - } : current); - } - if (type === "plan_question_requested") { - const question = payload.question as PlanQuestionPayload; - setWorkspace((current) => current ? { - ...current, - collaborationMode: "plan", - planState: { ...current.planState, phase: "awaiting_answer", pendingQuestion: question }, - } : current); - } - if (type === "plan_question_answered") { - setWorkspace((current) => current ? { - ...current, - planState: { ...current.planState, phase: "drafting", pendingQuestion: null }, - } : current); - } - if (type === "plan_ready") { - const plan = payload.plan as PlanRevisionPayload; - setSupplementingPlanDigest(null); - setWorkspace((current) => current ? { - ...current, - collaborationMode: "plan", - planState: { ...current.planState, phase: "ready", pendingQuestion: null, latestRevision: plan }, - } : current); - } - if (type === "plan_execution_started") { - setWorkspace((current) => current ? { - ...current, - collaborationMode: "default", - planState: { ...current.planState, phase: "executing", pendingQuestion: null }, - } : current); - } - if (type === "plan_execution_completed" || type === "plan_execution_failed" || type === "plan_execution_aborted") { - const phase = type.replace("plan_execution_", "") as "completed" | "failed" | "aborted"; - setWorkspace((current) => current ? { - ...current, - collaborationMode: "default", - planState: { ...current.planState, phase }, - } : current); + if (type === "plan_state_changed" || type === "plan.stateChanged") { + const state = payload.state as PlanStatePayload | undefined; + if (!state || typeof state.phase !== "string") return; + const payloadSessionId = typeof payload.sessionId === "string" + ? payload.sessionId + : typeof payload.session_id === "string" ? payload.session_id : envelope.sessionId; + const payloadMode = payload.collaboration_mode === "plan" ? "plan" + : payload.collaboration_mode === "default" ? "default" : undefined; + setWorkspace((current) => { + if (!current || (payloadSessionId && payloadSessionId !== current.sessionId)) return current; + return { + ...current, + collaborationMode: state.mode ?? payloadMode ?? current.collaborationMode, + planState: state, + }; + }); + if (state.phase !== "ready") setSupplementingPlanDigest(null); + return; } + // Granular Plan events remain on the wire for external older clients. + // Built-in clients never reconstruct state from them. }; const chooseWorkspace = async () => { @@ -763,7 +762,8 @@ export function App() { }; const enterPlanMode = async () => { - if (!workspace || running || compacting || workspace.collaborationMode === "plan") return; + if (!workspace || running || compacting + || (workspace.collaborationMode === "plan" && workspace.planState.phase !== "recovery_error")) return; try { applyWorkspace(await window.agent.request("mode.enterPlan")); } catch (reason) { @@ -773,7 +773,8 @@ export function App() { const cancelPlan = async () => { const planId = workspace?.planState.activePlanId; - if (!planId || running || compacting) return; + if (!workspace || (!planId && workspace.planState.phase !== "recovery_error") + || (running && workspace.planState.phase !== "awaiting_answer") || compacting) return; try { applyWorkspace(await window.agent.request("plan.cancel", { planId })); } catch (reason) { @@ -791,10 +792,6 @@ export function App() { answer: answer.trim(), }); setCustomPlanAnswer(""); - setWorkspace((current) => current ? { - ...current, - planState: { ...current.planState, phase: "drafting", pendingQuestion: null }, - } : current); } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } @@ -805,19 +802,49 @@ export function App() { if (!plan || running || compacting || workspace?.planState.phase !== "ready") return; setError(null); setRunning(true); - setWorkspace((current) => current ? { - ...current, - collaborationMode: "default", - planState: { ...current.planState, phase: "executing", pendingQuestion: null }, - } : current); + setPendingPlanAction("execute"); try { await window.agent.request("plan.execute", { planId: plan.planId, revision: plan.revision, digest: plan.digest, }); + await refreshSessionSnapshot(); + } catch (reason) { + setRunning(false); + setPendingPlanAction(null); + try { + await refreshSessionSnapshot(); + } catch { + // Keep the RPC error as the actionable message. The last authoritative + // snapshot remains visible when rehydration is itself unavailable. + } + setError(reason instanceof Error ? reason.message : String(reason)); + } + }; + + const handoffPlan = async () => { + const plan = workspace?.planState.latestRevision; + if (!plan || running || compacting || workspace?.planState.phase !== "ready") return; + setError(null); + setRunning(true); + setPendingPlanAction("handoff"); + try { + const payload = await window.agent.request("plan.handoff", { + planId: plan.planId, + revision: plan.revision, + digest: plan.digest, + }); + setRunning(false); + applyWorkspace(payload); } catch (reason) { setRunning(false); + setPendingPlanAction(null); + try { + await refreshSessionSnapshot(); + } catch { + // Preserve the last authoritative state if the runtime is unavailable. + } setError(reason instanceof Error ? reason.message : String(reason)); } }; @@ -945,22 +972,34 @@ export function App() { const readyPlan = workspace?.planState.phase === "ready" ? workspace.planState.latestRevision : null; + const visiblePlan = workspace?.planState.latestRevision ?? null; const awaitingPlanDecision = Boolean( readyPlan && supplementingPlanDigest !== readyPlan.digest, ); + const planStatusPhase = workspace?.planState.phase; + const planStatusMessage = planStatusPhase === "uncertain" + ? "上一次执行没有可靠的终态记录,无法判断是否完成。请先检查工作区和运行记录,再决定是否重新规划或执行。" + : planStatusPhase === "recovery_error" + ? "Plan 状态记录校验失败。为避免执行错误或被篡改的 revision,当前计划已被锁定。" + : null; + const planBadgeLabel = planStatusPhase === "ready" ? "PLAN READY" + : planStatusPhase === "uncertain" ? "PLAN UNCERTAIN" + : planStatusPhase === "recovery_error" ? "PLAN RECOVERY" : "PLAN"; const timelineItems = useMemo(() => selectTimelineItems(timelineState), [timelineState]); - const renderTimelineItems = useMemo(() => readyPlan + const renderedPlan = ["ready", "uncertain", "recovery_error"].includes(planStatusPhase ?? "") + ? visiblePlan : null; + const renderTimelineItems = useMemo(() => renderedPlan ? [ ...timelineItems, { - id: `plan:${readyPlan.planId}:${readyPlan.revision}:${readyPlan.digest}`, + id: `plan:${renderedPlan.planId}:${renderedPlan.revision}:${renderedPlan.digest}`, kind: "plan", order: timelineState.lastOrder + 1, - plan: readyPlan, + plan: renderedPlan, }, ] - : timelineItems, [readyPlan, timelineItems, timelineState.lastOrder]); + : timelineItems, [renderedPlan, timelineItems, timelineState.lastOrder]); const virtuosoComponents = useMemo(() => ({ Header: () => )} +
)} {readyPlan && awaitingPlanDecision && (
- PLAN + PLAN READY 下一步
-

计划已完成,下一步怎么做?

+

计划已提交,下一步怎么做?

+
)} + {workspace?.planState.legacyCandidate && ( +
+

检测到旧版计划候选文本,尚未形成可执行 revision。请继续规划,并通过 submit_plan 重新提交后再确认执行。

+
+ )} + {planStatusMessage && ( +
+
+ {planBadgeLabel} + {planStatusPhase === "uncertain" ? "执行状态不确定" : "计划恢复失败"} +
+

{planStatusMessage}

+ {workspace?.planState.recoveryError && ( +
{workspace.planState.recoveryError.code}: {workspace.planState.recoveryError.message}
+ )} +
+ 查看记录详情 +
{JSON.stringify(workspace?.planState.latestRun ?? workspace?.planState.recoveryError, null, 2)}
+
+
+ + +
+
+ )} {commandMenuOpen && (
@@ -1236,9 +1305,9 @@ export function App() { : supplementingPlanDigest === readyPlan?.digest ? "补充你的想法或修改要求…" : awaitingPlanDecision - ? "请先选择执行方案或补充想法" + ? "请先选择执行、新会话复核或继续修改" : "描述你想完成的任务…"} - disabled={!workspace || awaitingPlanDecision || compacting} + disabled={!workspace || awaitingPlanDecision || compacting || Boolean(planStatusMessage)} rows={3} />
@@ -1255,7 +1324,9 @@ export function App() { onClick={() => void enterPlanMode()} disabled={!workspace || running || compacting} >Plan - {workspace?.collaborationMode === "plan" && PLAN} + {(workspace?.collaborationMode === "plan" || planStatusMessage) && ( + {planBadgeLabel} + )}
Enter 发送 · Shift+Enter 换行 {running ? ( diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index d56dc66..200867e 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -315,6 +315,28 @@ button:focus-visible, textarea:focus-visible { outline: 2px solid #8e8a82; outli .plan-custom-answer { display: flex; gap: 7px; margin-top: 8px; } .plan-custom-answer input { flex: 1; min-width: 0; border: 1px solid #d8cfac; border-radius: 8px; padding: 7px 9px; outline: 0; background: #fff; font-size: 10px; } .plan-custom-answer input:focus { border-color: #aa984d; } +.plan-status { + max-width: 820px; + margin: 0 auto 9px; + padding: 13px 14px; + border: 1px solid #d9b56f; + border-radius: 12px; + background: #fff9e9; + box-shadow: 0 6px 22px rgba(98, 78, 24, .07); +} +.plan-status.recovery_error { border-color: #d39a92; background: #fff5f3; } +.plan-status p { margin: 10px 0 0; color: #5d5035; font-size: 11px; line-height: 1.6; } +.plan-status pre { + max-height: 120px; + margin: 9px 0 0; + padding: 8px 10px; + overflow: auto; + border-radius: 8px; + background: rgba(255, 255, 255, .72); + color: #7f433e; + font-size: 9px; + white-space: pre-wrap; +} .composer-wrap { position: relative; diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index edd9989..32c2d16 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -55,7 +55,8 @@ export interface MemoryStatePayload { export type PlanPhase = | "idle" | "drafting" | "awaiting_answer" | "ready" | "executing" - | "completed" | "failed" | "aborted" | "cancelled"; + | "completed" | "settled" | "failed" | "aborted" | "cancelled" + | "uncertain" | "recovery_error"; export interface PlanQuestionOptionPayload { label: string; @@ -77,13 +78,48 @@ export interface PlanRevisionPayload { markdown: string; digest: string; sourceMessageId: string; + schemaVersion?: number; + submittedByToolCallId?: string | null; + originSessionId?: string | null; +} + +export interface PlanRunPayload { + planId: string; + revision: number; + digest: string; + status: "started" | "completed" | "failed" | "aborted"; + runId: string | null; + error: string | null; + assistantMessageId: string | null; + stopReason: string | null; + entryId: string; + timestamp: string; +} + +export interface PlanRecoveryErrorPayload { + code: string; + message: string; + entryId: string; } export interface PlanStatePayload { + mode?: "default" | "plan"; phase: PlanPhase; activePlanId: string | null; latestRevision: PlanRevisionPayload | null; pendingQuestion: PlanQuestionPayload | null; + latestRun?: PlanRunPayload | null; + recoveryError?: PlanRecoveryErrorPayload | null; + handoffTargetSessionId?: string | null; + legacyCandidate?: boolean; +} + +export interface SessionSnapshotPayload { + sessionId: string; + messages: AgentMessage[]; + stats: Record; + collaborationMode: "default" | "plan"; + planState: PlanStatePayload; } export interface AgentMessage { diff --git a/docs/adr/0001-centralize-collaboration-mode-policy.md b/docs/adr/0001-centralize-collaboration-mode-policy.md new file mode 100644 index 0000000..4a49af8 --- /dev/null +++ b/docs/adr/0001-centralize-collaboration-mode-policy.md @@ -0,0 +1,71 @@ +# ADR 0001: Centralize Collaboration Mode Policy + +- Status: Accepted +- Date: 2026-08-22 +- Updated: 2026-09-07 + +## Context + +Coding Agent has a terminal client and an Electron desktop client over the same +session runtime. Plan Mode affects authorization, prompt composition, tool +registration, immutable user confirmation, branch recovery and cross-session +handoff. Prompt-only restrictions or frontend-specific state can diverge after +resume and cannot protect direct shell, custom-tool or RPC entry points. + +Command-string allowlists are also not a sufficient read-only boundary. Shell +syntax has multiple composition forms, and apparently read-only Git commands +can invoke configured external diff or textconv programs. + +## Decision + +`coding_agent.core.AgentSession` is the sole owner of collaboration mode and +Plan State. Every operation appends a branch-local entry and a pure reducer +projects the authoritative state. Frontends render full snapshots and translate +explicit user actions into Core calls; they do not parse natural-language +approval or maintain their own transition graph. + +Plan submission is an explicit control operation. `submit_plan` is available +only while drafting, must be the only tool call in its message, validates the +title/Markdown without semantic rewriting, persists an immutable digest-bound +revision and terminates the turn. + +Tool availability uses one `plan_access` classification owned by the runtime. +Unclassified tools fail closed. Plan exploration uses Python workspace readers +and structured Git tools; arbitrary shell, tests, builds and mutation tools are +not registered and are blocked again at execution time. Git diff/show disable +external diff and textconv explicitly. + +Execution authorization binds the latest `planId`, `revision` and `digest`. +Persisted `completed` means only a settled Agent turn. An orphaned `started` +entry becomes `uncertain`, never inferred success or an automatic retry. + +Clean-session execution is a linked handoff: only the confirmed revision and +origin metadata cross into a child session, which remains ready until a second +explicit confirmation. + +## Consequences + +- CLI/TUI and desktop resume the same branch-local state and security policy. +- New tools and subagents cannot gain Plan access by being omitted from an + allowlist maintained elsewhere. +- JSONL provides an auditable authorization trail and detects stale or corrupt + state before execution. +- Plan submission no longer depends on Markdown heuristics or response timing. +- Git observation has concrete external-helper guarantees without claiming a + general OS sandbox. +- Frontends contain more Plan-specific rendering but no safety-critical state + transitions. +- Stopping or cancelling Plan work does not undo filesystem, Git, subprocess or + external effects that already occurred. + +## Alternatives rejected + +- **Prompt-only Plan Mode:** cannot enforce tool or RPC behavior. +- **Frontend-local state:** diverges across clients and loses recovery truth. +- **Natural-language execution detection:** is ambiguous and unauditable. +- **General shell read allowlist:** cannot safely classify composition, flags, + aliases and configuration-driven helpers. +- **Copying full planning context into an execution session:** preserves hidden + instructions and defeats the isolation expected from a fresh handoff. +- **Treating a normal Agent stop as verified completion:** conflates runtime + lifecycle with implementation evidence. diff --git a/docs/specs/plan-mode.md b/docs/specs/plan-mode.md new file mode 100644 index 0000000..fa90820 --- /dev/null +++ b/docs/specs/plan-mode.md @@ -0,0 +1,193 @@ +# Plan Mode Specification + +## Status + +Plan Mode is implemented by the shared Python `AgentSession` and rendered by +the CLI/TUI and Electron desktop clients. Session storage remains append-only +JSONL v4; new Plan revisions use the v1 digest format while v0 entries remain +readable. + +## Runtime contract + +A session has one collaboration mode: + +- `default`: normal tools are available, subject to project trust and approval. +- `plan`: the runtime exposes only observation and Plan control tools. Project + mutation, arbitrary shell execution, tests, builds and package scripts are + unavailable. + +Only host controls can enter, cancel, hand off or execute Plan Mode. Model text, +ordinary user messages and frontend-local state never authorize execution. +Leaving Plan Mode never implies execution. + +Plan State is reduced from entries on the active branch. Switching `/tree` +therefore restores that branch's mode, pending question, latest revision and +run state rather than a session-global value. + +The projected phases are: + +```text +idle -> drafting -> awaiting_answer -> drafting -> ready +drafting|awaiting_answer|ready -> cancelled +ready -> executing -> settled|failed|aborted +orphaned executing -> uncertain +invalid persisted transitions or digests -> recovery_error +``` + +`settled` means only that the Agent execution turn ended normally. It is not +proof that the implementation is correct. Files, diffs, command exit codes and +user acceptance remain separate evidence. + +## Plan submission and revisions + +The assistant submits a plan only through +`submit_plan({title, markdown})`. The tool is available only while drafting, +must be the sole tool call in its assistant message and terminates that turn. +A normal text response, truncated response, aborted response or legacy +`` block cannot create a new executable revision. + +The runtime validates a single-line title of at most 200 characters and +Markdown of at most 64 KiB. It normalizes CRLF to LF but does not add +sections, strip prose or otherwise rewrite the submitted plan. + +Every accepted submission creates an immutable `PlanRevisionEntry`. New v1 +digests are SHA-256 over compact, key-sorted JSON containing +`schemaVersion`, `planId`, `revision`, `title` and normalized `markdown`. +Execution and handoff must match the latest `planId + revision + digest` tuple; +stale confirmation fails closed. + +Older digest entries remain readable and are never rewritten. A legacy session +with only assistant plan prose is shown as a legacy candidate: the user must +continue Plan Mode and the assistant must submit a new revision before it can +be executed. + +## Questions and controls + +`request_user_input` accepts one structured question with a short header, a +non-empty prompt, two or three mutually exclusive choices and an optional +custom response. The question is persisted before the UI waits. Closing a UI +or aborting the wait preserves the pending question for another client. + +A bare `/plan` is state-aware: + +- `idle`: start a new Plan Episode. +- `drafting`: continue, ask the assistant to submit, or cancel. +- `awaiting_answer`: restore the pending question or cancel. +- `ready`: supplement, execute here, hand off for clean-session review, or + cancel. +- `executing`: inspect status or request stop. +- `uncertain` / `recovery_error`: inspect details, start a new Plan Episode, or + cancel. The old run is never retried automatically. + +`/cancel-plan` and `/execute-plan` remain compatibility aliases. Non-interactive +controls are: + +```text +--agent-mode {default,plan} +--answer-plan-question QUESTION_ID "answer" +--execute-plan REVISION +--handoff-plan [REVISION] +--cancel-plan +``` + +The control flags are mutually exclusive. Omitting the revision after +`--handoff-plan` selects the latest ready revision. + +## Tool policy and Git boundary + +Every tool has one runtime-owned `plan_access` classification: +`observe`, `control` or `deny`; missing metadata defaults to `deny`. The same +classification controls tool registration and a pre-execution gate, so a +frontend, custom tool or subagent cannot widen Plan access independently. + +Plan Mode exposes Python-backed workspace readers (`read`, `grep`, `find`, +`ls`), Plan controls and structured Git readers. It does not expose `bash`, +write/edit tools, test runners, linters, builders or package managers. Python +backends are used for search and discovery so repository-local executables and +`PATH` shims are not launched. + +`git_status`, `git_log`, `git_diff` and `git_show` launch Git with an argument +array rather than through a shell. Pager, terminal prompts, optional locks and +fsmonitor are disabled. Diff/show additionally force +`--no-ext-diff --no-textconv --no-color`; log forces `--no-patch`. Revision and +path operands reject control characters, option-like values and excessive +length. These guarantees prevent external diff and textconv helpers from being +executed. Other hostile Git configuration remains part of the documented +Project Trust boundary, not an OS sandbox guarantee. + +Policy rejection uses stable code `PLAN_POLICY_BLOCKED`, includes a reason and +suggests a structured alternative when one exists. It happens before any +frontend allow-once prompt. + +## Persistence, execution and recovery + +Plan commands append and flush an entry before a pure reducer projects the +next state. The reducer verifies identifiers, monotonic revisions, v0/v1 +digests, question-answer pairing, run tuples and transition order. Invalid +history projects `recovery_error` and blocks execution. + +Malformed JSONL, invalid UTF-8, duplicate entry IDs and missing parents are +retained as load diagnostics. Because a damaged line may conceal an execution +record, the runtime blocks ordinary prompts and shell passthrough until the +user explicitly cancels or starts a new planning episode. This also applies +when damage prevents the runtime from identifying an active plan. History is +preserved; the explicit recovery action is appended after the diagnostic. + +Execution writes and flushes `plan_run: started` before enabling Default tools. +The terminal entry remains one of `completed`, `failed` or `aborted` for JSONL +compatibility; clients project `completed` as `settled`. If a later process +finds `started` without a live runtime owner, it projects `uncertain` and +requires explicit user action. + +If the runtime cannot persist a terminal entry, it releases live ownership and +replays the log. A remaining `started` entry becomes `uncertain` immediately, +including in the current process. No retry is scheduled. + +Confirmation may record accepted-plan memory. Neither submission alone nor a +`settled` result produces a completed-task memory claim. + +Session controls are not transactions. Cancelling a plan, stopping a run, +switching branches, or deleting/closing a session does **not** roll back files, +Git changes, subprocess effects or external service actions already performed. + +## Clean-session handoff + +Handoff validates the exact latest revision, creates a child session whose +`parent_session` points to the source, and copies only the immutable revision +plus origin metadata. Planning conversation, questions and tool output are not +copied. The child opens in `ready`; execution requires another explicit click +or `--execute-plan` in that child. + +The source records `reason=handoff` and the target ID only after the target is +durably created. A failed handoff leaves the source ready. A partially created +child is retained as an incomplete handoff for inspection; it is never executed +or deleted automatically. + +## Client projection + +Core publishes `plan.stateChanged` with `sessionId` and the complete +authoritative Plan State. The older granular lifecycle events remain available +for one compatibility cycle, but built-in clients render from the full state +snapshot. `session.snapshot` likewise includes `collaborationMode` and +`planState`. Clients may display a pending RPC spinner, but must not +optimistically advance mode or phase. + +TUI startup, session creation, branch switching and handoff all rehydrate the +question/action component from Core state. The footer displays `plan`, +`plan ready`, `plan uncertain` or `plan recovery`. Desktop uses the same +snapshot and refetches it after a rejected control request. + +## Acceptance criteria + +- Only a successful, sole `submit_plan` call creates a ready revision. +- Replaying one active branch deterministically yields the same Plan State. +- Unknown tools, shell commands, tests/builds and mutating tools are denied in + Plan Mode before frontend approval. +- Structured Git diff/show cannot launch configured external diff or textconv + helpers. +- A stale tuple cannot execute or hand off; an orphaned run restores as + `uncertain`. +- A child handoff contains no planning transcript and requires a second + confirmation. +- TUI and desktop restore the same controls from the same snapshot and always + expose a safe exit or recovery action. diff --git a/packages/app/src/coding_agent/cli/args.py b/packages/app/src/coding_agent/cli/args.py index 23fb083..22b099e 100644 --- a/packages/app/src/coding_agent/cli/args.py +++ b/packages/app/src/coding_agent/cli/args.py @@ -62,6 +62,8 @@ class Args: agent_mode: str | None = None answer_plan_question: str | None = None execute_plan: int | None = None + #: ``0`` means use the latest ready revision; positive values pin one. + handoff_plan: int | None = None cancel_plan: bool = False export_path: str | None = None list_models: str | None = None # None=off, ""=all, "str"=search @@ -117,6 +119,21 @@ def _parse_comma_list(value: str) -> list[str]: return [s.strip() for s in value.split(",") if s.strip()] +def _parse_positive_revision(value: str | int) -> int: + """Parse an explicitly supplied Plan revision. + + ``0`` is reserved internally as the ``--handoff-plan`` "latest" sentinel + and is never accepted from command-line text. + """ + try: + revision = int(value) + except (TypeError, ValueError) as exc: + raise argparse.ArgumentTypeError("Plan revision 必须是正整数") from exc + if revision < 1: + raise argparse.ArgumentTypeError("Plan revision 必须是正整数") + return revision + + # ─── Parser construction ────────────────────────────────────────────────── @@ -300,9 +317,17 @@ def build_parser() -> argparse.ArgumentParser: plan_control.add_argument( "--execute-plan", metavar="REVISION", - type=int, + type=_parse_positive_revision, help="执行恢复会话中的指定最新 Plan revision", ) + plan_control.add_argument( + "--handoff-plan", + metavar="REVISION", + nargs="?", + const=0, + type=_parse_positive_revision, + help="将指定(默认最新)Plan revision 交接到新会话复核,不立即执行", + ) plan_control.add_argument( "--cancel-plan", action="store_true", @@ -459,6 +484,7 @@ def _apply_namespace(args: Args, ns: argparse.Namespace) -> None: args.agent_mode = ns.agent_mode args.answer_plan_question = ns.answer_plan_question args.execute_plan = ns.execute_plan + args.handoff_plan = ns.handoff_plan args.cancel_plan = ns.cancel_plan args.export_path = ns.export_path args.list_models = ns.list_models # None=off, ""=all, "str"=search @@ -488,7 +514,12 @@ def resolve_app_mode(args: Args) -> str: """ if args.print_mode: return "print" - if args.answer_plan_question or args.execute_plan is not None or args.cancel_plan: + if ( + args.answer_plan_question + or args.execute_plan is not None + or args.handoff_plan is not None + or args.cancel_plan + ): return "print" if getattr(args, "output_mode", "text") == "json": return "print" diff --git a/packages/app/src/coding_agent/cli/main.py b/packages/app/src/coding_agent/cli/main.py index 52e788c..2f5ed1e 100644 --- a/packages/app/src/coding_agent/cli/main.py +++ b/packages/app/src/coding_agent/cli/main.py @@ -449,9 +449,13 @@ def _get_api_key(provider_id: str) -> str | None: print("错误:--answer-plan-question 必须且只能附带一个位置参数作为答案。", file=sys.stderr) return 2 control_answer = args.messages[0] - elif args.execute_plan is not None or args.cancel_plan: + elif ( + args.execute_plan is not None + or args.handoff_plan is not None + or args.cancel_plan + ): if args.messages or args.file_args or stdin_content is not None: - print("错误:执行或取消计划时不能附带普通提示词。", file=sys.stderr) + print("错误:执行、交接或取消计划时不能附带普通提示词。", file=sys.stderr) return 2 file_text = "" @@ -537,16 +541,22 @@ def _get_api_key(provider_id: str) -> str | None: and session.collaboration_mode == "plan" and not args.cancel_plan and args.execute_plan is None + and args.handoff_plan is None ): print( "错误:恢复中的 Plan 会话不能用 --agent-mode default 绕过确认;" - "请使用 --cancel-plan 或 --execute-plan。", + "请使用 --cancel-plan、--execute-plan 或 --handoff-plan。", file=sys.stderr, ) session.dispose() return 2 - if args.answer_plan_question or args.execute_plan is not None or args.cancel_plan: + if ( + args.answer_plan_question + or args.execute_plan is not None + or args.handoff_plan is not None + or args.cancel_plan + ): return _run_plan_control( session, args, answer=control_answer, mode=args.output_mode, ) @@ -734,6 +744,19 @@ def _text_event(event: dict) -> None: await session.answer_plan_question(args.answer_plan_question, answer or "") elif args.cancel_plan: session.cancel_plan_mode(session.plan_state.active_plan_id) + elif args.handoff_plan is not None: + latest = session.plan_state.latest_revision + if latest is None: + raise PlanModeError("PLAN_NOT_READY", "当前没有可交接的计划") + requested_revision = args.handoff_plan or latest.revision + target = session.handoff_plan_to_new_session( + latest.plan_id, requested_revision, latest.digest, + ) + if mode == "text": + print( + f"Plan 已交接到新会话 {target.header.id};请在新会话复核后执行。", + file=sys.stderr, + ) else: latest = session.plan_state.latest_revision requested_revision = args.execute_plan @@ -751,7 +774,9 @@ def _text_event(event: dict) -> None: if mode == "text": sys.stdout.write("\n") _print_plan_resume_hint(session) - return 1 if session.plan_state.phase in {"failed", "aborted"} else 0 + return 1 if session.plan_state.phase in { + "failed", "aborted", "uncertain", "recovery_error", + } else 0 async def _entry() -> int: try: @@ -777,13 +802,27 @@ def _print_plan_resume_hint(session: AgentSession) -> None: f"--answer-plan-question {question.question_id} \"\"", file=sys.stderr, ) - elif state.mode == "plan" and state.latest_revision is not None: + elif state.phase == "ready" and state.latest_revision is not None: revision = state.latest_revision print( f"下一步:coding-agent --session {session_id} --execute-plan {revision.revision} " - f"(或 --cancel-plan)", + f"(或 --handoff-plan {revision.revision} / --cancel-plan)", + file=sys.stderr, + ) + elif state.phase == "drafting": + print( + f"下一步:coding-agent --session {session_id} -p \"继续完善并提交计划\"", + file=sys.stderr, + ) + elif state.phase == "uncertain": + print( + "Plan 执行终态未知;不会自动重试。请恢复会话检查记录,或显式开始新的规划。", file=sys.stderr, ) + elif state.phase == "recovery_error": + error = getattr(state, "recovery_error", None) + detail = getattr(error, "message", "持久化 Plan 状态校验失败") + print(f"Plan 恢复失败:{detail}", file=sys.stderr) def _json_default(obj: Any) -> Any: diff --git a/packages/app/src/coding_agent/core/agent_session.py b/packages/app/src/coding_agent/core/agent_session.py index 89079fe..b8bb0de 100644 --- a/packages/app/src/coding_agent/core/agent_session.py +++ b/packages/app/src/coding_agent/core/agent_session.py @@ -20,6 +20,7 @@ import contextlib import sys import time +import uuid from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any, Callable, Literal, cast @@ -40,6 +41,10 @@ CompactionOrchestrator, EditTool, FindTool, + GitDiffTool, + GitLogTool, + GitShowTool, + GitStatusTool, GrepTool, LsTool, ReadTool, @@ -58,13 +63,12 @@ PlanState, QuestionBehavior, RequestUserInputTool, + SubmitPlanTool, call_hook, + create_plan_revision, enforce_plan_tool_policy, - is_plan_safe_shell_command, new_plan_id, - prepare_plan_reply, reduce_plan_state, - validate_proposed_plan, ) # ─── Session event types (extending AgentEvent) ────────────────────────── @@ -173,7 +177,7 @@ class _TokenStats: # ─── Built-in tool factory ──────────────────────────────────────────────── def _create_default_tools(cwd: str, shell_kind: str = "bash", platform: str = "") -> list[AgentTool]: - """Create the standard 7 built-in tools. + """Create the standard development and structured-observation tools. Returns ``AgentTool`` instances directly without additional wrappers. """ @@ -186,9 +190,30 @@ def _create_default_tools(cwd: str, shell_kind: str = "bash", platform: str = "" GrepTool(cwd=cwd), FindTool(cwd=cwd), LsTool(cwd=cwd), + GitStatusTool(cwd=cwd), + GitLogTool(cwd=cwd), + GitDiffTool(cwd=cwd), + GitShowTool(cwd=cwd), ]) +def _create_plan_observation_tools( + tools: list[AgentTool], *, cwd: str, +) -> list[AgentTool]: + """Build the fail-closed Plan view of an already-filtered tool registry.""" + result: list[AgentTool] = [] + for tool in tools: + if getattr(tool, "plan_access", "deny") != "observe": + continue + if getattr(tool, "name", "") == "grep": + result.append(cast(AgentTool, GrepTool(cwd=cwd, prefer_external=False))) + elif getattr(tool, "name", "") == "find": + result.append(cast(AgentTool, FindTool(cwd=cwd, prefer_external=False))) + else: + result.append(tool) + return result + + def _filter_tools( tools: list[AgentTool], allowed: list[str] | None, @@ -271,6 +296,9 @@ def __init__(self, config: AgentSessionConfig) -> None: config.excluded_tool_names, config.no_tools, ) + self._plan_observation_tools = _create_plan_observation_tools( + self._development_tools, cwd=config.cwd, + ) self._bash_tool = cast( BashTool | None, next((tool for tool in self._development_tools if tool.name == "bash"), None), @@ -293,7 +321,12 @@ def __init__(self, config: AgentSessionConfig) -> None: self._background_tasks: set = set() # ── Plan state + effective tools ───────────────────────────────── - self._plan_state = reduce_plan_state(self.session_manager.get_branch()) + self._active_plan_run_id: str | None = None + self._plan_state = reduce_plan_state( + self.session_manager.get_branch(), + parent_session_id=self.session_manager.header.parent_session, + load_issues=self.session_manager.load_issues, + ) self._question_behavior = config.question_behavior self._question_future: asyncio.Future[str] | None = None self._question_signal_task: asyncio.Task | None = None @@ -302,11 +335,14 @@ def __init__(self, config: AgentSessionConfig) -> None: self._request_plan_question, deferred=config.question_behavior == "deferred", ) + self._submit_plan_tool = SubmitPlanTool(self._submit_plan) if self._plan_state.phase == "idle" and config.collaboration_mode == "plan": plan_id = new_plan_id() self.session_manager.append_collaboration_mode_change("plan", plan_id=plan_id) - self._plan_state = PlanState( - mode="plan", phase="drafting", active_plan_id=plan_id, + self._plan_state = reduce_plan_state( + self.session_manager.get_branch(), + parent_session_id=self.session_manager.header.parent_session, + load_issues=self.session_manager.load_issues, ) self._tools = self._effective_tools() @@ -444,10 +480,17 @@ def on_event( # ── Collaboration mode ─────────────────────────────────────────────── def _effective_tools(self) -> list[AgentTool]: - tools = list(self._development_tools) - if self._plan_state.mode == "plan": - tools.append(cast(AgentTool, self._control_tool)) - return tools + if self._plan_state.phase in {"uncertain", "recovery_error"}: + return [] + if self._plan_state.mode != "plan": + return list(self._development_tools) + if self._plan_state.phase != "drafting": + return [] + return [ + *self._plan_observation_tools, + cast(AgentTool, self._control_tool), + cast(AgentTool, self._submit_plan_tool), + ] @property def web_search_enabled(self) -> bool: @@ -456,7 +499,12 @@ def web_search_enabled(self) -> bool: @property def native_web_search_enabled(self) -> bool: """Whether this session will use the model provider's built-in search.""" - return self.web_search_enabled and self._model.provider == "deepseek" + return ( + self.web_search_enabled + and self._model.provider == "deepseek" + and self._plan_state.mode == "default" + and self._plan_state.phase not in {"uncertain", "recovery_error"} + ) @property def web_search_backend_label(self) -> str: @@ -464,7 +512,7 @@ def web_search_backend_label(self) -> str: async def set_web_search_enabled(self, enabled: bool) -> None: """Enable or disable the application-owned search tool at runtime.""" - if self._is_processing: + if self._is_processing or self._active_plan_run_id is not None: raise RuntimeError("RUN_IN_PROGRESS: 任务运行时不能切换联网检索") if enabled == self.web_search_enabled: self._config.web_search_enabled = enabled @@ -533,29 +581,53 @@ def _refresh_collaboration_runtime(self) -> None: async def _before_tool_call( self, context: BeforeToolCallContext, signal: asyncio.Event, ) -> BeforeToolCallResult | None: - if self._plan_state.mode == "plan": - policy_result = await enforce_plan_tool_policy(context, self.cwd) + if self._plan_state.mode == "plan" or self._plan_state.phase in {"uncertain", "recovery_error"}: + policy_result = await enforce_plan_tool_policy( + context, self.cwd, phase=self._plan_state.phase, + ) if policy_result is not None and policy_result.block: self._emit_event({ "type": "plan_policy_blocked", - "code": "PLAN_POLICY_BLOCKED", + "code": policy_result.code or "PLAN_POLICY_BLOCKED", "tool_name": context.tool_call.name, "reason": policy_result.reason, }) return policy_result return await call_hook(self._config.before_tool_call, context, signal) + def _sync_plan_state(self, *, emit: bool = True) -> PlanState: + """Rebuild authoritative Plan State after a persisted transition.""" + previous = self._plan_state + self._plan_state = reduce_plan_state( + self.session_manager.get_branch(), + live_run_id=self._active_plan_run_id, + parent_session_id=self.session_manager.header.parent_session, + load_issues=self.session_manager.load_issues, + ) + if self._plan_state != previous: + self._refresh_collaboration_runtime() + if emit and self._plan_state != previous: + self._emit_event({ + "type": "plan.stateChanged", + "sessionId": self.session_manager.header.id, + "state": self._plan_state.to_payload(), + }) + return self._plan_state + def enter_plan_mode(self) -> PlanState: - if self._is_processing: + if self._is_processing or self._active_plan_run_id is not None: raise PlanModeError("RUN_IN_PROGRESS", "任务运行中,不能切换协作模式") - if self._plan_state.mode == "plan": + if self._plan_state.mode == "plan" and self._plan_state.phase != "recovery_error": return self._plan_state + if self._plan_state.phase == "recovery_error": + self.session_manager.append_collaboration_mode_change( + "default", + plan_id=self._plan_state.active_plan_id, + reason="user", + ) plan_id = new_plan_id() self.session_manager.append_collaboration_mode_change("plan", plan_id=plan_id) - self._plan_state = PlanState( - mode="plan", phase="drafting", active_plan_id=plan_id, - ) - self._refresh_collaboration_runtime() + self._sync_plan_state() self._emit_event({ "type": "collaboration_mode_changed", "mode": "plan", "phase": "drafting", "plan_id": plan_id, @@ -563,20 +635,29 @@ def enter_plan_mode(self) -> PlanState: return self._plan_state def cancel_plan_mode(self, plan_id: str | None = None) -> PlanState: + if self._active_plan_run_id is not None: + raise PlanModeError("RUN_IN_PROGRESS", "任务运行中,不能取消规划") if self._is_processing and self._question_future is None: raise PlanModeError("RUN_IN_PROGRESS", "任务运行中,不能取消规划") - if self._plan_state.mode != "plan" or not self._plan_state.active_plan_id: + if ( + self._plan_state.mode != "plan" + and self._plan_state.phase != "uncertain" + ): raise PlanModeError("INVALID_MODE_TRANSITION", "当前不在 Plan Mode") + if ( + self._plan_state.active_plan_id is None + and self._plan_state.phase != "recovery_error" + ): + raise PlanModeError("INVALID_MODE_TRANSITION", "当前没有活动 Plan Episode") if plan_id is not None and plan_id != self._plan_state.active_plan_id: raise PlanModeError("INVALID_MODE_TRANSITION", "planId 与当前 Plan Episode 不匹配") active_id = self._plan_state.active_plan_id if self._question_future is not None and not self._question_future.done(): self._question_future.cancel() - self.session_manager.append_collaboration_mode_change("default", plan_id=active_id) - self._plan_state.mode = "default" - self._plan_state.phase = "cancelled" - self._plan_state.pending_question = None - self._refresh_collaboration_runtime() + self.session_manager.append_collaboration_mode_change( + "default", plan_id=active_id, reason="user", + ) + self._sync_plan_state() self._emit_event({ "type": "collaboration_mode_changed", "mode": "default", "phase": "cancelled", "plan_id": active_id, @@ -590,6 +671,8 @@ async def _request_plan_question( raise PlanModeError("INVALID_MODE_TRANSITION", "结构化问题只能在 Plan Mode 使用") if self._plan_state.pending_question is not None: raise PlanModeError("QUESTION_ALREADY_PENDING", "已有一个问题等待回答") + if self._plan_state.phase != "drafting": + raise PlanModeError("INVALID_MODE_TRANSITION", "结构化问题只能在 Plan drafting 状态使用") plan_id = self._plan_state.active_plan_id self.session_manager.append_plan_question( plan_id=plan_id, question_id=question.question_id, @@ -597,8 +680,8 @@ async def _request_plan_question( options=[asdict(option) for option in question.options], allow_custom=question.allow_custom, ) - self._plan_state.phase = "awaiting_answer" - self._plan_state.pending_question = question + self.session_manager.flush() + self._sync_plan_state() self._emit_event({ "type": "plan_question_requested", "plan_id": plan_id, "question": question.to_payload(), @@ -641,8 +724,8 @@ async def answer_plan_question(self, question_id: str, answer: str) -> PlanState answer_entry = self.session_manager.append_plan_question_answer( plan_id=plan_id, question_id=question_id, answer=answer, ) - self._plan_state.pending_question = None - self._plan_state.phase = "drafting" + self.session_manager.flush() + self._sync_plan_state() self._emit_event({ "type": "plan_question_answered", "plan_id": plan_id, "question_id": question_id, "answer": answer, @@ -665,10 +748,117 @@ async def answer_plan_question(self, question_id: str, answer: str) -> PlanState ) return self._plan_state + async def _submit_plan( + self, tool_call_id: str, title: str, markdown: str, + ) -> PlanRevision: + """Persist one exact submit_plan call as the next immutable revision.""" + if ( + self._plan_state.mode != "plan" + or self._plan_state.phase != "drafting" + or not self._plan_state.active_plan_id + ): + raise PlanModeError( + "INVALID_MODE_TRANSITION", + "submit_plan 只能在 Plan drafting 状态使用", + ) + source_message_id = self._validate_submit_source_message( + tool_call_id, + title=title, + markdown=markdown, + ) + revision_number = ( + self._plan_state.latest_revision.revision + 1 + if self._plan_state.latest_revision is not None else 1 + ) + revision = create_plan_revision( + plan_id=self._plan_state.active_plan_id, + revision=revision_number, + title=title, + markdown=markdown, + source_message_id=source_message_id, + submitted_by_tool_call_id=tool_call_id, + ) + self.session_manager.append_plan_revision( + plan_id=revision.plan_id, + revision=revision.revision, + title=revision.title, + markdown=revision.markdown, + digest=revision.digest, + source_message_id=revision.source_message_id, + schema_version=revision.schema_version, + submitted_by_tool_call_id=revision.submitted_by_tool_call_id, + origin_session_id=revision.origin_session_id, + ) + self.session_manager.flush() + self._sync_plan_state() + self._emit_event({"type": "plan_ready", "plan": revision.to_payload()}) + return revision + + def _validate_submit_source_message( + self, + tool_call_id: str, + *, + title: str, + markdown: str, + ) -> str: + """Return the persisted source only for an exact, exclusive call.""" + from agent_core.session.types import SessionMessageEntry + + branch = self.session_manager.get_branch() + if any( + getattr(entry, "submitted_by_tool_call_id", None) == tool_call_id + and getattr(entry, "origin_session_id", None) is None + for entry in branch + ): + raise PlanModeError("PLAN_SUBMIT_REUSED", "此 submit_plan 调用已经提交过计划") + for entry in reversed(branch): + if not isinstance(entry, SessionMessageEntry) or entry.message is None: + continue + if getattr(entry.message, "role", None) != "assistant": + continue + tool_calls = [ + block + for block in getattr(entry.message, "content", []) + if getattr(block, "type", None) == "toolCall" + ] + if not any( + getattr(block, "type", None) == "toolCall" + and getattr(block, "id", None) == tool_call_id + for block in tool_calls + ): + continue + if entry is not branch[-1]: + raise PlanModeError("PLAN_SOURCE_MISMATCH", "submit_plan 必须来自当前 assistant message") + if getattr(entry.message, "stop_reason", None) in {"aborted", "error", "length"}: + raise PlanModeError("PLAN_SUBMIT_INCOMPLETE", "中止或截断的回复不能提交计划") + if ( + len(tool_calls) != 1 + or getattr(tool_calls[0], "name", None) != "submit_plan" + ): + raise PlanModeError( + "PLAN_SUBMIT_NOT_EXCLUSIVE", + "submit_plan 必须是 assistant message 中唯一的工具调用", + ) + arguments = getattr(tool_calls[0], "arguments", None) + if ( + not isinstance(arguments, dict) + or arguments.get("title") != title + or arguments.get("markdown") != markdown + ): + raise PlanModeError( + "PLAN_SOURCE_MISMATCH", + "submit_plan 参数与已持久化 tool call 不匹配", + ) + return entry.id + raise PlanModeError( + "PLAN_SOURCE_MISSING", + "submit_plan 的 assistant message 尚未持久化", + ) + async def execute_plan( self, plan_id: str, revision: int, digest: str, run_id: str | None = None, ) -> PlanRevision: - if self._is_processing: + if self._is_processing or self._active_plan_run_id is not None: raise PlanModeError("RUN_IN_PROGRESS", "任务运行中,不能执行计划") latest = self._plan_state.latest_revision if self._plan_state.mode != "plan" or self._plan_state.phase != "ready" or latest is None: @@ -680,28 +870,37 @@ async def execute_plan( }) raise PlanModeError("STALE_PLAN_REVISION", "只能执行最新的 Plan revision") + run_id = run_id or uuid.uuid4().hex started_entry = self.session_manager.append_plan_run( plan_id=plan_id, revision=revision, digest=digest, status="started", run_id=run_id, ) - self._plan_state.mode = "default" - self._plan_state.phase = "executing" + try: + self.session_manager.flush() + except BaseException: + # A durable started entry may exist, but this runtime has not + # acquired the run. Replaying it must lock execution as uncertain. + self._sync_plan_state() + raise + self._active_plan_run_id = run_id self._plan_abort_requested = False - self._refresh_collaboration_runtime() + self._last_assistant_message = None + self._sync_plan_state() self._emit_event({ "type": "plan_execution_started", "plan": latest.to_payload(), "run_id": run_id, }) - await self._extract_plan_memory(latest, started_entry, completed=False) execution_prompt = ( "\n" - f"plan_id: {plan_id}\nrevision: {revision}\ndigest: {digest}\n\n" + f"plan_id: {plan_id}\nrevision: {revision}\ndigest: {digest}\ntitle: {latest.title}\n\n" f"{latest.markdown}\n" "\n" "Execute this exact confirmed plan now." ) try: - await self.prompt(execution_prompt) + await self._extract_accepted_plan_memory(latest, started_entry) + if not self._plan_abort_requested: + await self.prompt(execution_prompt, _plan_run_id=run_id) except asyncio.CancelledError: self._finish_plan_run(latest, "aborted", run_id=run_id) raise @@ -720,20 +919,109 @@ async def execute_plan( ), ) else: - completed_entry = self._finish_plan_run(latest, "completed", run_id=run_id) - await self._extract_plan_memory(latest, completed_entry, completed=True) + self._finish_plan_run(latest, "completed", run_id=run_id) return latest + def handoff_plan_to_new_session( + self, + plan_id: str, + revision: int, + digest: str, + *, + attach: bool = True, + ) -> SessionManager: + """Persist a clean child session containing only one confirmed plan. + + The child is made durable before the source records the handoff. A + failure between those writes therefore leaves the source ready and + preserves the child as an inspectable, unfinished handoff. + """ + if self._is_processing or self._active_plan_run_id is not None: + raise PlanModeError("RUN_IN_PROGRESS", "任务运行中,不能交接计划") + latest = self._plan_state.latest_revision + if ( + self._plan_state.mode != "plan" + or self._plan_state.phase != "ready" + or latest is None + ): + raise PlanModeError("PLAN_NOT_READY", "当前没有可交接的计划") + if ( + plan_id != latest.plan_id + or revision != latest.revision + or digest != latest.digest + ): + raise PlanModeError( + "STALE_PLAN_REVISION", + "只能交接最新的 Plan revision", + ) + + source = self.session_manager + target = SessionManager.create( + cwd=self.cwd, + agent_dir=source.agent_dir, + sessions_dir=source.sessions_dir, + in_memory=source.in_memory, + parent_session=source.header.id, + ) + target.append_collaboration_mode_change( + "plan", + plan_id=latest.plan_id, + reason="handoff", + related_session_id=source.header.id, + ) + target.append_plan_revision( + plan_id=latest.plan_id, + revision=latest.revision, + title=latest.title, + markdown=latest.markdown, + digest=latest.digest, + source_message_id=latest.source_message_id, + schema_version=latest.schema_version, + submitted_by_tool_call_id=latest.submitted_by_tool_call_id, + origin_session_id=source.header.id, + ) + target.flush() + + source.flush() + source.append_collaboration_mode_change( + "default", + plan_id=latest.plan_id, + reason="handoff", + related_session_id=target.header.id, + ) + self._sync_plan_state() + self._emit_event({ + "type": "plan_handoff_created", + "source_session_id": source.header.id, + "target_session_id": target.header.id, + "plan": latest.to_payload(), + }) + + if attach: + self._attach_session_manager(target) + return target + def _finish_plan_run( self, plan: PlanRevision, status: Literal["completed", "failed", "aborted"], *, run_id: str | None, error: str | None = None, ) -> Any: - entry = self.session_manager.append_plan_run( - plan_id=plan.plan_id, revision=plan.revision, digest=plan.digest, - status=status, run_id=run_id, error=error, + assistant_message_id = ( + self._latest_assistant_entry_id() if self._last_assistant_message is not None else None ) - self._plan_state.mode = "default" - self._plan_state.phase = status + stop_reason = getattr(self._last_assistant_message, "stop_reason", None) + try: + entry = self.session_manager.append_plan_run( + plan_id=plan.plan_id, revision=plan.revision, digest=plan.digest, + status=status, run_id=run_id, error=error, + assistant_message_id=assistant_message_id, + stop_reason=stop_reason, + ) + self.session_manager.flush() + finally: + # Even failed persistence ends live ownership. If the terminal + # append failed, the reducer now exposes the orphaned started run. + self._active_plan_run_id = None + self._sync_plan_state() event_suffix = {"completed": "completed", "failed": "failed", "aborted": "aborted"}[status] self._emit_event({ "type": f"plan_execution_{event_suffix}", "plan": plan.to_payload(), @@ -741,10 +1029,22 @@ def _finish_plan_run( }) return entry - async def _extract_plan_memory( - self, plan: PlanRevision, entry: Any, *, completed: bool, + def _latest_assistant_entry_id(self) -> str | None: + from agent_core.session.types import SessionMessageEntry + + for entry in reversed(self.session_manager.get_branch()): + if ( + isinstance(entry, SessionMessageEntry) + and entry.message is not None + and getattr(entry.message, "role", None) == "assistant" + ): + return entry.id + return None + + async def _extract_accepted_plan_memory( + self, plan: PlanRevision, entry: Any, ) -> None: - """Extract only from an explicitly accepted immutable Plan revision.""" + """Store the accepted decision, never an inferred completion claim.""" if self.memory_service is None or self.memory_identity is None: return from coding_agent.memory.types import CompletedTask, MemoryEvidence @@ -752,15 +1052,15 @@ async def _extract_plan_memory( evidence = MemoryEvidence( id=entry.id, text=plan.markdown, - source_kind="plan_completed" if completed else "accepted_plan", + source_kind="accepted_plan", timestamp=entry.timestamp, ) task = CompletedTask( identity=self.memory_identity, session_id=self.session_manager.header.id, evidence=[evidence], - final_response=_assistant_text(self._last_assistant_message) if completed else "", - mode="plan_completed" if completed else "plan_accepted", + final_response="", + mode="plan_accepted", success=True, ended_at=entry.timestamp, ) @@ -771,9 +1071,7 @@ async def _extract_plan_memory( self._emit_event({"type": "memory_extraction_failed", "error_code": "PLAN_MEMORY"}) def refresh_plan_state_from_branch(self) -> PlanState: - self._plan_state = reduce_plan_state(self.session_manager.get_branch()) - self._refresh_collaboration_runtime() - return self._plan_state + return self._sync_plan_state(emit=False) # ── Prompt ──────────────────────────────────────────────────────────── @@ -786,6 +1084,7 @@ async def prompt( _memory_query: str | None = None, _memory_evidence: list[Any] | None = None, _memory_mode: str | None = None, + _plan_run_id: str | None = None, ) -> None: """Send a user message and run the agent loop. @@ -797,6 +1096,16 @@ async def prompt( """ if self._is_processing: raise RuntimeError("Agent is already processing a prompt.") + if ( + self._active_plan_run_id is not None + and _plan_run_id != self._active_plan_run_id + ): + raise PlanModeError("RUN_IN_PROGRESS", "已有 Plan run 正在执行") + if self._plan_state.phase in {"uncertain", "recovery_error"}: + raise PlanModeError( + "PLAN_RECOVERY_REQUIRED", + "当前 Plan 状态需要先查看详情、重新规划或取消", + ) if isinstance(message, str) and message.lstrip().startswith(""): _retrieve_memory = False _extract_memory = False @@ -810,17 +1119,6 @@ async def prompt( "QUESTION_NOT_PENDING", "请先通过结构化问题控件回答当前 Plan 问题", ) - if self._plan_state.mode == "plan" and self._plan_state.phase == "ready": - # Ordinary user text is feedback, never execution authorization. - # The persisted user message reproduces this transition on resume. - self._plan_state.phase = "drafting" - self._emit_event({ - "type": "collaboration_mode_changed", - "mode": "plan", - "phase": "drafting", - "plan_id": self._plan_state.active_plan_id, - }) - branch_before = {entry.id for entry in self.session_manager.get_branch()} task_mode = _memory_mode or self._plan_state.mode query = _memory_query if _memory_query is not None else _message_text(message) @@ -1026,6 +1324,8 @@ def new_session(self) -> SessionManager: flushed before it is detached; subsequent messages can therefore never leak into the old JSONL file. """ + if self._is_processing or self._active_plan_run_id is not None: + raise PlanModeError("RUN_IN_PROGRESS", "任务运行中,不能新建会话") previous = self.session_manager if not previous.in_memory and previous.has_meaningful_activity(): previous.flush() @@ -1036,15 +1336,24 @@ def new_session(self) -> SessionManager: sessions_dir=previous.sessions_dir, in_memory=previous.in_memory, ) + self._attach_session_manager(new_manager) + return new_manager + + def _attach_session_manager(self, manager: SessionManager) -> None: + """Attach one manager and reset all session-local runtime state.""" self._agent.reset() - self._agent.attach_session(new_manager) - self.session_manager = new_manager + self._agent.clear_all_queues() + self._agent.attach_session(manager) + self.session_manager = manager + if hasattr(self, "_compaction_orchestrator"): + self._compaction_orchestrator.session_manager = manager self._last_assistant_message = None self._turn_index = 0 - self._plan_state = PlanState() self._question_future = None - self._refresh_collaboration_runtime() - return new_manager + self._question_signal_task = None + self._active_plan_run_id = None + self._plan_abort_requested = False + self._sync_plan_state() async def abort(self) -> None: """Abort the current agent run.""" @@ -1121,10 +1430,11 @@ async def run_bash(self, command: str, *, exclude_from_context: bool = False) -> """ if self._bash_tool is None: return {"error": "No bash tool available"} - if self._plan_state.mode == "plan" and not is_plan_safe_shell_command(command, self.cwd): + if self._plan_state.mode == "plan" or self._plan_state.phase == "uncertain": self._emit_event({ "type": "plan_policy_blocked", "code": "PLAN_POLICY_BLOCKED", - "tool_name": "bash", "reason": "PLAN_POLICY_BLOCKED: Plan Mode 禁止该命令", + "tool_name": "bash", + "reason": "PLAN_POLICY_BLOCKED: Plan Mode 禁止通用 Shell", }) return {"error": "PLAN_POLICY_BLOCKED", "code": "PLAN_POLICY_BLOCKED"} try: @@ -1267,43 +1577,6 @@ def _restore_persisted_context(self) -> None: None if context.thinking_level == "off" else context.thinking_level ) self.refresh_plan_state_from_branch() - self._recover_latest_complete_plan() - - def _recover_latest_complete_plan(self) -> None: - """Upgrade the latest bare plan in a resumed Plan episode to ready. - - Older clients persisted the assistant Markdown but silently skipped a - revision when the model omitted the control envelope. Only the newest - assistant response in the active episode is considered, so later user - feedback cannot accidentally revive an obsolete plan. - """ - if ( - self._plan_state.mode != "plan" - or self._plan_state.latest_revision is not None - or not self._plan_state.active_plan_id - ): - return - - from agent_core.session.types import ( - CollaborationModeChangeEntry, - SessionMessageEntry, - ) - - active_plan_id = self._plan_state.active_plan_id - for entry in reversed(self.session_manager.get_branch()): - if ( - isinstance(entry, CollaborationModeChangeEntry) - and entry.mode == "plan" - and entry.plan_id == active_plan_id - ): - break - if isinstance(entry, SessionMessageEntry) and entry.message is not None: - role = getattr(entry.message, "role", None) - if role == "user": - break - if role == "assistant": - self._capture_plan_revision(entry.message) - break # ── Internal: event forwarding ──────────────────────────────────────── @@ -1319,7 +1592,8 @@ async def _on_agent_event(self, event: AgentEvent, signal: asyncio.Event) -> Non msg = event.get("message") if msg is not None and getattr(msg, "role", None) == "assistant": self._last_assistant_message = msg - self._capture_plan_revision(msg) + if msg is not None and getattr(msg, "role", None) in {"user", "assistant"}: + self._sync_plan_state() # Emit compaction events for the UI. if etype == "message_end": @@ -1337,42 +1611,6 @@ async def _on_agent_event(self, event: AgentEvent, signal: asyncio.Event) -> Non # Forward to external listeners. self._emit_event(event) - def _capture_plan_revision(self, message: Any) -> None: - """Validate a completed assistant plan before publishing ``plan_ready``.""" - if self._plan_state.mode != "plan" or not self._plan_state.active_plan_id: - return - if getattr(message, "stop_reason", None) == "error": - return - text = _assistant_text(message) - prepared_reply = prepare_plan_reply(text) - if prepared_reply is None: - return - revision_number = ( - self._plan_state.latest_revision.revision + 1 - if self._plan_state.latest_revision is not None else 1 - ) - try: - revision = validate_proposed_plan( - prepared_reply, plan_id=self._plan_state.active_plan_id, - revision=revision_number, - source_message_id=str(getattr(message, "id", "")), - ) - except PlanModeError as exc: - self._emit_event({ - "type": "plan_validation_failed", "code": exc.code, - "message": str(exc), "plan_id": self._plan_state.active_plan_id, - }) - return - self.session_manager.append_plan_revision( - plan_id=revision.plan_id, revision=revision.revision, - title=revision.title, markdown=revision.markdown, - digest=revision.digest, source_message_id=revision.source_message_id, - ) - self._plan_state.phase = "ready" - self._plan_state.pending_question = None - self._plan_state.latest_revision = revision - self._emit_event({"type": "plan_ready", "plan": revision.to_payload()}) - def _emit_event(self, event: Any) -> None: """Emit an event to all registered listeners. @@ -1429,7 +1667,14 @@ def _create_stream_fn(self): def _stream(model: Model, context, options=None): effective_options: dict[str, Any] = dict(options or {}) effective_context = context - if self.web_search_enabled and model.provider == "deepseek": + search_allowed = ( + self._plan_state.mode == "default" + and self._plan_state.phase not in {"uncertain", "recovery_error"} + and any(tool.name == "web_search" for tool in (context.tools or [])) + ) + if not search_allowed: + effective_options.pop("web_search", None) + if self.web_search_enabled and model.provider == "deepseek" and search_allowed: # DeepSeek's Responses API owns web_search server-side. Keep # the application tool registered for UI/provider switching, # but do not expose a duplicate function tool to DeepSeek. diff --git a/packages/app/src/coding_agent/core/plan_mode.py b/packages/app/src/coding_agent/core/plan_mode.py index 694c159..7c70964 100644 --- a/packages/app/src/coding_agent/core/plan_mode.py +++ b/packages/app/src/coding_agent/core/plan_mode.py @@ -1,22 +1,19 @@ -"""Shared Plan Mode state, validation, control tool, and shell policy. +"""Plan Episode policy, immutable revisions, controls, and recovery reducer. -This module deliberately lives in the application package: both the CLI/TUI -and desktop runtime depend on it, while ``agent_core`` remains UI agnostic. +This module is the single application-layer seam for Plan Mode. Frontends +render PlanState; they never infer lifecycle transitions. """ from __future__ import annotations import hashlib import inspect -import os +import json import re -import shlex import uuid from dataclasses import asdict, dataclass -from pathlib import Path from typing import Any, Awaitable, Callable, Literal, cast -from agent_llm import TextContent -from agent_core import AgentToolResult, BeforeToolCallContext, BeforeToolCallResult +from agent_core import AgentToolResult, BeforeToolCallContext, BeforeToolCallResult, PlanAccess from agent_core.session.types import ( CollaborationModeChangeEntry, PlanQuestionAnswerEntry, @@ -26,14 +23,28 @@ SessionEntry, SessionMessageEntry, ) +from agent_llm import TextContent CollaborationMode = Literal["default", "plan"] PlanPhase = Literal[ - "idle", "drafting", "awaiting_answer", "ready", "executing", - "completed", "failed", "aborted", "cancelled", + "idle", + "drafting", + "awaiting_answer", + "ready", + "executing", + "settled", + "failed", + "aborted", + "cancelled", + "uncertain", + "recovery_error", ] QuestionBehavior = Literal["interactive", "deferred"] +PLAN_REVISION_SCHEMA_VERSION = 1 +MAX_PLAN_TITLE_CHARS = 200 +MAX_PLAN_MARKDOWN_BYTES = 64 * 1024 + class PlanModeError(RuntimeError): """Stable error-code exception shared by CLI and desktop adapters.""" @@ -75,6 +86,9 @@ class PlanRevision: markdown: str digest: str source_message_id: str + schema_version: int = PLAN_REVISION_SCHEMA_VERSION + submitted_by_tool_call_id: str = "" + origin_session_id: str | None = None def to_payload(self) -> dict[str, Any]: return { @@ -84,6 +98,51 @@ def to_payload(self) -> dict[str, Any]: "markdown": self.markdown, "digest": self.digest, "sourceMessageId": self.source_message_id, + "schemaVersion": self.schema_version, + "submittedByToolCallId": self.submitted_by_tool_call_id, + "originSessionId": self.origin_session_id, + } + + +@dataclass(frozen=True) +class PlanRun: + plan_id: str + revision: int + digest: str + status: Literal["started", "completed", "failed", "aborted"] + run_id: str | None + error: str | None + assistant_message_id: str | None + stop_reason: str | None + entry_id: str + timestamp: str + + def to_payload(self) -> dict[str, Any]: + return { + "planId": self.plan_id, + "revision": self.revision, + "digest": self.digest, + "status": self.status, + "runId": self.run_id, + "error": self.error, + "assistantMessageId": self.assistant_message_id, + "stopReason": self.stop_reason, + "entryId": self.entry_id, + "timestamp": self.timestamp, + } + + +@dataclass(frozen=True) +class PlanRecoveryError: + code: str + message: str + entry_id: str + + def to_payload(self) -> dict[str, str]: + return { + "code": self.code, + "message": self.message, + "entryId": self.entry_id, } @@ -94,9 +153,14 @@ class PlanState: active_plan_id: str | None = None latest_revision: PlanRevision | None = None pending_question: PlanQuestion | None = None + latest_run: PlanRun | None = None + recovery_error: PlanRecoveryError | None = None + handoff_target_session_id: str | None = None + legacy_candidate: bool = False def to_payload(self) -> dict[str, Any]: return { + "mode": self.mode, "phase": self.phase, "activePlanId": self.active_plan_id, "latestRevision": ( @@ -105,6 +169,12 @@ def to_payload(self) -> dict[str, Any]: "pendingQuestion": ( self.pending_question.to_payload() if self.pending_question else None ), + "latestRun": self.latest_run.to_payload() if self.latest_run else None, + "recoveryError": ( + self.recovery_error.to_payload() if self.recovery_error else None + ), + "handoffTargetSessionId": self.handoff_target_session_id, + "legacyCandidate": self.legacy_candidate, } @@ -114,176 +184,443 @@ def to_payload(self) -> dict[str, Any]: project context, and skill instructions. - Inspect the repository before asking questions. Never ask for facts that can - be discovered safely from the workspace. -- You may explore, explain, run policy-approved read-only checks, and draft a - decision-complete implementation specification. You must not edit files, - implement changes, or use unknown/mutating tools or shell commands. + be discovered safely with the available observation tools. +- Only tools explicitly classified as Plan observation or control tools are + available. Shell, write, edit, tests, builds, package managers, and unknown + tools are forbidden until the user confirms execution. - Ask at most one structured question at a time with request_user_input. Give 2-3 mutually exclusive options, put the recommended option first, and explain each option's impact. The UI automatically offers a custom answer. -- Do not present a final plan until interfaces, state transitions, failures, - compatibility, and tests are resolved. -- A valid final response must consist solely of one block with - no text outside it. Its Markdown must have exactly one H1 title and H2 - sections Summary/摘要, Implementation Changes/实现变更, Public - Interfaces/公开接口, Test Plan/测试计划, and Assumptions/假设. +- Do not submit a plan until interfaces, transitions, failures, compatibility, + and tests are decision complete. +- Submit the final plan with submit_plan(title, markdown). submit_plan must be + the only tool call in that assistant message. Do not wrap the Markdown in + tags; the host stores the exact submitted Markdown. - A request to implement or natural-language approval does not change modes. - Only the host application may confirm or cancel the exact latest revision. + Only the host may execute or cancel the exact latest Plan Revision. """.strip() -_PLAN_BLOCK_RE = re.compile(r"\A\s*\n([\s\S]*?)\n\s*\Z") -_REQUIRED_SECTIONS = ( - {"summary", "摘要"}, - {"implementation changes", "实现变更"}, - {"public interfaces", "公开接口"}, - {"test plan", "测试计划"}, - {"assumptions", "假设"}, -) +def normalize_plan_markdown(markdown: str) -> str: + """Normalize line endings only; never rewrite plan prose.""" + return markdown.replace("\r\n", "\n") + + +def compute_plan_digest( + *, plan_id: str, revision: int, title: str, markdown: str, + schema_version: int = PLAN_REVISION_SCHEMA_VERSION, +) -> str: + """Return the versioned authorization digest for a Plan Revision.""" + if schema_version == 0: + return hashlib.sha256(markdown.encode("utf-8")).hexdigest() + if schema_version != PLAN_REVISION_SCHEMA_VERSION: + raise PlanModeError( + "UNSUPPORTED_PLAN_SCHEMA", + f"不支持 Plan revision schema v{schema_version}", + ) + encoded = json.dumps( + { + "schemaVersion": schema_version, + "planId": plan_id, + "revision": revision, + "title": title, + "markdown": markdown, + }, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() -def _section_heading_tokens(heading: str) -> set[str]: - """Return aliases from an English, Chinese, or bilingual H2 heading.""" - normalized = heading.strip().casefold() - return { - token.strip() - for token in re.split(r"[//]", normalized) - if token.strip() - } | {normalized} +def create_plan_revision( + *, plan_id: str, revision: int, title: str, markdown: str, + source_message_id: str, submitted_by_tool_call_id: str, + origin_session_id: str | None = None, +) -> PlanRevision: + """Validate exact submit_plan arguments and construct schema-v1 revision.""" + if not plan_id or revision < 1: + raise PlanModeError("INVALID_PLAN_SPEC", "planId 和 revision 无效") + if not title.strip() or "\n" in title or "\r" in title: + raise PlanModeError("INVALID_PLAN_SPEC", "title 必须是非空单行文本") + if len(title) > MAX_PLAN_TITLE_CHARS: + raise PlanModeError("INVALID_PLAN_SPEC", "title 不能超过 200 个字符") + if any(ord(char) < 32 for char in title): + raise PlanModeError("INVALID_PLAN_SPEC", "title 包含不允许的控制字符") + normalized = normalize_plan_markdown(markdown) + if not normalized.strip(): + raise PlanModeError("INVALID_PLAN_SPEC", "markdown 不能为空") + if len(normalized.encode("utf-8")) > MAX_PLAN_MARKDOWN_BYTES: + raise PlanModeError("INVALID_PLAN_SPEC", "markdown 不能超过 64 KiB") + if any(ord(char) < 32 and char not in {"\n", "\r", "\t"} for char in normalized): + raise PlanModeError("INVALID_PLAN_SPEC", "markdown 包含不允许的控制字符") + digest = compute_plan_digest( + plan_id=plan_id, + revision=revision, + title=title, + markdown=normalized, + ) + return PlanRevision( + plan_id=plan_id, + revision=revision, + title=title, + markdown=normalized, + digest=digest, + source_message_id=source_message_id, + submitted_by_tool_call_id=submitted_by_tool_call_id, + origin_session_id=origin_session_id, + ) -def _markdown_section_tokens(markdown: str) -> set[str]: - tokens: set[str] = set() - for heading in re.findall(r"(?m)^##\s+(.+?)\s*$", markdown): - tokens.update(_section_heading_tokens(heading)) - return tokens +_PLAN_BLOCK_RE = re.compile(r"\A\s*\n([\s\S]*?)\n\s*\Z") -def prepare_plan_reply(text: str) -> str | None: - """Normalize a recognizably complete bare Markdown plan for validation. +def validate_proposed_plan( + text: str, *, plan_id: str, revision: int, source_message_id: str, +) -> PlanRevision: + """Compatibility validator for callers holding a legacy plan envelope. - Models occasionally omit the control envelope or use a sequence of - ``## 变更 N`` headings instead of the implementation umbrella heading. - The host accepts that narrow shape so a complete plan can reach ``ready``; - ordinary Markdown discussion remains untouched and non-executable. + Live Plan Mode never calls this function; only submit_plan can persist a + new revision. """ - normalized = text.replace("\r\n", "\n").replace("\r", "\n").strip() - if "" in normalized or "" in normalized: - return normalized - - h1_match = re.search(r"(?m)^#\s+.+?\s*$", normalized) - if h1_match is None: - return None - markdown = normalized[h1_match.start():].strip() - tokens = _markdown_section_tokens(markdown) - non_implementation_sections = ( - _REQUIRED_SECTIONS[0], - _REQUIRED_SECTIONS[2], - _REQUIRED_SECTIONS[3], - _REQUIRED_SECTIONS[4], + normalized = normalize_plan_markdown(text) + match = _PLAN_BLOCK_RE.fullmatch(normalized) + if match is None: + raise PlanModeError( + "INVALID_PLAN_SPEC", "回复必须只包含一个 块", + ) + markdown = match.group(1) + h1 = re.findall(r"(?m)^#\s+(.+?)\s*$", markdown) + if len(h1) != 1: + raise PlanModeError("INVALID_PLAN_SPEC", "legacy 计划必须包含一个一级标题") + return create_plan_revision( + plan_id=plan_id, + revision=revision, + title=h1[0].strip(), + markdown=markdown, + source_message_id=source_message_id, + submitted_by_tool_call_id="legacy-validator", ) - if any(tokens.isdisjoint(aliases) for aliases in non_implementation_sections): - return None - # Strip a trailing conversational execution question. It is not part of - # the immutable specification and cannot authorize execution. - separator = markdown.rfind("\n---\n") - if separator >= 0: - trailing = markdown[separator + 5:].strip() - if ( - trailing.endswith(("?", "?")) - and re.search(r"执行|开工|开始|implement|proceed", trailing, re.IGNORECASE) - ): - markdown = markdown[:separator].rstrip() - - tokens = _markdown_section_tokens(markdown) - if tokens.isdisjoint(_REQUIRED_SECTIONS[1]): - headings = list(re.finditer(r"(?m)^##\s+(.+?)\s*$", markdown)) - summary_index = next( - (index for index, match in enumerate(headings) - if not _section_heading_tokens(match.group(1)).isdisjoint(_REQUIRED_SECTIONS[0])), - None, - ) - public_index = next( - (index for index, match in enumerate(headings) - if not _section_heading_tokens(match.group(1)).isdisjoint(_REQUIRED_SECTIONS[2])), - None, - ) - if ( - summary_index is not None - and public_index is not None - and public_index > summary_index + 1 - ): - insert_at = headings[summary_index + 1].start() - markdown = ( - markdown[:insert_at] - + "## Implementation Changes / 实现变更\n\n" - + markdown[insert_at:] - ) - return f"\n{markdown}\n" +def prepare_plan_reply(text: str) -> str | None: + """Detect only an existing legacy envelope; never synthesize one.""" + normalized = normalize_plan_markdown(text) + return normalized if _PLAN_BLOCK_RE.fullmatch(normalized) else None + + +def _message_text(message: Any) -> str: + content = getattr(message, "content", "") + if isinstance(content, str): + return content + return "\n".join( + str(getattr(block, "text", "")) + for block in content or [] + if getattr(block, "type", None) == "text" + ) -def normalize_plan_markdown(markdown: str) -> str: - return markdown.replace("\r\n", "\n").replace("\r", "\n").strip() +def _recovery( + state: PlanState, entry: SessionEntry, code: str, message: str, +) -> None: + state.mode = "plan" + state.phase = "recovery_error" + state.pending_question = None + state.recovery_error = PlanRecoveryError(code, message, entry.id) -def validate_proposed_plan( - text: str, *, plan_id: str, revision: int, source_message_id: str, -) -> PlanRevision: - """Validate the strict envelope and return an immutable revision.""" - normalized_reply = text.replace("\r\n", "\n").replace("\r", "\n") - if normalized_reply.count("") != 1 or normalized_reply.count("") != 1: - raise PlanModeError("INVALID_PLAN_SPEC", "回复必须只包含一个 块") - match = _PLAN_BLOCK_RE.fullmatch(normalized_reply) - if match is None: - raise PlanModeError("INVALID_PLAN_SPEC", "计划块外不能包含正文") - markdown = normalize_plan_markdown(match.group(1)) - h1 = re.findall(r"(?m)^#\s+(.+?)\s*$", markdown) - if len(h1) != 1: - raise PlanModeError("INVALID_PLAN_SPEC", "计划必须包含且只包含一个一级标题") - headings = _markdown_section_tokens(markdown) - missing = ["/".join(sorted(aliases)) for aliases in _REQUIRED_SECTIONS if headings.isdisjoint(aliases)] - if missing: - raise PlanModeError("INVALID_PLAN_SPEC", f"计划缺少章节:{', '.join(missing)}") - digest = hashlib.sha256(markdown.encode("utf-8")).hexdigest() +def _revision_from_entry(entry: PlanRevisionEntry) -> PlanRevision: return PlanRevision( - plan_id=plan_id, revision=revision, title=h1[0].strip(), - markdown=markdown, digest=digest, source_message_id=source_message_id, + plan_id=entry.plan_id, + revision=entry.revision, + title=entry.title, + markdown=entry.markdown, + digest=entry.digest, + source_message_id=entry.source_message_id, + schema_version=entry.schema_version, + submitted_by_tool_call_id=entry.submitted_by_tool_call_id, + origin_session_id=entry.origin_session_id, ) -def reduce_plan_state(entries: list[SessionEntry]) -> PlanState: - """Fold Plan state from the active JSONL branch.""" - state = PlanState() +def _run_from_entry(entry: PlanRunEntry) -> PlanRun: + return PlanRun( + plan_id=entry.plan_id, + revision=entry.revision, + digest=entry.digest, + status=entry.status, + run_id=entry.run_id, + error=entry.error, + assistant_message_id=entry.assistant_message_id, + stop_reason=entry.stop_reason, + entry_id=entry.id, + timestamp=entry.timestamp, + ) + + +@dataclass(frozen=True) +class _ReplayIssue: + line: int + code: str + message: str + + +def _replay_items( + entries: list[SessionEntry], + load_issues: list[dict[str, Any]] | None, +) -> list[SessionEntry | _ReplayIssue]: + """Merge storage diagnostics back into their original JSONL order.""" + issues = sorted( + ( + _ReplayIssue( + line=max(1, int(raw.get("line", 1))), + code=str(raw.get("code") or "CORRUPT_SESSION_ENTRY"), + message=str(raw.get("message") or "Session JSONL entry is corrupt"), + ) + for raw in (load_issues or []) + if isinstance(raw, dict) + ), + key=lambda issue: issue.line, + ) + merged: list[SessionEntry | _ReplayIssue] = [] + issue_index = 0 for entry in entries: - if isinstance(entry, SessionMessageEntry): + source_line = getattr(entry, "_source_line", None) + if source_line is None: + merged.extend(issues[issue_index:]) + issue_index = len(issues) + else: + while issue_index < len(issues) and issues[issue_index].line <= source_line: + merged.append(issues[issue_index]) + issue_index += 1 + merged.append(entry) + merged.extend(issues[issue_index:]) + return merged + + +def _valid_replay_token(value: Any, *, max_chars: int = 200) -> bool: + return ( + isinstance(value, str) + and bool(value) + and len(value) <= max_chars + and not any(ord(char) < 32 for char in value) + ) + + +def reduce_plan_state( + entries: list[SessionEntry], *, live_run_id: str | None = None, + parent_session_id: str | None = None, + load_issues: list[dict[str, Any]] | None = None, +) -> PlanState: + """Validate and fold Plan state from one active JSONL branch. + + A persisted started entry is executing only while this Runtime owns its + run id. On resume it is uncertain and never retried automatically. + """ + state = PlanState() + entry_ids = [entry.id for entry in entries] + if len(entry_ids) != len(set(entry_ids)): + state.mode = "plan" + state.phase = "recovery_error" + state.recovery_error = PlanRecoveryError( + "DUPLICATE_ENTRY_ID", + "Session JSONL 包含重复 entry ID", + "session-log", + ) + return state + messages_by_id: dict[str, SessionMessageEntry] = {} + submitted_tool_call_ids: set[str] = set() + seen_plan_ids: set[str] = set() + seen_run_ids: set[str] = set() + seen_question_ids: set[tuple[str, str]] = set() + handoff_origin_session_id: str | None = None + previous_by_id: dict[str, SessionEntry | None] = { + entry.id: entries[index - 1] if index else None + for index, entry in enumerate(entries) + } + for replay_item in _replay_items(entries, load_issues): + if isinstance(replay_item, _ReplayIssue): + state.mode = "plan" + state.phase = "recovery_error" + state.pending_question = None + state.recovery_error = PlanRecoveryError( + replay_item.code, + f"Session JSONL 第 {replay_item.line} 行损坏:{replay_item.message}", + f"line:{replay_item.line}", + ) + continue + entry = replay_item + if state.recovery_error is not None: if ( - state.mode == "plan" - and state.phase == "ready" - and getattr(entry.message, "role", None) == "user" + isinstance(entry, CollaborationModeChangeEntry) + and entry.mode == "default" + and entry.reason in {None, "user"} + and ( + (state.active_plan_id is None and entry.plan_id is None) + or ( + state.active_plan_id is not None + and entry.plan_id == state.active_plan_id + ) + ) ): - # Feedback after a ready revision starts another drafting pass. - # Retain the immutable revision for history/revision numbering, - # but it is not executable while the episode is drafting. + state.mode = "default" + state.phase = "cancelled" if state.active_plan_id else "idle" + state.pending_question = None + state.recovery_error = None + continue + + if isinstance(entry, SessionMessageEntry): + messages_by_id[entry.id] = entry + role = getattr(entry.message, "role", None) + if state.mode == "plan" and state.phase == "ready" and role == "user": state.phase = "drafting" - elif isinstance(entry, CollaborationModeChangeEntry): - state.mode = cast(CollaborationMode, entry.mode) + state.legacy_candidate = False + elif state.mode == "plan" and state.phase == "drafting": + if role == "user": + state.legacy_candidate = False + elif role == "assistant": + text = _message_text(entry.message) + state.legacy_candidate = ( + text.count("") == 1 + and text.count("") == 1 + ) + continue + + if isinstance(entry, CollaborationModeChangeEntry): + if entry.reason not in {None, "user", "handoff"}: + _recovery( + state, entry, "INVALID_MODE_TRANSITION", + "Collaboration mode change reason 无效", + ) + continue if entry.mode == "plan": - state.phase = "drafting" - state.active_plan_id = entry.plan_id - state.latest_revision = None - state.pending_question = None + if not _valid_replay_token(entry.plan_id): + _recovery( + state, entry, "INVALID_PLAN_ID", + "Plan entry 缺少 planId", + ) + continue + if entry.plan_id in seen_plan_ids: + _recovery( + state, entry, "DUPLICATE_PLAN_ID", + "同一 session 不能复用 planId", + ) + continue + if state.phase == "executing": + _recovery( + state, entry, "INVALID_MODE_TRANSITION", + "Plan run 执行中不能开始新 Episode", + ) + continue + if state.mode == "plan": + _recovery( + state, entry, "INVALID_MODE_TRANSITION", + "活动 Plan Episode 中不能直接开始另一个 Plan", + ) + continue + if entry.reason == "handoff": + if ( + not _valid_replay_token(entry.related_session_id) + or entry.related_session_id != parent_session_id + ): + _recovery( + state, entry, "INVALID_HANDOFF", + "Plan handoff 来源与 parentSession 不匹配", + ) + continue + handoff_origin_session_id = entry.related_session_id + else: + if entry.related_session_id is not None: + _recovery( + state, entry, "INVALID_HANDOFF", + "非 handoff Plan entry 包含来源 session ID", + ) + continue + handoff_origin_session_id = None + seen_plan_ids.add(entry.plan_id) + state = PlanState( + mode="plan", + phase="drafting", + active_plan_id=entry.plan_id, + ) else: + valid_exit_state = ( + state.mode == "plan" + and state.phase in {"drafting", "awaiting_answer", "ready"} + ) or state.phase == "uncertain" + if not valid_exit_state: + _recovery( + state, entry, "INVALID_MODE_TRANSITION", + "当前状态不接受退出 Plan entry", + ) + continue + if entry.plan_id != state.active_plan_id: + _recovery( + state, entry, "INVALID_MODE_TRANSITION", + "退出 Plan 的 planId 与活动 Episode 不匹配", + ) + continue + if entry.reason == "handoff": + if ( + state.phase != "ready" + or not _valid_replay_token(entry.related_session_id) + ): + _recovery( + state, entry, "INVALID_HANDOFF", + "只有 ready Plan 可以完成 handoff", + ) + continue + elif entry.related_session_id is not None: + _recovery( + state, entry, "INVALID_HANDOFF", + "非 handoff 退出 entry 包含目标 session ID", + ) + continue + state.mode = "default" state.phase = "cancelled" if state.active_plan_id else "idle" state.pending_question = None - elif isinstance(entry, PlanQuestionEntry): - state.mode = "plan" + state.handoff_target_session_id = ( + entry.related_session_id if entry.reason == "handoff" else None + ) + continue + + if isinstance(entry, PlanQuestionEntry): + if ( + state.mode != "plan" + or state.phase != "drafting" + or entry.plan_id != state.active_plan_id + or state.pending_question is not None + or (entry.plan_id, entry.question_id) in seen_question_ids + ): + _recovery( + state, entry, "INVALID_PLAN_QUESTION", + "Plan Question 状态顺序无效", + ) + continue + if ( + not entry.question_id + or not entry.question + or not entry.header.strip() + or len(entry.header) > 12 + or not 2 <= len(entry.options) <= 3 + or any( + not isinstance(option, dict) + or not str(option.get("label", "")).strip() + or not str(option.get("description", "")).strip() + for option in entry.options + ) + ): + _recovery( + state, entry, "INVALID_PLAN_QUESTION", + "Plan Question 内容无效", + ) + continue state.phase = "awaiting_answer" - state.active_plan_id = entry.plan_id + seen_question_ids.add((entry.plan_id, entry.question_id)) state.pending_question = PlanQuestion( - question_id=entry.question_id, header=entry.header, + question_id=entry.question_id, + header=entry.header, question=entry.question, options=tuple( PlanQuestionOption( @@ -294,30 +631,217 @@ def reduce_plan_state(entries: list[SessionEntry]) -> PlanState: ), allow_custom=entry.allow_custom, ) - elif isinstance(entry, PlanQuestionAnswerEntry): - if state.pending_question and state.pending_question.question_id == entry.question_id: - state.pending_question = None - state.mode = "plan" - state.phase = "drafting" - state.active_plan_id = entry.plan_id - elif isinstance(entry, PlanRevisionEntry): - state.mode = "plan" - state.phase = "ready" - state.active_plan_id = entry.plan_id + continue + + if isinstance(entry, PlanQuestionAnswerEntry): + if ( + state.mode != "plan" + or state.phase != "awaiting_answer" + or state.pending_question is None + or entry.plan_id != state.active_plan_id + or entry.question_id != state.pending_question.question_id + or not entry.answer.strip() + ): + _recovery( + state, entry, "INVALID_PLAN_ANSWER", + "Plan Question Answer 状态顺序无效", + ) + continue state.pending_question = None - state.latest_revision = PlanRevision( - plan_id=entry.plan_id, revision=entry.revision, title=entry.title, - markdown=entry.markdown, digest=entry.digest, - source_message_id=entry.source_message_id, + state.phase = "drafting" + continue + + if isinstance(entry, PlanRevisionEntry): + if ( + state.mode != "plan" + or state.phase != "drafting" + or entry.plan_id != state.active_plan_id + ): + _recovery( + state, entry, "INVALID_PLAN_REVISION", + "Plan Revision 状态顺序无效", + ) + continue + expected_revision = ( + state.latest_revision.revision + 1 if state.latest_revision else 1 ) - elif isinstance(entry, PlanRunEntry): - state.mode = "default" - state.active_plan_id = entry.plan_id + is_handoff_copy = ( + state.latest_revision is None + and bool(entry.origin_session_id) + and entry.origin_session_id == handoff_origin_session_id + ) + if ( + entry.revision < 1 + or (entry.revision != expected_revision and not is_handoff_copy) + ): + _recovery( + state, entry, "INVALID_PLAN_REVISION", + "Plan revision 必须单调递增", + ) + continue + if entry.schema_version == PLAN_REVISION_SCHEMA_VERSION: + if not _valid_replay_token(entry.submitted_by_tool_call_id): + _recovery( + state, entry, "INVALID_PLAN_REVISION_SOURCE", + "v1 Plan Revision 缺少 submit_plan tool call ID", + ) + continue + if entry.submitted_by_tool_call_id in submitted_tool_call_ids: + _recovery( + state, entry, "INVALID_PLAN_REVISION_SOURCE", + "submit_plan tool call 不能重复生成 revision", + ) + continue + if entry.origin_session_id is not None: + if not is_handoff_copy: + _recovery( + state, entry, "INVALID_HANDOFF", + "handoff revision 来源与 Plan entry 不匹配", + ) + continue + else: + source = messages_by_id.get(entry.source_message_id) + previous = previous_by_id.get(entry.id) + tool_calls = [ + block + for block in getattr(source.message, "content", []) + if getattr(block, "type", None) == "toolCall" + ] if source is not None else [] + call = tool_calls[0] if len(tool_calls) == 1 else None + arguments = getattr(call, "arguments", None) + if ( + source is None + or previous is not source + or getattr(source.message, "role", None) != "assistant" + or getattr(source.message, "stop_reason", None) in {"aborted", "error", "length"} + or call is None + or getattr(call, "name", None) != "submit_plan" + or getattr(call, "id", None) + != entry.submitted_by_tool_call_id + or not isinstance(arguments, dict) + or arguments.get("title") != entry.title + or not isinstance(arguments.get("markdown"), str) + or normalize_plan_markdown(arguments["markdown"]) + != entry.markdown + ): + _recovery( + state, entry, "INVALID_PLAN_REVISION_SOURCE", + "v1 Plan Revision 不对应独占 submit_plan 调用", + ) + continue + try: + if entry.schema_version == PLAN_REVISION_SCHEMA_VERSION: + validated = create_plan_revision( + plan_id=entry.plan_id, + revision=entry.revision, + title=entry.title, + markdown=entry.markdown, + source_message_id=entry.source_message_id, + submitted_by_tool_call_id=entry.submitted_by_tool_call_id, + origin_session_id=entry.origin_session_id, + ) + if validated.markdown != entry.markdown: + raise PlanModeError( + "INVALID_PLAN_SPEC", + "v1 Plan markdown 未按 CRLF 到 LF 规范化", + ) + expected_digest = validated.digest + else: + expected_digest = compute_plan_digest( + plan_id=entry.plan_id, + revision=entry.revision, + title=entry.title, + markdown=entry.markdown, + schema_version=entry.schema_version, + ) + except PlanModeError as exc: + _recovery(state, entry, exc.code, str(exc)) + continue + if entry.digest != expected_digest: + _recovery( + state, entry, "PLAN_DIGEST_MISMATCH", + "Plan Revision digest 校验失败", + ) + continue + state.latest_revision = _revision_from_entry(entry) + if ( + entry.schema_version == PLAN_REVISION_SCHEMA_VERSION + and entry.origin_session_id is None + ): + submitted_tool_call_ids.add(entry.submitted_by_tool_call_id) state.pending_question = None + state.phase = "ready" + state.legacy_candidate = False + continue + + if isinstance(entry, PlanRunEntry): + if entry.status not in {"started", "completed", "failed", "aborted"}: + _recovery(state, entry, "INVALID_PLAN_RUN", "Plan run status 无效") + continue + run = _run_from_entry(entry) + if entry.status == "started": + latest = state.latest_revision + if ( + state.mode != "plan" + or state.phase != "ready" + or latest is None + or (entry.plan_id, entry.revision, entry.digest) + != (latest.plan_id, latest.revision, latest.digest) + or ( + latest.schema_version >= PLAN_REVISION_SCHEMA_VERSION + and not _valid_replay_token(entry.run_id) + ) + or (entry.run_id is not None and entry.run_id in seen_run_ids) + ): + _recovery( + state, entry, "INVALID_PLAN_RUN", + "Plan Run 未确认最新 revision", + ) + continue + if entry.run_id is not None: + seen_run_ids.add(entry.run_id) + state.mode = "default" + state.pending_question = None + state.latest_run = run + state.phase = ( + "executing" + if live_run_id is not None and entry.run_id == live_run_id + else "uncertain" + ) + continue + + previous = state.latest_run + if ( + state.phase not in {"executing", "uncertain"} + or state.latest_revision is None + or ( + state.latest_revision.schema_version + >= PLAN_REVISION_SCHEMA_VERSION + and not _valid_replay_token(entry.run_id) + ) + or previous is None + or previous.status != "started" + or (entry.plan_id, entry.revision, entry.digest, entry.run_id) + != ( + previous.plan_id, + previous.revision, + previous.digest, + previous.run_id, + ) + ): + _recovery( + state, entry, "INVALID_PLAN_RUN", + "Plan Run 终态缺少匹配的 started", + ) + continue + state.mode = "default" + state.latest_run = run state.phase = cast(PlanPhase, { - "started": "executing", "completed": "completed", - "failed": "failed", "aborted": "aborted", + "completed": "settled", + "failed": "failed", + "aborted": "aborted", }[entry.status]) + return state @@ -330,25 +854,32 @@ def new_question_id() -> str: class RequestUserInputTool: - """Control tool used only in Plan Mode.""" + """Plan control tool for one structured product decision.""" name = "request_user_input" label = "request user input" effect = "control" + plan_access: PlanAccess = "control" execution_mode = "sequential" - description = "Ask exactly one structured question and wait or defer for its answer." + description = ( + "Ask exactly one structured question and wait or defer for its answer." + ) parameters = { "type": "object", "properties": { "questions": { - "type": "array", "minItems": 1, "maxItems": 1, + "type": "array", + "minItems": 1, + "maxItems": 1, "items": { "type": "object", "properties": { "header": {"type": "string", "maxLength": 12}, "question": {"type": "string"}, "options": { - "type": "array", "minItems": 2, "maxItems": 3, + "type": "array", + "minItems": 2, + "maxItems": 3, "items": { "type": "object", "properties": { @@ -367,20 +898,27 @@ class RequestUserInputTool: } def __init__( - self, callback: Callable[[PlanQuestion, Any], Awaitable[str | None]], - *, deferred: bool, + self, + callback: Callable[[PlanQuestion, Any], Awaitable[str | None]], + *, + deferred: bool, ) -> None: self._callback = callback self._deferred = deferred async def execute( - self, tool_call_id: str, params: dict, signal: Any = None, + self, + tool_call_id: str, + params: dict, + signal: Any = None, on_update: Any = None, ) -> AgentToolResult: del tool_call_id, on_update questions = params.get("questions") if not isinstance(questions, list) or len(questions) != 1: - raise PlanModeError("INVALID_PLAN_QUESTION", "一次必须且只能提交一个问题") + raise PlanModeError( + "INVALID_PLAN_QUESTION", "一次必须且只能提交一个问题", + ) raw = questions[0] if not isinstance(raw, dict): raise PlanModeError("INVALID_PLAN_QUESTION", "问题格式无效") @@ -388,9 +926,14 @@ async def execute( question = str(raw.get("question", "")).strip() options_raw = raw.get("options") if not header or len(header) > 12 or not question: - raise PlanModeError("INVALID_PLAN_QUESTION", "header 必须为 1-12 个字符且 question 不能为空") + raise PlanModeError( + "INVALID_PLAN_QUESTION", + "header 必须为 1-12 个字符且 question 不能为空", + ) if not isinstance(options_raw, list) or not 2 <= len(options_raw) <= 3: - raise PlanModeError("INVALID_PLAN_QUESTION", "问题必须包含 2-3 个互斥选项") + raise PlanModeError( + "INVALID_PLAN_QUESTION", "问题必须包含 2-3 个互斥选项", + ) options: list[PlanQuestionOption] = [] for option in options_raw: if not isinstance(option, dict): @@ -398,159 +941,167 @@ async def execute( label = str(option.get("label", "")).strip() description = str(option.get("description", "")).strip() if not label or not description: - raise PlanModeError("INVALID_PLAN_QUESTION", "选项标签和影响说明不能为空") - options.append(PlanQuestionOption(label=label, description=description)) + raise PlanModeError( + "INVALID_PLAN_QUESTION", "选项标签和影响说明不能为空", + ) + options.append( + PlanQuestionOption(label=label, description=description), + ) plan_question = PlanQuestion( - question_id=new_question_id(), header=header, question=question, - options=tuple(options), allow_custom=True, + question_id=new_question_id(), + header=header, + question=question, + options=tuple(options), + allow_custom=True, ) answer = await self._callback(plan_question, signal) if answer is None: return AgentToolResult( - content=[TextContent(text="Question saved. Resume the session to answer it.")], - terminate=self._deferred, + content=[ + TextContent( + text="Question saved. Resume the session to answer it.", + ), + ], + terminate=True, ) - return AgentToolResult(content=[TextContent(text=f"User answer: {answer}")]) - - -_CONTROL_TOKENS = (";", "||", "`", "$(", ">", "<", "\n", "\r") -_SHELL_SPLIT_RE = re.compile(r"\s*(?:\|\||&&|\|)\s*") -_SAFE_FD_REDIRECTION_RE = re.compile(r"(?&1|1>&2)(?=\s|$)") -_READ_COMMANDS = { - "pwd", "ls", "dir", "cat", "head", "tail", "wc", "sort", "uniq", - "cut", "grep", "rg", "fd", "find", "where", "which", "type", - "get-content", "select-string", "get-childitem", "git", -} -_READ_ONLY_GIT = { - "status", "diff", "show", "log", "branch", "rev-parse", "ls-files", - "ls-tree", "cat-file", "grep", "blame", "remote", "tag", "describe", -} -_VALIDATION_EXECUTABLES = {"pytest", "ruff", "pyright", "mypy"} -_SAFE_PACKAGE_SCRIPTS = {"test", "typecheck", "lint", "check", "build"} -_DANGEROUS_FIND_FLAGS = {"-delete", "-exec", "-execdir", "-ok", "-okdir"} - - -def is_plan_safe_shell_command(command: str, cwd: str) -> bool: - """Conservatively allow reads and recognized validation/build commands.""" - command = command.strip() - # Merging stderr/stdout does not write to disk. Models commonly append - # ``2>&1`` to read-only Git commands; strip only these exact FD-to-FD forms - # before rejecting real redirects such as ``> output.txt`` or ``2>file``. - policy_command = _SAFE_FD_REDIRECTION_RE.sub("", command) - if not policy_command or any(token in policy_command for token in _CONTROL_TOKENS): - return False - if re.search(r"(?:^|\s)\.\.(?:[\\/]|(?:\s|$))", policy_command): - return False - segments = _SHELL_SPLIT_RE.split(policy_command) - if not segments or any(not segment.strip() for segment in segments): - return False - for segment in segments: - try: - tokens = shlex.split(segment, posix=True) - except ValueError: - return False - if not tokens or not _is_safe_segment(tokens, cwd): - return False - return True - - -def _is_safe_segment(tokens: list[str], cwd: str) -> bool: - executable = tokens[0].replace("\\", "/").rsplit("/", 1)[-1].casefold() - if executable.endswith(".exe"): - executable = executable[:-4] - args = tokens[1:] - if executable == "cd": - return len(args) == 1 and _path_stays_in_workspace(args[0], cwd) - if executable == "git": - positional = [item for item in args if not item.startswith("-")] - if not positional or positional[0].casefold() not in _READ_ONLY_GIT: - return False - if positional[0].casefold() == "branch" and len(positional) > 1: - return False - if any( - item in {"-c", "-o", "--paginate"} - or item.startswith(("--output", "--exec-path", "--open-files-in-pager")) - for item in args - ): - return False - elif executable in _READ_COMMANDS: - if executable == "find" and any(item.casefold() in _DANGEROUS_FIND_FLAGS for item in args): - return False - if executable == "rg" and any(item == "--pre" or item.startswith("--pre=") for item in args): - return False - if executable == "sort" and any(item == "-o" or item.startswith("--output") for item in args): - return False - if executable == "uniq" and len([item for item in args if not item.startswith("-")]) > 1: - return False - elif executable in _VALIDATION_EXECUTABLES: - pass - elif executable in {"pnpm", "npm", "yarn"}: - lowered = {item.casefold() for item in args if not item.startswith("-")} - if lowered.isdisjoint(_SAFE_PACKAGE_SCRIPTS) or lowered & {"add", "install", "exec", "publish"}: - return False - elif executable == "uv": - if not _is_safe_uv(args): - return False - else: - return False - return all( - _path_stays_in_workspace(token, cwd) - for token in args - if _looks_like_path(token) - ) - + return AgentToolResult( + content=[TextContent(text=f"User answer: {answer}")], + ) -def _is_safe_uv(args: list[str]) -> bool: - lowered = [item.casefold() for item in args if not item.startswith("-")] - if not lowered: - return False - if lowered[0] == "build": - return True - if lowered[0] != "run" or len(lowered) < 2: - return False - if lowered[1] in _VALIDATION_EXECUTABLES: - return True - return lowered[1:3] == ["python", "scripts/check_versions.py"] +class SubmitPlanTool: + """The sole live path that can persist a schema-v1 Plan Revision.""" -def _looks_like_path(token: str) -> bool: - if token.startswith("-"): - return False - return ( - "/" in token or "\\" in token or token in {".", ".."} - or bool(re.match(r"^[A-Za-z]:", token)) + name = "submit_plan" + label = "submit plan" + effect = "control" + plan_access: PlanAccess = "control" + execution_mode = "sequential" + description = ( + "Submit the decision-complete plan. This must be the only tool call " + "in the assistant message and ends the planning turn." ) + parameters = { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": MAX_PLAN_TITLE_CHARS, + }, + "markdown": {"type": "string", "minLength": 1}, + }, + "required": ["title", "markdown"], + "additionalProperties": False, + } + + def __init__( + self, + callback: Callable[ + [str, str, str], Awaitable[PlanRevision] | PlanRevision + ], + ) -> None: + self._callback = callback + + async def execute( + self, + tool_call_id: str, + params: dict, + signal: Any = None, + on_update: Any = None, + ) -> AgentToolResult: + del signal, on_update + title = params.get("title") + markdown = params.get("markdown") + if not isinstance(title, str) or not isinstance(markdown, str): + raise PlanModeError( + "INVALID_PLAN_SPEC", "submit_plan 需要 title 和 markdown", + ) + result = self._callback(tool_call_id, title, markdown) + revision = await result if inspect.isawaitable(result) else result + return AgentToolResult( + content=[ + TextContent( + text=( + f"Plan revision {revision.revision} is ready for " + f"explicit user confirmation (digest {revision.digest})." + ), + ), + ], + details={"plan": revision.to_payload()}, + terminate=True, + ) -def _path_stays_in_workspace(token: str, cwd: str) -> bool: - if any(char in token for char in "*?[]{}"): - token = token.split("*", 1)[0].split("?", 1)[0] or "." - try: - root = Path(cwd).resolve() - candidate = Path(token) - resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve() - return os.path.commonpath((str(root), str(resolved))) == str(root) - except (OSError, ValueError): - return False +def is_plan_safe_shell_command(command: str, cwd: str = "") -> bool: + """Compatibility helper: generic shell is never authorized in Plan Mode.""" + del command, cwd + return False async def enforce_plan_tool_policy( - context: BeforeToolCallContext, cwd: str, + context: BeforeToolCallContext, + cwd: str = "", + *, + phase: PlanPhase = "drafting", ) -> BeforeToolCallResult | None: - """Return a block result before any frontend-specific approval hook runs.""" + """Fail closed before any frontend approval adapter is called.""" + del cwd tool = next( - (item for item in context.context.tools or [] if getattr(item, "name", None) == context.tool_call.name), + ( + item + for item in context.context.tools or [] + if getattr(item, "name", None) == context.tool_call.name + ), None, ) - effect = getattr(tool, "effect", "unknown") - if effect in {"read", "control"}: - return None - if effect == "shell" and is_plan_safe_shell_command(str(context.args.get("command", "")), cwd): - return None - return BeforeToolCallResult( - block=True, - reason=f"PLAN_POLICY_BLOCKED: Plan Mode 禁止执行 {context.tool_call.name}(effect={effect})", + access: PlanAccess = cast( + PlanAccess, getattr(tool, "plan_access", "deny"), ) + name = context.tool_call.name + if phase != "drafting": + return BeforeToolCallResult( + block=True, + reason=f"PLAN_PHASE_BLOCKED: {phase} 状态不接受模型工具调用", + code="PLAN_PHASE_BLOCKED", + ) + if access not in {"observe", "control"}: + return BeforeToolCallResult( + block=True, + reason=( + f"PLAN_POLICY_BLOCKED: Plan Mode 禁止执行 {name};" + "请使用 read/grep/find/ls 或结构化 git_* 工具" + ), + code="PLAN_POLICY_BLOCKED", + alternatives=[{"tool": item} for item in ( + "read", "grep", "find", "ls", "git_status", "git_log", "git_diff", "git_show", + )], + ) + if name == "submit_plan": + if context.assistant_message.stop_reason in {"aborted", "error", "length"}: + return BeforeToolCallResult( + block=True, code="PLAN_SUBMIT_INCOMPLETE", + reason="PLAN_SUBMIT_INCOMPLETE: 中止或截断的回复不能提交计划", + ) + tool_calls = [ + block + for block in getattr(context.assistant_message, "content", []) + if getattr(block, "type", None) == "toolCall" + ] + if ( + len(tool_calls) != 1 + or getattr(tool_calls[0], "name", None) != "submit_plan" + ): + return BeforeToolCallResult( + block=True, + reason=( + "PLAN_SUBMIT_NOT_EXCLUSIVE: submit_plan " + "必须是本条消息唯一的工具调用" + ), + code="PLAN_SUBMIT_NOT_EXCLUSIVE", + alternatives=[{"tool": "submit_plan", "exclusive": True}], + ) + return None async def call_hook(hook: Any, context: Any, signal: Any) -> Any: diff --git a/packages/app/src/coding_agent/desktop/runtime.py b/packages/app/src/coding_agent/desktop/runtime.py index 9a07db1..515bb44 100644 --- a/packages/app/src/coding_agent/desktop/runtime.py +++ b/packages/app/src/coding_agent/desktop/runtime.py @@ -4,6 +4,8 @@ import asyncio import os from pathlib import Path +import re +import shlex import time from typing import Any, Awaitable, Callable, cast import uuid @@ -18,7 +20,7 @@ from coding_agent.core.credentials import CredentialStore from coding_agent.core.providers import _all_providers, get_configured_models from coding_agent.core.retry import RetryPolicy -from coding_agent.core.plan_mode import PlanModeError, is_plan_safe_shell_command +from coding_agent.core.plan_mode import PlanModeError from coding_agent.core.settings import SettingsManager from coding_agent.core.slash_commands import get_active_commands from coding_agent.desktop.protocol import PROTOCOL_VERSION, RpcError, to_jsonable @@ -74,6 +76,7 @@ async def dispatch(self, method: str, params: dict[str, Any]) -> Any: "plan.answer": self._plan_answer, "plan.cancel": self._plan_cancel, "plan.execute": self._plan_execute, + "plan.handoff": self._plan_handoff, "memory.status": self._memory_status, "memory.list": self._memory_list, "memory.conflicts": self._memory_conflicts, @@ -255,6 +258,8 @@ async def _session_snapshot(self, params: dict[str, Any]) -> dict[str, Any]: "sessionId": session.session_manager.header.id, "messages": to_jsonable(session.state.messages), "stats": to_jsonable(session.get_stats()), + "collaborationMode": session.collaboration_mode, + "planState": session.plan_state.to_payload(), } async def _session_clear(self, params: dict[str, Any]) -> dict[str, Any]: @@ -390,12 +395,16 @@ async def _drive_plan_answer( self._run_task = None async def _plan_cancel(self, params: dict[str, Any]) -> dict[str, Any]: - self._ensure_no_active_run("任务运行时不能取消规划") + session = self._require_session() + if session.plan_state.phase != "awaiting_answer": + self._ensure_no_active_run("任务运行时不能取消规划") plan_id = params.get("planId") - if not isinstance(plan_id, str) or not plan_id: + if plan_id is not None and (not isinstance(plan_id, str) or not plan_id): + raise RpcError("INVALID_PARAMS", "plan.cancel 需要有效 planId") + if plan_id is None and session.plan_state.phase != "recovery_error": raise RpcError("INVALID_PARAMS", "plan.cancel 需要 planId") try: - self._require_session().cancel_plan_mode(plan_id) + session.cancel_plan_mode(plan_id) except PlanModeError as exc: raise RpcError(exc.code, str(exc)) from exc return await self._workspace_payload_with_memory() @@ -423,6 +432,35 @@ async def _plan_execute(self, params: dict[str, Any]) -> dict[str, Any]: ) return {"accepted": True, "runId": run_id} + async def _plan_handoff(self, params: dict[str, Any]) -> dict[str, Any]: + """Create and open a fresh session containing only the confirmed plan. + + The core writes the child, records the source handoff, and atomically + reattaches this live AgentSession. No second open/initialization step + can strand the desktop after the source has been finalized. + """ + self._ensure_no_active_run("任务运行时不能交接计划") + plan_id = params.get("planId") + revision = params.get("revision") + digest = params.get("digest") + if ( + not isinstance(plan_id, str) or not isinstance(revision, int) + or not isinstance(digest, str) + ): + raise RpcError("INVALID_PARAMS", "plan.handoff 需要 planId、revision 和 digest") + + session = self._require_session() + try: + session.handoff_plan_to_new_session( + plan_id, revision, digest, attach=True, + ) + except PlanModeError as exc: + raise RpcError(exc.code, str(exc)) from exc + + payload = self._workspace_payload() + self._publish("session.changed", payload) + return payload + async def _drive_plan_execution( self, session: AgentSession, plan_id: str, revision: int, digest: str, run_id: str, @@ -497,7 +535,7 @@ async def _before_tool_call( ) -> BeforeToolCallResult | None: if context.tool_call.name not in _APPROVAL_TOOLS: return None - if context.tool_call.name == "bash" and is_plan_safe_shell_command( + if context.tool_call.name == "bash" and _is_default_approval_safe_shell_command( str(context.args.get("command", "")), str(self._workspace or Path.cwd()), ): @@ -871,6 +909,140 @@ def _require_session(self) -> AgentSession: return self._session +_DEFAULT_CONTROL_TOKENS = (";", "||", "`", "$(", ">", "<", "\n", "\r") +_DEFAULT_SHELL_SPLIT_RE = re.compile(r"\s*(?:\|\||&&|\|)\s*") +_DEFAULT_SAFE_FD_REDIRECTION_RE = re.compile(r"(?&1|1>&2)(?=\s|$)") +_DEFAULT_READ_COMMANDS = { + "pwd", "ls", "dir", "cat", "head", "tail", "wc", "sort", "uniq", + "cut", "grep", "rg", "fd", "find", "where", "which", "type", + "get-content", "select-string", "get-childitem", "git", +} +_DEFAULT_READ_ONLY_GIT = { + "status", "diff", "show", "log", "branch", "rev-parse", "ls-files", + "ls-tree", "cat-file", "grep", "blame", "remote", "tag", "describe", +} +_DEFAULT_VALIDATION_EXECUTABLES = {"pytest", "ruff", "pyright", "mypy"} +_DEFAULT_SAFE_PACKAGE_SCRIPTS = {"test", "typecheck", "lint", "check", "build"} +_DEFAULT_DANGEROUS_FIND_FLAGS = {"-delete", "-exec", "-execdir", "-ok", "-okdir"} + + +def _is_default_approval_safe_shell_command(command: str, cwd: str) -> bool: + """Preserve the desktop's Default-mode approval shortcut. + + Plan Mode no longer uses a shell allowlist: its runtime tool policy prevents + Bash from being registered and blocks denied tools again before execution. + This classifier is deliberately local to the desktop approval adapter so + tightening Plan Mode does not silently change established Default behavior. + """ + command = _DEFAULT_SAFE_FD_REDIRECTION_RE.sub("", command.strip()) + if not command or any(token in command for token in _DEFAULT_CONTROL_TOKENS): + return False + if re.search(r"(?:^|\s)\.\.(?:[\\/]|(?:\s|$))", command): + return False + segments = _DEFAULT_SHELL_SPLIT_RE.split(command) + if not segments or any(not segment.strip() for segment in segments): + return False + for segment in segments: + try: + tokens = shlex.split(segment, posix=True) + except ValueError: + return False + if not tokens or not _is_default_approval_safe_segment(tokens, cwd): + return False + return True + + +def _is_default_approval_safe_segment(tokens: list[str], cwd: str) -> bool: + executable = tokens[0].replace("\\", "/").rsplit("/", 1)[-1].casefold() + if executable.endswith(".exe"): + executable = executable[:-4] + args = tokens[1:] + if executable == "cd": + return len(args) == 1 and _default_path_stays_in_workspace(args[0], cwd) + if executable == "git": + positional = [item for item in args if not item.startswith("-")] + if not positional or positional[0].casefold() not in _DEFAULT_READ_ONLY_GIT: + return False + if positional[0].casefold() == "branch" and len(positional) > 1: + return False + if any( + item in {"-c", "-o", "--paginate"} + or item.startswith(("--output", "--exec-path", "--open-files-in-pager")) + for item in args + ): + return False + elif executable in _DEFAULT_READ_COMMANDS: + if executable == "find" and any( + item.casefold() in _DEFAULT_DANGEROUS_FIND_FLAGS for item in args + ): + return False + if executable == "rg" and any( + item == "--pre" or item.startswith("--pre=") for item in args + ): + return False + if executable == "sort" and any( + item == "-o" or item.startswith("--output") for item in args + ): + return False + if executable == "uniq" and len( + [item for item in args if not item.startswith("-")] + ) > 1: + return False + elif executable in _DEFAULT_VALIDATION_EXECUTABLES: + pass + elif executable in {"pnpm", "npm", "yarn"}: + lowered = {item.casefold() for item in args if not item.startswith("-")} + if ( + lowered.isdisjoint(_DEFAULT_SAFE_PACKAGE_SCRIPTS) + or lowered & {"add", "install", "exec", "publish"} + ): + return False + elif executable == "uv": + if not _is_default_approval_safe_uv(args): + return False + else: + return False + return all( + _default_path_stays_in_workspace(token, cwd) + for token in args + if _default_looks_like_path(token) + ) + + +def _is_default_approval_safe_uv(args: list[str]) -> bool: + lowered = [item.casefold() for item in args if not item.startswith("-")] + if not lowered: + return False + if lowered[0] == "build": + return True + if lowered[0] != "run" or len(lowered) < 2: + return False + if lowered[1] in _DEFAULT_VALIDATION_EXECUTABLES: + return True + return lowered[1:3] == ["python", "scripts/check_versions.py"] + + +def _default_looks_like_path(token: str) -> bool: + if token.startswith("-"): + return False + return ( + "/" in token or "\\" in token or token in {".", ".."} + or bool(re.match(r"^[A-Za-z]:", token)) + ) + + +def _default_path_stays_in_workspace(token: str, cwd: str) -> bool: + if any(char in token for char in "*?[]{}"): + token = token.split("*", 1)[0].split("?", 1)[0] or "." + try: + root = Path(cwd).resolve() + candidate = Path(token) + resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve() + return os.path.commonpath((str(root), str(resolved))) == str(root) + except (OSError, ValueError): + return False + + def _is_read_only_bash_command(command: str) -> bool: - """Compatibility alias for the shared Plan/safe-shell classifier.""" - return is_plan_safe_shell_command(command, os.getcwd()) + """Compatibility alias retained for desktop protocol tests/extensions.""" + return _is_default_approval_safe_shell_command(command, os.getcwd()) diff --git a/packages/app/src/coding_agent/modes/interactive/components/footer.py b/packages/app/src/coding_agent/modes/interactive/components/footer.py index fba537e..678b640 100644 --- a/packages/app/src/coding_agent/modes/interactive/components/footer.py +++ b/packages/app/src/coding_agent/modes/interactive/components/footer.py @@ -84,10 +84,7 @@ def render(self, width: int) -> list[str]: if is_reasoning: lvl = thinking_level or "off" model_part = f"{model_id} • thinking {lvl}" - mode_part = ( - "mode plan" if getattr(self._session, "collaboration_mode", "default") == "plan" - else "" - ) + mode_part = self._plan_label() # Token stats + context usage. token_part = "" @@ -102,10 +99,19 @@ def render(self, width: int) -> list[str]: cost_part = f"${stats.cost:.4f}" if stats.cost > 0 else "" - # Join with separators, then color and truncate. + # Join with separators. Wide terminals retain the existing inline + # order. On narrow terminals, keep the Plan recovery/lifecycle label + # visible even when a long cwd would otherwise consume the whole row. parts = [p for p in (left, mode_part, model_part, token_part, cost_part) if p] line = " | ".join(parts) + if len(line) > width and mode_part: + suffix = f" | {mode_part}" + if len(suffix) < width: + line = left[: width - len(suffix)] + suffix + else: + line = mode_part[:width] + # Colorize the context marker if present. line = self._colorize_context(line, model, stats) @@ -120,6 +126,27 @@ def render(self, width: int) -> list[str]: # ── helpers ───────────────────────────────────────────────────────── + def _plan_label(self) -> str: + """Return the compact, state-aware Plan indicator. + + ``uncertain`` and ``recovery_error`` remain visible even though the + runtime is fail-closed in Default mode, so reopening a session never + hides the fact that user action is required. + """ + state = getattr(self._session, "plan_state", None) + phase = getattr(state, "phase", None) + if phase == "ready": + return "plan ready" + if phase == "uncertain": + return "plan uncertain" + if phase == "recovery_error": + return "plan recovery" + if phase in {"drafting", "awaiting_answer", "executing"}: + return "plan" + if getattr(self._session, "collaboration_mode", "default") == "plan": + return "plan" + return "" + def _context_percent(self, model: Any, stats: Any) -> "float | None": """Current context-window occupancy as a percentage. diff --git a/packages/app/src/coding_agent/modes/interactive/components/plan_actions.py b/packages/app/src/coding_agent/modes/interactive/components/plan_actions.py index 47472dc..d65039f 100644 --- a/packages/app/src/coding_agent/modes/interactive/components/plan_actions.py +++ b/packages/app/src/coding_agent/modes/interactive/components/plan_actions.py @@ -1,4 +1,4 @@ -"""Focused actions for the latest immutable Plan revision.""" +"""Focused, state-aware actions for Plan Mode.""" from __future__ import annotations from typing import Callable @@ -19,10 +19,11 @@ def __init__( self._plan = plan self._on_action = on_action self._on_close = on_close - self._list = SelectList(max_visible=3) + self._list = SelectList(max_visible=4) self._list.set_items([ SelectItem(value="supplement", label="补充想法", description="保留 Plan Mode,在输入框补充修改要求"), - SelectItem(value="execute", label="执行方案", description="确认当前 revision 并立即切回 Default 执行"), + SelectItem(value="execute", label="当前会话执行", description="确认当前 revision 并立即切回 Default 执行"), + SelectItem(value="handoff", label="新会话复核", description="仅交接已确认方案,在干净会话中再次确认"), SelectItem(value="cancel", label="取消规划", description="切回 Default,不执行计划"), ]) self.focused = True @@ -55,3 +56,80 @@ def render(self, width: int) -> list[str]: lines.append(" ↑↓ 选择 · Enter 确认 · Esc 关闭操作栏") lines.append(border) return lines + + +class PlanModeMenuComponent: + """Small action menu used by a bare ``/plan`` in non-ready phases.""" + + _PHASE_ITEMS: dict[str, tuple[tuple[str, str, str], ...]] = { + "drafting": ( + ("continue", "继续规划", "返回输入框,继续补充需求或约束"), + ("submit", "整理并提交", "让 Agent 将现有讨论整理为可复核方案"), + ("cancel", "取消规划", "切回 Default,不执行任何方案"), + ), + "awaiting_answer": ( + ("answer", "回答待处理问题", "重新显示尚未回答的结构化问题"), + ("cancel", "取消规划", "放弃问题并切回 Default"), + ), + "executing": ( + ("details", "查看运行状态", "显示当前执行阶段与 planId"), + ("stop", "停止执行", "请求中止当前执行回合"), + ), + "uncertain": ( + ("details", "查看恢复详情", "检查无法确认终态的执行记录"), + ("replan", "重新规划", "开始新的 Plan Episode,不自动重试"), + ("cancel", "取消规划", "退出当前 Plan 状态"), + ), + "recovery_error": ( + ("details", "查看恢复错误", "显示持久化状态校验失败原因"), + ("replan", "重新规划", "保留历史并开始新的 Plan Episode"), + ("cancel", "取消规划", "退出当前 Plan 状态"), + ), + } + + def __init__( + self, + theme: Theme, + phase: str, + on_action: Callable[[str], None], + on_close: Callable[[], None], + ) -> None: + if phase not in self._PHASE_ITEMS: + raise ValueError(f"unsupported Plan phase: {phase}") + self._theme = theme + self._phase = phase + self._on_action = on_action + self._on_close = on_close + items = self._PHASE_ITEMS[phase] + self._list = SelectList(max_visible=len(items)) + self._list.set_items([ + SelectItem(value=value, label=label, description=description) + for value, label, description in items + ]) + self.focused = True + + def handle_input(self, data: str) -> bool: + if matches_key(data, "escape") or matches_key(data, "ctrl+c"): + self._on_close() + return True + if matches_key(data, "up"): + self._list.move_up() + return True + if matches_key(data, "down"): + self._list.move_down() + return True + if matches_key(data, "enter"): + selected = self._list.get_selected() + if selected is not None: + self._on_action(selected.value) + return True + return False + + def render(self, width: int) -> list[str]: + border = self._theme.fg("warning", "─" * width) + content_width = max(1, width - 4) + lines = [border, f" PLAN · {self._phase}"] + lines.extend(" " + row for row in self._list.render(content_width)) + lines.append(" ↑↓ 选择 · Enter 确认 · Esc 返回") + lines.append(border) + return lines diff --git a/packages/app/src/coding_agent/modes/interactive/interactive_mode.py b/packages/app/src/coding_agent/modes/interactive/interactive_mode.py index be0f32a..f66a86e 100644 --- a/packages/app/src/coding_agent/modes/interactive/interactive_mode.py +++ b/packages/app/src/coding_agent/modes/interactive/interactive_mode.py @@ -59,7 +59,10 @@ from coding_agent.modes.interactive.components.model_selector import ( ModelSelectorComponent, ) -from coding_agent.modes.interactive.components.plan_actions import PlanActionsComponent +from coding_agent.modes.interactive.components.plan_actions import ( + PlanActionsComponent, + PlanModeMenuComponent, +) from coding_agent.modes.interactive.components.plan_question import PlanQuestionComponent from coding_agent.modes.interactive.components.status_indicator import ( StatusIndicator, @@ -150,6 +153,7 @@ def __init__(self, session: AgentSession) -> None: #: Either a ModelSelectorComponent or a LoginDialogComponent (duck-typed #: via handle_input + render + focused). self._current_selector: Any = None + self._displayed_plan_key: tuple[Any, ...] | None = None def _spawn(self, coro): """Schedule a fire-and-forget coroutine, keeping a strong reference. @@ -439,9 +443,15 @@ def _on_agent_event(self, event: dict) -> None: self.theme, )) elif etype == "plan_question_requested": - self._open_plan_question(event) + pass # Compatibility event; controls use only full snapshots. elif etype == "plan_ready": - self._show_plan_ready(event) + pass + elif etype in {"plan_state_changed", "plan.stateChanged"}: + # The runtime owns all transitions. Frontends only re-render the + # authoritative snapshot already applied to ``session.plan_state``. + self._refresh_footer() + self._update_editor_border_color() + self._render_plan_state_controls() elif etype == "plan_validation_failed": self._add_system_message( f"Plan 校验失败 [{event.get('code', 'INVALID_PLAN_SPEC')}]:" @@ -451,8 +461,7 @@ def _on_agent_event(self, event: dict) -> None: "collaboration_mode_changed", "plan_execution_started", "plan_execution_completed", "plan_execution_failed", "plan_execution_aborted", }: - self._refresh_footer() - self._update_editor_border_color() + pass # 16 ms matches the TUI render interval. Duplicated as a # literal to avoid a cross-package import for one constant; the value is @@ -652,6 +661,7 @@ def _rebuild_chat_from_messages(self) -> None: Used after compaction rewrites the session history. """ self.chat_container.clear() + self._displayed_plan_key = None self._pending_tools.clear() self._streaming_component = None try: @@ -808,11 +818,27 @@ def _cmd_help(self) -> None: def _cmd_clear(self) -> None: self._session.agent.reset() self.chat_container.clear() + self._displayed_plan_key = None self._tool_cards.clear() self._print_welcome() self._add_system_message("对话已清空。") + self._render_plan_state_controls() def _cmd_plan(self) -> None: + state = self._session.plan_state + # Reopening a pending question through bare /plan first shows the + # state menu, so users can either redisplay the question or leave the + # episode. Startup/event rehydration still restores the question card + # directly through _render_plan_state_controls(). + if state.phase == "awaiting_answer": + self._mount_plan_phase_menu("awaiting_answer") + return + if state.phase in { + "drafting", "ready", "executing", + "uncertain", "recovery_error", + }: + self._render_plan_state_controls() + return try: self._session.enter_plan_mode() except PlanModeError as exc: @@ -821,6 +847,7 @@ def _cmd_plan(self) -> None: self._refresh_footer() self._update_editor_border_color() self._add_system_message("已进入计划模式。") + self._render_plan_state_controls() async def _cmd_compact(self) -> None: self._add_system_message("正在压缩上下文…") @@ -1017,6 +1044,7 @@ def _on_tree_selected(self, entry_id: str) -> None: self._add_system_message("已切换到所选分支。") finally: self._restore_editor() + self._render_plan_state_controls() def _swap_editor_for(self, selector: Any) -> None: """Replace the editor with ``selector`` in the TUI tree. @@ -1077,6 +1105,11 @@ def _open_plan_question(self, event: dict) -> None: self._add_system_message("收到无效的 Plan 问题事件。") return + self._mount_plan_question(question) + + def _mount_plan_question(self, question: PlanQuestion) -> None: + """Mount a pending question from either a live event or restored state.""" + def on_answer(answer: str) -> None: self._restore_editor() self._spawn(self._answer_plan_question(question.question_id, answer)) @@ -1091,26 +1124,70 @@ def on_cancel() -> None: ) async def _answer_plan_question(self, question_id: str, answer: str) -> None: + # A live question continues inside the already-subscribed `_respond` + # task. A restored/deferred question starts a fresh Core prompt here, + # so this method must temporarily subscribe and lock the editor itself. + resumed = getattr(self._session, "_question_future", None) is None + unsub: Callable[[], None] | None = None + if resumed: + self._is_responding = True + self.editor.disable_submit = True + unsub = self._session.on_event(self._on_agent_event) try: await self._session.answer_plan_question(question_id, answer) except PlanModeError as exc: self._add_system_message(f"回答失败 [{exc.code}]:{exc}") + except Exception as exc: + self._add_system_message(f"回答失败:{exc}") + finally: + if resumed: + if unsub is not None: + unsub() + self._is_responding = False + self.editor.disable_submit = False + self._refresh_footer() + self._render_plan_state_controls() def _show_plan_ready(self, event: dict) -> None: raw = event.get("plan") or {} try: - plan = PlanRevision( - plan_id=str(raw["planId"]), revision=int(raw["revision"]), - title=str(raw["title"]), markdown=str(raw["markdown"]), - digest=str(raw["digest"]), - source_message_id=str(raw.get("sourceMessageId", "")), - ) + plan_id = str(raw["planId"]) + revision = int(raw["revision"]) + restored = self._session.plan_state.latest_revision + if ( + restored is not None + and restored.plan_id == plan_id + and restored.revision == revision + and restored.digest == str(raw["digest"]) + ): + plan = restored + else: + plan = PlanRevision( + plan_id=plan_id, revision=revision, + title=str(raw["title"]), markdown=str(raw["markdown"]), + digest=str(raw["digest"]), + source_message_id=str(raw.get("sourceMessageId", "")), + schema_version=int(raw.get("schemaVersion", 0)), + submitted_by_tool_call_id=str(raw.get("submittedByToolCallId", "")), + origin_session_id=( + str(raw["originSessionId"]) + if raw.get("originSessionId") is not None else None + ), + ) except (KeyError, TypeError, ValueError): self._add_system_message("收到无效的 Plan revision 事件。") return - self._add_assistant_text( - f"**PLAN · revision {plan.revision} · `{plan.digest[:12]}`**\n\n{plan.markdown}" - ) + self._mount_plan_ready(plan) + + def _mount_plan_ready(self, plan: PlanRevision) -> None: + """Mount actions for an immutable ready revision without duplicating it.""" + key = (self._session.session_manager.header.id, plan.plan_id, plan.revision, plan.digest) + if getattr(self, "_displayed_plan_key", None) != key: + self._add_assistant_text( + f"**PLAN · revision {plan.revision} · `{plan.digest[:12]}`**\n\n" + f"{plan.title}\n\n{plan.markdown}" + ) + self._displayed_plan_key = key def on_action(action: str) -> None: self._restore_editor() @@ -1120,11 +1197,133 @@ def on_action(action: str) -> None: self._cancel_plan() elif action == "execute": self._spawn(self._execute_latest_plan()) + elif action == "handoff": + self._handoff_latest_plan() self._swap_editor_for( PlanActionsComponent(self.theme, plan, on_action, self._restore_editor) ) + def _render_plan_state_controls(self) -> None: + """Rehydrate the correct Plan control from authoritative Core state. + + This is deliberately called after startup, branch/session switches and + full-state events. It contains no transition logic; callbacks invoke + ``AgentSession`` APIs and let the runtime publish the next snapshot. + """ + state = getattr(self._session, "plan_state", None) + if state is None: + return + phase = str(getattr(state, "phase", "idle")) + question = getattr(state, "pending_question", None) + latest = getattr(state, "latest_revision", None) + if getattr(state, "legacy_candidate", False): + key = (self._session.session_manager.header.id, state.active_plan_id, "legacy") + if getattr(self, "_displayed_plan_key", None) != key: + self._add_system_message( + "检测到旧版计划候选文本,尚未形成可执行 revision。" + "请继续规划,并通过 submit_plan 重新提交后再确认执行。" + ) + self._displayed_plan_key = key + + if phase == "awaiting_answer" and question is not None: + self._mount_plan_question(question) + return + if phase == "ready" and latest is not None: + self._mount_plan_ready(latest) + return + if phase in {"drafting", "executing", "uncertain", "recovery_error"}: + self._mount_plan_phase_menu(phase) + return + self._close_plan_controls() + + def _mount_plan_phase_menu(self, phase: str) -> None: + def on_action(action: str) -> None: + self._restore_editor() + if action == "continue": + self._add_system_message("继续输入需求、约束或修改意见。") + elif action == "submit": + self._spawn(self._request_plan_submission()) + elif action == "answer": + question = self._session.plan_state.pending_question + if question is None: + self._add_system_message("当前没有待回答的 Plan 问题。") + else: + self._mount_plan_question(question) + elif action == "details": + self._show_plan_recovery_details() + elif action == "stop": + self._spawn(self._session.abort()) + self._add_system_message("已请求停止当前 Plan 执行回合。") + elif action == "replan": + self._restart_plan_after_recovery() + elif action == "cancel": + self._cancel_plan() + + self._swap_editor_for( + PlanModeMenuComponent(self.theme, phase, on_action, self._restore_editor) + ) + + def _close_plan_controls(self) -> None: + selector = getattr(self, "_current_selector", None) + if isinstance(selector, (PlanActionsComponent, PlanModeMenuComponent, PlanQuestionComponent)): + self._restore_editor() + + async def _request_plan_submission(self) -> None: + """Ask the model to submit the discussion through the control tool.""" + if self._is_responding: + self._add_system_message("任务运行中,请先按 Esc 停止。") + return + prompt = ( + "请基于当前讨论整理一份决策完备的实施计划,并仅通过 submit_plan " + "控制工具提交;不要执行计划。" + ) + self._add_user_message(prompt) + self._is_responding = True + self.editor.disable_submit = True + try: + await self._respond(prompt) + finally: + self._is_responding = False + self.editor.disable_submit = False + self._refresh_footer() + self._render_plan_state_controls() + + def _show_plan_recovery_details(self) -> None: + state = self._session.plan_state + details = [f"Plan 状态:`{state.phase}`"] + if state.active_plan_id: + details.append(f"planId:`{state.active_plan_id}`") + latest_run = getattr(state, "latest_run", None) + run_id = getattr(latest_run, "run_id", None) + if run_id: + details.append(f"runId:`{run_id}`") + error = getattr(state, "recovery_error", None) + if error: + if isinstance(error, dict): + code = error.get("code", "PLAN_RECOVERY_ERROR") + message = error.get("message", "") + else: + code = getattr(error, "code", "PLAN_RECOVERY_ERROR") + message = getattr(error, "message", str(error)) + details.append(f"恢复错误:`{code}` {message}") + details.append("不会自动重试,也不会把未知终态当作成功。") + self._add_assistant_text("\n\n".join(details)) + + def _restart_plan_after_recovery(self) -> None: + """Start a new episode through Core; never retry the old run.""" + try: + if self._session.collaboration_mode == "plan": + self._session.cancel_plan_mode(self._session.plan_state.active_plan_id) + self._session.enter_plan_mode() + except PlanModeError as exc: + self._add_system_message(f"重新规划失败 [{exc.code}]:{exc}") + return + self._refresh_footer() + self._update_editor_border_color() + self._add_system_message("已开始新的 Plan Episode;旧执行不会自动重试。") + self._render_plan_state_controls() + def _cancel_plan(self) -> None: try: self._session.cancel_plan_mode(self._session.plan_state.active_plan_id) @@ -1161,6 +1360,34 @@ async def _execute_latest_plan(self) -> None: self._refresh_footer() self._update_editor_border_color() + def _handoff_latest_plan(self) -> None: + """Move the latest revision into a clean, linked session for review.""" + latest = self._session.plan_state.latest_revision + if latest is None: + self._add_system_message("当前没有可交接的 Plan revision。") + return + try: + manager = self._session.handoff_plan_to_new_session( + latest.plan_id, latest.revision, latest.digest, + ) + except PlanModeError as exc: + self._add_system_message(f"交接失败 [{exc.code}]:{exc}") + return + except Exception as exc: + self._add_system_message(f"交接失败:{exc}") + return + + self.chat_container.clear() + self._displayed_plan_key = None + self._tool_cards.clear() + self._print_welcome() + self._refresh_footer() + self._update_editor_border_color() + self._add_system_message( + f"Plan 已交接到新会话 {manager.header.id};请再次复核后执行。" + ) + self._render_plan_state_controls() + # ── Collaboration / thinking cycles ──────────────────────────────────── def _cycle_collaboration_mode(self) -> None: @@ -1441,10 +1668,13 @@ def _cmd_name(self, name: str) -> None: def _cmd_new(self) -> None: manager = self._session.new_session() self.chat_container.clear() + self._displayed_plan_key = None self._tool_cards.clear() self._print_welcome() self._refresh_footer() + self._update_editor_border_color() self._add_system_message(f"已创建新会话:{manager.header.id}") + self._render_plan_state_controls() def _cmd_settings(self, arg: str) -> None: """Show settings or persist ``/settings ``.""" @@ -1918,6 +2148,10 @@ async def run(self, initial_prompt: Any = None, initial_display_text: str = "") self._is_responding = False self.editor.disable_submit = False + # Rehydrate branch-local Plan controls after all startup work. This + # also reflects any state transition caused by an initial prompt. + self._render_plan_state_controls() + # Event loop. while self._running: await asyncio.sleep(0.05) diff --git a/packages/app/src/coding_agent/search/tool.py b/packages/app/src/coding_agent/search/tool.py index e6ef6bd..6fa4e62 100644 --- a/packages/app/src/coding_agent/search/tool.py +++ b/packages/app/src/coding_agent/search/tool.py @@ -4,6 +4,7 @@ from typing import Any from agent_core import AgentToolResult +from agent_core.types import PlanAccess from agent_llm import TextContent from coding_agent.search.backend import SearchBackend @@ -15,6 +16,7 @@ class WebSearchTool: name = "web_search" label = "web search" effect = "read" + plan_access: PlanAccess = "deny" execution_mode = "parallel" description = ( "Search the public web for current information and return source URLs. " diff --git a/packages/app/tests/test_agent_session_memory.py b/packages/app/tests/test_agent_session_memory.py index c6871ef..38d07fa 100644 --- a/packages/app/tests/test_agent_session_memory.py +++ b/packages/app/tests/test_agent_session_memory.py @@ -6,7 +6,7 @@ import pytest from agent_core import SessionManager -from agent_llm import AssistantMessage, Model, ModelCost, TextContent, UserMessage +from agent_llm import AssistantMessage, Model, ModelCost, TextContent, ToolCall, UserMessage from coding_agent.core.agent_session import AgentSession, AgentSessionConfig from coding_agent.memory.types import MemoryContext, MemoryIdentity @@ -74,6 +74,15 @@ async def close(self, *, grace_seconds: float = 0.25) -> None: pass +def _submit(session: AgentSession, title: str, markdown: str) -> None: + call = ToolCall( + id="submit-memory", name="submit_plan", + arguments={"title": title, "markdown": markdown}, + ) + session.session_manager.append_message(AssistantMessage(content=[call], stop_reason="tool_use")) + asyncio.run(session._submit_plan(call.id, title, markdown)) + + def test_prompt_retrieves_transient_context_and_queues_user_evidence(tmp_path: Path) -> None: manager = SessionManager.create(cwd=str(tmp_path), in_memory=True) memory = _MemoryService() @@ -320,12 +329,12 @@ def test_plan_ready_is_not_memory_but_exact_execution_is(tmp_path: Path) -> None ## Assumptions Local only. """ - session._capture_plan_revision(AssistantMessage(content=[TextContent(text=plan_text)])) + _submit(session, "Memory plan", plan_text) latest = session.plan_state.latest_revision assert latest is not None assert memory.tasks == [] - async def fake_execute_prompt(_message: str) -> None: + async def fake_execute_prompt(_message: str, **_kwargs) -> None: session._last_assistant_message = AssistantMessage( content=[TextContent(text="implemented")], stop_reason="stop", ) @@ -333,9 +342,9 @@ async def fake_execute_prompt(_message: str) -> None: session.prompt = fake_execute_prompt # type: ignore[method-assign] asyncio.run(session.execute_plan(latest.plan_id, latest.revision, latest.digest)) - assert [task.mode for task in memory.tasks] == ["plan_accepted", "plan_completed"] + assert [task.mode for task in memory.tasks] == ["plan_accepted"] assert memory.tasks[0].evidence[0].source_kind == "accepted_plan" - assert memory.tasks[1].evidence[0].source_kind == "plan_completed" + assert session.plan_state.phase == "settled" def test_aborted_plan_execution_does_not_create_completed_memory(tmp_path: Path) -> None: @@ -363,11 +372,11 @@ def test_aborted_plan_execution_does_not_create_completed_memory(tmp_path: Path) ## Assumptions Local only. """ - session._capture_plan_revision(AssistantMessage(content=[TextContent(text=plan_text)])) + _submit(session, "Abort plan", plan_text) latest = session.plan_state.latest_revision assert latest is not None - async def fake_execute_prompt(_message: str) -> None: + async def fake_execute_prompt(_message: str, **_kwargs) -> None: session._last_assistant_message = AssistantMessage( content=[TextContent(text="aborted")], stop_reason="aborted", ) diff --git a/packages/app/tests/test_cli_runtime_options.py b/packages/app/tests/test_cli_runtime_options.py index 45e8797..cdcea87 100644 --- a/packages/app/tests/test_cli_runtime_options.py +++ b/packages/app/tests/test_cli_runtime_options.py @@ -14,6 +14,7 @@ _configure_output_encoding, _create_session_manager, _load_context_for_run, + _run_plan_control, ) from coding_agent.core.agent_session import AgentSession, AgentSessionConfig @@ -41,6 +42,59 @@ def test_plan_controls_are_mutually_exclusive(): assert exc.value.code == 2 +def test_handoff_plan_accepts_latest_or_an_explicit_revision(): + assert parse_args(["--handoff-plan"]).handoff_plan == 0 + assert parse_args(["--handoff-plan", "3"]).handoff_plan == 3 + + +def test_handoff_plan_rejects_non_positive_revision(): + with pytest.raises(SystemExit) as exc: + parse_args(["--handoff-plan", "0"]) + assert exc.value.code == 2 + + +def test_execute_plan_rejects_non_positive_revision(): + with pytest.raises(SystemExit) as exc: + parse_args(["--execute-plan", "0"]) + assert exc.value.code == 2 + + +def test_handoff_plan_is_mutually_exclusive_with_execution(): + with pytest.raises(SystemExit) as exc: + parse_args(["--handoff-plan", "--execute-plan", "1"]) + assert exc.value.code == 2 + + +def test_handoff_plan_control_uses_latest_revision_and_attaches(capsys): + latest = SimpleNamespace(plan_id="plan-1", revision=4, digest="digest-4") + target = SimpleNamespace(header=SimpleNamespace(id="session-child")) + calls: list[tuple[str, int, str]] = [] + + class _Session: + plan_state = SimpleNamespace( + mode="plan", phase="ready", pending_question=None, latest_revision=latest, + ) + session_manager = target + + def on_event(self, _callback): + return lambda: None + + def handoff_plan_to_new_session(self, plan_id, revision, digest): + calls.append((plan_id, revision, digest)) + return target + + def dispose(self): + pass + + result = _run_plan_control( + _Session(), Args(handoff_plan=0), answer=None, mode="text", # type: ignore[arg-type] + ) + + assert result == 0 + assert calls == [("plan-1", 4, "digest-4")] + assert "session-child" in capsys.readouterr().err + + def test_agent_mode_is_distinct_from_output_mode(): args = parse_args(["--mode", "json", "--agent-mode", "plan"]) assert args.output_mode == "json" @@ -51,6 +105,7 @@ def test_plan_control_forces_non_interactive_mode(monkeypatch): monkeypatch.setattr("sys.stdin.isatty", lambda: True) monkeypatch.setattr("sys.stdout.isatty", lambda: True) assert resolve_app_mode(Args(cancel_plan=True)) == "print" + assert resolve_app_mode(Args(handoff_plan=0)) == "print" def test_project_trust_flags_are_mutually_exclusive(): diff --git a/packages/app/tests/test_desktop_protocol.py b/packages/app/tests/test_desktop_protocol.py index 5464c59..0ca094f 100644 --- a/packages/app/tests/test_desktop_protocol.py +++ b/packages/app/tests/test_desktop_protocol.py @@ -283,6 +283,95 @@ async def compact(_reason: str) -> dict: assert changed["payload"]["messages"][0]["summary"] == "durable compacted context" +def test_session_snapshot_includes_authoritative_plan_state() -> None: + import asyncio + + state_payload = { + "mode": "plan", + "phase": "ready", + "activePlanId": "plan-1", + "latestRevision": {"planId": "plan-1", "revision": 2, "digest": "abc"}, + "pendingQuestion": None, + "latestRun": None, + "recoveryError": None, + "handoffTargetSessionId": None, + } + runtime = DesktopRuntime(lambda _event: None) + runtime._session = SimpleNamespace( + collaboration_mode="plan", + plan_state=SimpleNamespace(to_payload=lambda: state_payload), + session_manager=SimpleNamespace(header=SimpleNamespace(id="session-plan")), + state=SimpleNamespace(messages=[]), + get_stats=lambda: {"messages": 0}, + ) + + result = asyncio.run(runtime.dispatch("session.snapshot", {})) + + assert result["sessionId"] == "session-plan" + assert result["collaborationMode"] == "plan" + assert result["planState"] == state_payload + + +def test_plan_handoff_opens_fresh_review_session_after_core_persists( + tmp_path: Path, +) -> None: + import asyncio + + calls: list[tuple[str, int, str, bool]] = [] + target_manager = SimpleNamespace(header=SimpleNamespace(id="session-child")) + + def handoff(plan_id: str, revision: int, digest: str, *, attach: bool): + calls.append((plan_id, revision, digest, attach)) + source.__dict__.update(target_session.__dict__) + return target_manager + + source = SimpleNamespace(handoff_plan_to_new_session=handoff) + ready_state = { + "mode": "plan", + "phase": "ready", + "activePlanId": "plan-1", + "latestRevision": { + "planId": "plan-1", "revision": 2, "digest": "digest-2", + "title": "Plan", "markdown": "body", "sourceMessageId": "message-1", + }, + "pendingQuestion": None, + } + target_session = SimpleNamespace( + session_manager=target_manager, + state=SimpleNamespace(messages=[]), + model=SimpleNamespace(id="model", name="Model", provider="test"), + thinking_level=None, + tools=[], + collaboration_mode="plan", + plan_state=SimpleNamespace(to_payload=lambda: ready_state), + memory_enabled=False, + memory_identity=None, + ) + events: list[dict] = [] + runtime = DesktopRuntime(events.append) + runtime._workspace = tmp_path + runtime._session = source # type: ignore[assignment] + + async def replace_session( + workspace: Path, *, session_id: str | None = None, resume: bool = False, + ) -> None: + assert workspace == tmp_path + assert session_id == "session-child" + assert resume is False + runtime._session = target_session # type: ignore[assignment] + + runtime._replace_session = replace_session # type: ignore[method-assign] + + result = asyncio.run(runtime.dispatch("plan.handoff", { + "planId": "plan-1", "revision": 2, "digest": "digest-2", + })) + + assert calls == [("plan-1", 2, "digest-2", True)] + assert result["sessionId"] == "session-child" + assert result["planState"] == ready_state + assert events[-1]["event"] == {"type": "session.changed", "payload": result} + + def test_opening_saved_session_does_not_persist_abandoned_empty_session( tmp_path: Path, ) -> None: @@ -429,3 +518,9 @@ def test_unanswered_approval_expires_instead_of_waiting_forever() -> None: "approval.requested", "approval.expired", ] + + +def test_default_approval_preserves_upstream_fd_redirect_support() -> None: + from coding_agent.desktop.runtime import _is_read_only_bash_command + assert _is_read_only_bash_command("git log --oneline -20 --no-merges 2>&1 | head -30") + assert not _is_read_only_bash_command("cat missing.txt 2> errors.txt") diff --git a/packages/app/tests/test_footer.py b/packages/app/tests/test_footer.py index 9351863..14d1ff5 100644 --- a/packages/app/tests/test_footer.py +++ b/packages/app/tests/test_footer.py @@ -4,6 +4,7 @@ import re import subprocess from dataclasses import dataclass, field +from types import SimpleNamespace from coding_agent.modes.interactive.components.footer import FooterComponent @@ -59,7 +60,8 @@ class FakeSession: """Minimal session stub for footer render tests.""" def __init__(self, *, model=None, thinking_level="high", entries=None, - tokens_in=0, tokens_out=0, cost=0.0, cwd="."): + tokens_in=0, tokens_out=0, cost=0.0, cwd=".", + collaboration_mode="default", plan_phase="idle"): self.model = model or _FakeModel() self.thinking_level = thinking_level self.cwd = cwd @@ -67,6 +69,8 @@ def __init__(self, *, model=None, thinking_level="high", entries=None, self._tokens_in = tokens_in self._tokens_out = tokens_out self._cost = cost + self.collaboration_mode = collaboration_mode + self.plan_state = SimpleNamespace(phase=plan_phase) self.session_manager = type("SM", (), {"get_branch": self._branch})() def _branch(self): @@ -80,8 +84,7 @@ def get_stats(self): def _make(**kw) -> "tuple[FooterComponent, FakeSession]": sess = FakeSession(**kw) - # Stub out git so refresh doesn't actually run git in tests. - sess.cwd = "." + # Construct without __init__, so refresh_git_branch never spawns Git. footer = FooterComponent.__new__(FooterComponent) footer._session = sess footer._theme = _theme() @@ -110,6 +113,37 @@ def test_no_thinking_when_model_not_reasoning(): assert "thinking" not in line +# ── Plan state ───────────────────────────────────────────────────────── + + +def test_plan_footer_labels_are_state_aware(): + cases = { + "drafting": "plan", + "awaiting_answer": "plan", + "ready": "plan ready", + "executing": "plan", + "uncertain": "plan uncertain", + "recovery_error": "plan recovery", + } + for phase, expected in cases.items(): + mode = "plan" if phase in {"drafting", "awaiting_answer", "ready"} else "default" + footer, _ = _make(collaboration_mode=mode, plan_phase=phase) + line = _strip(footer.render(120)[0]) + assert expected in line + assert "mode plan" not in line + + +def test_plan_recovery_label_survives_narrow_footer(): + footer, _ = _make( + collaboration_mode="default", + plan_phase="recovery_error", + model=_FakeModel(id="very-long-model-name"), + cwd="C:/a/very/long/project/path/that/would/hide/the/state", + ) + line = _strip(footer.render(24)[0]) + assert "plan recovery" in line + + # ── tokens + context ─────────────────────────────────────────────────── diff --git a/packages/app/tests/test_plan_actions.py b/packages/app/tests/test_plan_actions.py index 48c1be9..c2bf3a9 100644 --- a/packages/app/tests/test_plan_actions.py +++ b/packages/app/tests/test_plan_actions.py @@ -3,7 +3,10 @@ from agent_tui import load_theme from coding_agent.core.plan_mode import PlanRevision -from coding_agent.modes.interactive.components.plan_actions import PlanActionsComponent +from coding_agent.modes.interactive.components.plan_actions import ( + PlanActionsComponent, + PlanModeMenuComponent, +) def test_ready_plan_offers_execute_or_supplement_choices() -> None: @@ -19,6 +22,24 @@ def test_ready_plan_offers_execute_or_supplement_choices() -> None: rendered = "\n".join(component.render(100)) - assert "执行方案" in rendered + assert "当前会话执行" in rendered + assert "新会话复核" in rendered assert "补充想法" in rendered assert "继续规划" not in rendered + + +def test_plan_phase_menus_keep_an_explicit_exit() -> None: + theme = load_theme("dark") + expectations = { + "drafting": ("继续规划", "整理并提交", "取消规划"), + "awaiting_answer": ("回答待处理问题", "取消规划"), + "executing": ("查看运行状态", "停止执行"), + "uncertain": ("查看恢复详情", "重新规划", "取消规划"), + "recovery_error": ("查看恢复错误", "重新规划", "取消规划"), + } + + for phase, labels in expectations.items(): + component = PlanModeMenuComponent(theme, phase, lambda _: None, lambda: None) + rendered = "\n".join(component.render(100)) + for label in labels: + assert label in rendered diff --git a/packages/app/tests/test_plan_lifecycle.py b/packages/app/tests/test_plan_lifecycle.py new file mode 100644 index 0000000..a17918e --- /dev/null +++ b/packages/app/tests/test_plan_lifecycle.py @@ -0,0 +1,506 @@ +"""Authorization, crash recovery, and clean-session handoff regressions.""" +from __future__ import annotations + +import asyncio +from dataclasses import replace +import hashlib +import json +from pathlib import Path + +import pytest +from agent_core import AgentContext, BeforeToolCallContext, SessionManager +from agent_core.agent_loop import _execute_tool_calls +from agent_core.types import AgentLoopConfig +from agent_core.session.storage import entry_to_line_dict, line_dict_to_entry +from agent_core.session.types import PlanRevisionEntry, PlanRunEntry, SessionMessageEntry +from agent_llm import AssistantMessage, Model, ModelCost, TextContent, ToolCall, UserMessage + +from coding_agent.core.agent_session import AgentSession, AgentSessionConfig +from coding_agent.core.plan_mode import ( + PlanModeError, PlanQuestion, PlanQuestionOption, create_plan_revision, + reduce_plan_state, +) + + +class Stream: + def __init__(self, message): + self.message = message + + def __aiter__(self): + return self + + async def __anext__(self): + raise StopAsyncIteration + + async def result(self): + return self.message + + +def session_at(tmp_path: Path, *, disk=False): + manager = SessionManager.create( + cwd=str(tmp_path), sessions_dir=tmp_path / "sessions", in_memory=not disk, + ) + return AgentSession(AgentSessionConfig( + model=Model(id="plan-test", provider="test", context_window=1_000_000, + cost=ModelCost(input=0, output=0, cache_read=0, cache_write=0)), + cwd=str(tmp_path), session_manager=manager, collaboration_mode="plan", + question_behavior="deferred", + )) + + +def submission(call_id="submit-1", title="Title", markdown="body\r\n\n"): + return AssistantMessage(content=[ToolCall( + id=call_id, name="submit_plan", arguments={"title": title, "markdown": markdown}, + )], stop_reason="tool_use") + + +async def drive(session, message, response, *, seen=None): + def stream(_model, context, _options=None): + if seen is not None: + seen.append(context) + return Stream(response) + session.agent.stream_fn = stream + await session.prompt(message) + + +def ready(session): + asyncio.run(drive(session, "plan this", submission())) + assert session.plan_state.phase == "ready" + return session.plan_state.latest_revision + + +def test_end_to_end_question_revision_stale_handoff_and_settled(tmp_path): + session = session_at(tmp_path, disk=True) + events = [] + session.on_event(events.append) + question = PlanQuestion("q", "Scope", "Which scope?", ( + PlanQuestionOption("Core", "Change core"), PlanQuestionOption("All", "Change all"), + )) + asyncio.run(session._request_plan_question(question, None)) + session.agent.stream_fn = lambda *_args: Stream(submission()) + asyncio.run(session.answer_plan_question("q", "Core")) + first = session.plan_state.latest_revision + assert first is not None + contexts = [] + asyncio.run(drive(session, "add crash recovery", submission("submit-2", markdown="revised"), seen=contexts)) + assert "submit_plan" in {tool.name for tool in contexts[0].tools} + latest = session.plan_state.latest_revision + assert latest.revision == first.revision + 1 + with pytest.raises(PlanModeError) as error: + asyncio.run(session.execute_plan(first.plan_id, first.revision, first.digest)) + assert error.value.code == "STALE_PLAN_REVISION" + source = session.session_manager + session.agent.follow_up("private queued conversation") + target = session.handoff_plan_to_new_session(latest.plan_id, latest.revision, latest.digest) + assert target.header.parent_session == source.header.id + assert session.state.messages == [] + assert not session.agent.has_queued_messages() + assert len(target.entries) == 2 + assert not any(isinstance(item, SessionMessageEntry) for item in target.entries) + assert session.plan_state.phase == "ready" + assert session.plan_state.latest_revision.digest == latest.digest + restored = SessionManager.open(target.path) + assert reduce_plan_state( + restored.get_branch(), parent_session_id=restored.header.parent_session, + ).phase == "ready" + assert reduce_plan_state(source.get_branch()).handoff_target_session_id == target.header.id + session.agent.stream_fn = lambda *_args: Stream(AssistantMessage( + content=[TextContent(text="Agent turn ended")], stop_reason="stop", + )) + asyncio.run(session.execute_plan(latest.plan_id, latest.revision, latest.digest)) + assert session.plan_state.phase == "settled" + runs = [item for item in target.entries if isinstance(item, PlanRunEntry)] + assert [item.status for item in runs] == ["started", "completed"] + assert runs[-1].assistant_message_id + assert runs[-1].stop_reason == "stop" + assert events[-2]["type"] == "plan.stateChanged" + assert events[-2]["state"]["phase"] == "settled" + + +@pytest.mark.parametrize("stop", ["aborted", "error", "length"]) +def test_incomplete_assistant_cannot_submit(tmp_path, stop): + session = session_at(tmp_path) + message = submission() + message.stop_reason = stop + # Direct control callback and model-loop entry point both enforce this. + session.session_manager.append_message(message) + with pytest.raises(PlanModeError): + asyncio.run(session._submit_plan("submit-1", "Title", "body\r\n\n")) + assert not any(isinstance(item, PlanRevisionEntry) for item in session.session_manager.entries) + + +@pytest.mark.parametrize("sibling", ["read", "write", "submit_plan"]) +def test_submit_with_any_parallel_call_saves_no_revision(tmp_path, sibling): + session = session_at(tmp_path) + message = submission() + message.content.append(ToolCall(id="sibling", name=sibling, arguments={})) + session.session_manager.append_message(message) + call = message.content[0] + context = BeforeToolCallContext( + assistant_message=message, tool_call=call, + args=call.arguments, context=AgentContext(tools=session.tools), + ) + result = asyncio.run(session._before_tool_call(context, asyncio.Event())) + assert result.block and result.code == "PLAN_SUBMIT_NOT_EXCLUSIVE" + with pytest.raises(PlanModeError): + asyncio.run(session._submit_plan(call.id, "Title", "body\r\n\n")) + assert session.plan_state.phase == "drafting" + assert not any(isinstance(item, PlanRevisionEntry) for item in session.session_manager.entries) + + +def test_submit_stops_even_with_queued_follow_up(tmp_path): + session = session_at(tmp_path) + session.agent.follow_up("continue and execute") + contexts = [] + asyncio.run(drive(session, "make plan", submission(), seen=contexts)) + assert len(contexts) == 1 + assert session.plan_state.phase == "ready" + + +def test_unregistered_tool_denial_has_code_and_alternatives(tmp_path): + session = session_at(tmp_path) + message = AssistantMessage(content=[ToolCall(id="bad", name="bash", arguments={"command": "pytest"})]) + async def convert(messages): + return messages + results, _ = asyncio.run(_execute_tool_calls( + message.content, AgentContext(tools=session.tools), message, + AgentLoopConfig(model=session.model, convert_to_llm=convert, before_tool_call=session._before_tool_call), + lambda _event: None, asyncio.Event(), + )) + assert results[0].is_error + assert results[0].details["code"] == "PLAN_POLICY_BLOCKED" + assert {"tool": "git_diff"} in results[0].details["alternatives"] + + +def test_started_without_live_owner_recovers_uncertain_and_requires_action(tmp_path): + session = session_at(tmp_path, disk=True) + plan = ready(session) + session.session_manager.append_plan_run( + plan_id=plan.plan_id, revision=plan.revision, digest=plan.digest, + status="started", run_id="crashed", + ) + session.session_manager.flush() + restored = AgentSession(AgentSessionConfig( + model=session.model, cwd=str(tmp_path), + session_manager=SessionManager.open(session.session_manager.path), + )) + assert restored.plan_state.phase == "uncertain" + assert restored.tools == [] + with pytest.raises(PlanModeError) as error: + asyncio.run(restored.prompt("go ahead")) + assert error.value.code == "PLAN_RECOVERY_REQUIRED" + assert asyncio.run(restored.run_bash("pytest"))["code"] == "PLAN_POLICY_BLOCKED" + restored.enter_plan_mode() + assert restored.plan_state.phase == "drafting" + assert restored.plan_state.active_plan_id != plan.plan_id + + +@pytest.mark.parametrize("mutation", [ + "digest", "cross_plan", "duplicate_revision", "missing_source", + "source_call", "old_revision", "missing_run_id", "terminal_tuple", + "duplicate_mode", "reused_mode", "wrong_cancel", "wrong_answer", + "unanswered", "terminal_after_cancel", "forged_origin", +]) +def test_reducer_rejects_invalid_sequences(tmp_path, mutation): + session = session_at(tmp_path) + plan = ready(session) + entries = session.session_manager.get_branch() + revision = next(item for item in entries if isinstance(item, PlanRevisionEntry)) + index = entries.index(revision) + manager = session.session_manager + if mutation == "digest": + entries[index] = replace(revision, digest="bad") + elif mutation == "cross_plan": + entries[index] = replace(revision, plan_id="another") + elif mutation == "missing_source": + entries[index] = replace(revision, source_message_id="missing") + elif mutation == "source_call": + entries[index] = replace(revision, submitted_by_tool_call_id="missing") + elif mutation == "old_revision": + entries[index] = replace(revision, revision=0) + elif mutation == "forged_origin": + entries[index] = replace(revision, origin_session_id="forged") + elif mutation == "duplicate_revision": + entries.append(replace(revision, id="duplicate")) + elif mutation in {"missing_run_id", "terminal_tuple", "terminal_after_cancel"}: + started = manager.append_plan_run( + plan_id=plan.plan_id, revision=plan.revision, digest=plan.digest, + status="started", run_id="run", + ) + if mutation == "missing_run_id": + started.run_id = None + else: + if mutation == "terminal_after_cancel": + manager.append_collaboration_mode_change("default", plan_id=plan.plan_id, reason="user") + manager.append_plan_run( + plan_id=plan.plan_id, revision=plan.revision, + digest="wrong" if mutation == "terminal_tuple" else plan.digest, + status="completed", run_id="run", + ) + entries = manager.get_branch() + elif mutation in {"duplicate_mode", "reused_mode", "wrong_cancel"}: + if mutation == "reused_mode": + manager.append_collaboration_mode_change("default", plan_id=plan.plan_id) + if mutation == "wrong_cancel": + manager.append_collaboration_mode_change("default", plan_id="wrong") + else: + manager.append_collaboration_mode_change("plan", plan_id=plan.plan_id) + entries = manager.get_branch() + else: + manager.append_message(UserMessage(content="revise")) + manager.append_plan_question( + plan_id=plan.plan_id, question_id="q", header="Scope", question="Which?", + options=[{"label": "A", "description": "A"}, {"label": "B", "description": "B"}], + ) + if mutation == "wrong_answer": + manager.append_plan_question_answer(plan_id=plan.plan_id, question_id="wrong", answer="A") + else: + manager.append_plan_revision( + plan_id=plan.plan_id, revision=2, title="Title", markdown="body", + digest="bad", source_message_id="missing", + ) + entries = manager.get_branch() + state = reduce_plan_state(entries) + assert state.phase == "recovery_error" + assert state.recovery_error is not None + assert state == reduce_plan_state(entries) + + +def test_legacy_digest_validated_without_rewrite(tmp_path): + session = session_at(tmp_path) + manager = session.session_manager + markdown = "# Legacy\n" + manager.append_plan_revision( + plan_id=session.plan_state.active_plan_id, revision=1, title="Legacy", markdown=markdown, + digest=hashlib.sha256(markdown.encode()).hexdigest(), source_message_id="old-source", + ) + session.refresh_plan_state_from_branch() + assert session.plan_state.phase == "ready" + assert session.plan_state.latest_revision.schema_version == 0 + entry = manager.entries[-1] + assert line_dict_to_entry(entry_to_line_dict(entry)) == entry + plan = session.plan_state.latest_revision + child = session.handoff_plan_to_new_session(plan.plan_id, plan.revision, plan.digest) + assert session.plan_state.phase == "ready" + assert child.entries[-1].schema_version == 0 + + +@pytest.mark.parametrize("bad_line", ['{"type":', "[]", '"text"', '{"type":"plan_run","status":"bad"}']) +def test_corrupt_jsonl_requires_recovery_and_explicit_exit(tmp_path, bad_line): + session = session_at(tmp_path, disk=True) + ready(session) + path = session.session_manager.path + with path.open("a", encoding="utf-8") as stream: + stream.write(bad_line + "\n") + with pytest.warns(RuntimeWarning): + manager = SessionManager.open(path) + restored = AgentSession(AgentSessionConfig(model=session.model, session_manager=manager)) + assert restored.plan_state.phase == "recovery_error" + with pytest.raises(PlanModeError): + asyncio.run(restored.prompt("execute")) + restored.cancel_plan_mode() + assert restored.plan_state.phase == "cancelled" + with pytest.warns(RuntimeWarning): + reopened = SessionManager.open(path) + assert reduce_plan_state(reopened.get_branch(), load_issues=reopened.load_issues).phase == "cancelled" + + +@pytest.mark.parametrize("stage", ["target_flush", "source_append"]) +def test_handoff_failure_retains_source_ready_and_keeps_child(tmp_path, monkeypatch, stage): + session = session_at(tmp_path, disk=True) + plan = ready(session) + source = session.session_manager + original_flush = SessionManager.flush + original_append = SessionManager.append_collaboration_mode_change + def flush(manager): + if manager is not source and stage == "target_flush": + original_flush(manager) + raise OSError("target flush interrupted") + return original_flush(manager) + def append(manager, mode, **kwargs): + if manager is source and stage == "source_append": + raise OSError("source append interrupted") + return original_append(manager, mode, **kwargs) + monkeypatch.setattr(SessionManager, "flush", flush) + monkeypatch.setattr(SessionManager, "append_collaboration_mode_change", append) + with pytest.raises(OSError): + session.handoff_plan_to_new_session(plan.plan_id, plan.revision, plan.digest) + assert session.session_manager is source + assert session.plan_state.phase == "ready" + assert reduce_plan_state(SessionManager.open(source.path).get_branch()).phase == "ready" + children = [path for path in source.path.parent.glob("*.jsonl") if path != source.path] + assert len(children) == 1 + child = SessionManager.open(children[0]) + assert child.header.parent_session == source.header.id + assert reduce_plan_state(child.get_branch(), parent_session_id=source.header.id).phase == "ready" + assert not any(isinstance(item, PlanRunEntry) for item in child.entries) + + +def test_entry_append_failure_does_not_advance_memory_or_file(tmp_path, monkeypatch): + import agent_core.session.storage as storage + session = session_at(tmp_path, disk=True) + ready(session) + manager = session.session_manager + original_bytes = manager.path.read_bytes() + previous_leaf = manager.leaf_id + def fail_fsync(_fd): + raise OSError("fsync failed") + monkeypatch.setattr(storage.os, "fsync", fail_fsync) + with pytest.raises(OSError): + manager.append_collaboration_mode_change("default", plan_id=session.plan_state.active_plan_id) + assert manager.leaf_id == previous_leaf + assert manager.path.read_bytes() == original_bytes + + +def test_execution_registration_guards_session_changes_and_early_abort(tmp_path): + session = session_at(tmp_path) + plan = ready(session) + calls = [] + async def before_prompt_memory(*_args): + for action in (session.enter_plan_mode, session.new_session, session.cancel_plan_mode): + with pytest.raises(PlanModeError): + action() + with pytest.raises(PlanModeError): + await session.prompt("unconfirmed") + await session.abort() + async def forbidden_prompt(*_args, **_kwargs): + calls.append("prompt") + session._extract_accepted_plan_memory = before_prompt_memory + session.agent.prompt = forbidden_prompt + asyncio.run(session.execute_plan(plan.plan_id, plan.revision, plan.digest)) + assert not calls + assert session.plan_state.phase == "aborted" + + +@pytest.mark.parametrize("title,markdown", [("x" * 201, "body"), ("two\nlines", "body"), ("Title", "x" * 65537)], ids=["long_title", "multiline_title", "long_markdown"]) +def test_submit_limits(title, markdown): + with pytest.raises(PlanModeError): + create_plan_revision(plan_id="p", revision=1, title=title, markdown=markdown, + source_message_id="m", submitted_by_tool_call_id="t") + + +def test_title_and_markdown_are_preserved_exactly(): + revision = create_plan_revision( + plan_id="p", revision=1, title=" Title ", markdown=" no added heading\r\n\n", + source_message_id="m", submitted_by_tool_call_id="t", + ) + assert revision.title == " Title " + assert revision.markdown == " no added heading\n\n" + expected = {"schemaVersion": 1, "planId": "p", "revision": 1, + "title": revision.title, "markdown": revision.markdown} + assert revision.digest == hashlib.sha256(json.dumps( + expected, ensure_ascii=False, sort_keys=True, separators=(",", ":"), + ).encode()).hexdigest() + lone_cr = create_plan_revision( + plan_id="p", revision=2, title="Title", markdown="one\rtwo\r\nthree", + source_message_id="m", submitted_by_tool_call_id="t2", + ) + assert lone_cr.markdown == "one\rtwo\nthree" + + +def test_deferred_question_is_durable_before_any_assistant_message(tmp_path): + session = session_at(tmp_path, disk=True) + asyncio.run(session._request_plan_question(PlanQuestion("q", "Scope", "Which?", ( + PlanQuestionOption("A", "A"), PlanQuestionOption("B", "B"), + )), None)) + reopened = SessionManager.open(session.session_manager.path) + assert reduce_plan_state(reopened.get_branch()).phase == "awaiting_answer" + + +@pytest.mark.parametrize("stage", ["started_flush", "terminal_append"]) +def test_execution_persistence_failure_releases_ownership_and_locks_retry(tmp_path, monkeypatch, stage): + session = session_at(tmp_path, disk=True) + plan = ready(session) + manager = session.session_manager + calls = [] + session.agent.stream_fn = lambda *_args: calls.append("model") or Stream(AssistantMessage( + content=[TextContent(text="ended")], stop_reason="stop", + )) + if stage == "started_flush": + def fail_flush(): + raise OSError("started flush failed") + monkeypatch.setattr(manager, "flush", fail_flush) + else: + original_append = manager.append_plan_run + def append(**kwargs): + if kwargs["status"] != "started": + raise OSError("terminal append failed") + return original_append(**kwargs) + monkeypatch.setattr(manager, "append_plan_run", append) + with pytest.raises(OSError): + asyncio.run(session.execute_plan(plan.plan_id, plan.revision, plan.digest)) + assert session._active_plan_run_id is None + assert session.plan_state.phase == "uncertain" + assert not session.tools + if stage == "started_flush": + assert not calls + assert reduce_plan_state(SessionManager.open(manager.path).get_branch()).phase == "uncertain" + with pytest.raises(PlanModeError): + asyncio.run(session.execute_plan(plan.plan_id, plan.revision, plan.digest)) + + +def test_partial_initial_flush_can_resume_without_losing_buffered_entries(tmp_path, monkeypatch): + import agent_core.session.session_manager as persistence + manager = SessionManager.create(cwd=str(tmp_path), sessions_dir=tmp_path / "sessions") + manager.append_collaboration_mode_change("plan", plan_id="p") + manager.append_message(UserMessage(content="retain me")) + original_append = persistence.append_entry_line + def interrupted_append(path, entry): + if isinstance(entry, SessionMessageEntry): + raise OSError("interrupted buffer") + original_append(path, entry) + monkeypatch.setattr(persistence, "append_entry_line", interrupted_append) + with pytest.raises(OSError): + manager.flush() + monkeypatch.setattr(persistence, "append_entry_line", original_append) + manager.flush() + assert SessionManager.open(manager.path).entries == manager.entries + + +@pytest.mark.parametrize("field,value", [ + ("revision", True), ("revision", "1"), ("schemaVersion", "1"), + ("title", None), ("originSessionId", []), ("planId", 1), +]) +def test_jsonl_plan_fields_are_not_coerced_before_digest_validation(tmp_path, field, value): + session = session_at(tmp_path) + ready(session) + revision = next(item for item in session.session_manager.entries if isinstance(item, PlanRevisionEntry)) + raw = entry_to_line_dict(revision) + raw[field] = value + with pytest.raises(ValueError): + line_dict_to_entry(raw) + + +def test_live_question_can_be_cancelled_through_desktop_rpc_without_default_continuation(tmp_path): + from coding_agent.desktop.runtime import DesktopRuntime + session = session_at(tmp_path) + session._question_behavior = "blocking" + contexts = [] + async def exercise(): + runtime = DesktopRuntime(lambda _event: None) + runtime._session = session + runtime._workspace = tmp_path + async def cancel_when_ready(): + # Core publishes the question synchronously before yielding to its + # pending-answer future. Schedule RPC cancellation on the next tick. + await asyncio.sleep(0) + payload = await runtime.dispatch("plan.cancel", {"planId": session.plan_state.active_plan_id}) + assert payload["planState"]["phase"] == "cancelled" + tasks = [] + def on_event(event): + if event["type"] == "plan.stateChanged" and event["state"]["phase"] == "awaiting_answer": + tasks.append(asyncio.create_task(cancel_when_ready())) + session.on_event(on_event) + runtime._run_task = asyncio.create_task(drive(session, "ask first", AssistantMessage(content=[ToolCall( + id="q", name="request_user_input", arguments={"questions": [{ + "header": "Scope", "question": "Which?", "options": [ + {"label": "A", "description": "A"}, {"label": "B", "description": "B"}, + ], + }]}, + )], stop_reason="tool_use"), seen=contexts)) + await asyncio.wait_for(runtime._run_task, timeout=3) + await asyncio.gather(*tasks) + asyncio.run(exercise()) + assert len(contexts) == 1 + assert session.plan_state.phase == "cancelled" + assert not any(isinstance(item, PlanRevisionEntry) for item in session.session_manager.entries) diff --git a/packages/app/tests/test_plan_mode.py b/packages/app/tests/test_plan_mode.py index eb16f3c..eb98a50 100644 --- a/packages/app/tests/test_plan_mode.py +++ b/packages/app/tests/test_plan_mode.py @@ -18,6 +18,8 @@ PlanModeError, PlanQuestion, PlanQuestionOption, + compute_plan_digest, + create_plan_revision, is_plan_safe_shell_command, reduce_plan_state, validate_proposed_plan, @@ -31,6 +33,25 @@ def _model() -> Model: ) +def _plan_markdown(title: str = "Plan") -> str: + return f"""# {title} + +## Summary +Summary. + +## Implementation Changes +Changes. + +## Public Interfaces +Interfaces. + +## Test Plan +Tests. + +## Assumptions +None.""" + + def _plan_text(title: str = "Plan") -> str: return f""" # {title} @@ -52,6 +73,27 @@ def _plan_text(title: str = "Plan") -> str: """ +def _submit_plan( + session: AgentSession, + *, + title: str = "Plan", + markdown: str | None = None, + call_id: str = "submit-1", +): + markdown = markdown if markdown is not None else _plan_markdown(title) + params = {"title": title, "markdown": markdown} + call = ToolCall(id=call_id, name="submit_plan", arguments=params) + session.session_manager.append_message(AssistantMessage( + content=[call], + stop_reason="tool_use", + )) + tool = next(item for item in session.tools if item.name == "submit_plan") + result = asyncio.run(tool.execute(call_id, params)) + assert result.terminate is True + assert session.plan_state.latest_revision is not None + return session.plan_state.latest_revision + + def _bare_bilingual_plan_text() -> str: return """我已经完成检查,下面是详细计划。 @@ -78,7 +120,7 @@ def _bare_bilingual_plan_text() -> str: 需要我按这个计划开始实现吗?""" -def test_plan_spec_is_strict_and_digest_is_stable() -> None: +def test_plan_spec_is_exact_and_digest_v1_binds_all_authorization_fields() -> None: revision = validate_proposed_plan( _plan_text().replace("\n", "\r\n"), plan_id="p", revision=1, source_message_id="m", @@ -87,12 +129,27 @@ def test_plan_spec_is_strict_and_digest_is_stable() -> None: assert len(revision.digest) == 64 assert "\r" not in revision.markdown - with pytest.raises(PlanModeError, match="块外"): + assert revision.digest != compute_plan_digest( + plan_id="p", revision=1, title="Other", markdown=revision.markdown, + ) + assert revision.digest != compute_plan_digest( + plan_id="p", revision=2, title=revision.title, + markdown=revision.markdown, + ) + + with pytest.raises(PlanModeError, match="proposed_plan"): validate_proposed_plan( "prefix\n" + _plan_text(), plan_id="p", revision=2, source_message_id="m2", ) + with pytest.raises(PlanModeError, match="控制字符"): + create_plan_revision( + plan_id="p", revision=2, title="Plan", + markdown="line one\x00line two", source_message_id="m2", + submitted_by_tool_call_id="submit-2", + ) + def test_plan_spec_accepts_bilingual_section_headings() -> None: text = """ @@ -119,23 +176,28 @@ def test_plan_spec_accepts_bilingual_section_headings() -> None: assert revision.title == "双语计划" -@pytest.mark.parametrize( - ("command", "allowed"), - [ - ("git status --short", True), - ("git log --oneline -20 --no-merges 2>&1 | head -30", True), - ("rg --files packages/app | head -20", True), - ("uv run pytest -q", True), - ("pnpm typecheck", True), - ("git checkout -- file.py", False), - ("python scripts/mutate.py", False), - ("cat file > copy", False), - ("cat missing.txt 2> errors.txt", False), - ("rg token ../private", False), - ], -) -def test_plan_shell_policy(command: str, allowed: bool, tmp_path: Path) -> None: - assert is_plan_safe_shell_command(command, str(tmp_path)) is allowed +@pytest.mark.parametrize("command", [ + "git status --short", + "git log --oneline -20 --no-merges 2>&1 | head -30", + "rg --files packages/app | head -20", + "uv run pytest -q", + "pnpm typecheck", + "git remote remove origin", + "git tag -d release", + "git diff --raw", + "git show --patch HEAD", + "git log -p", + "find . -fprint output.txt", + "sort input -o output", + "tail -f app.log", + "ruff check --fix .", + "python -m build", + "echo ok & touch sentinel", + "cat file > copy", + "cat missing.txt 2> errors.txt", +]) +def test_plan_shell_policy_denies_every_command(command: str, tmp_path: Path) -> None: + assert is_plan_safe_shell_command(command, str(tmp_path)) is False def test_mode_only_session_is_not_materialized(tmp_path: Path) -> None: @@ -170,61 +232,63 @@ def test_agent_session_switches_prompt_tools_and_captures_revision(tmp_path: Pat session.enter_plan_mode() assert session.collaboration_mode == "plan" - assert session.tools[-1].name == "request_user_input" + plan_tools = {tool.name for tool in session.tools} + assert {"read", "grep", "find", "ls", "git_status", "git_log", + "git_diff", "git_show", "request_user_input", "submit_plan"} <= plan_tools + assert {"bash", "write", "edit"}.isdisjoint(plan_tools) assert "" in session.state.system_prompt - session._capture_plan_revision(AssistantMessage(content=[TextContent(text=_plan_text())])) - latest = session.plan_state.latest_revision - assert latest is not None + latest = _submit_plan(session) assert session.plan_state.phase == "ready" + assert session.tools == [] + assert latest.schema_version == 1 with pytest.raises(PlanModeError) as stale: asyncio.run(session.execute_plan(latest.plan_id, latest.revision, "bad")) assert stale.value.code == "STALE_PLAN_REVISION" -def test_bare_complete_plan_is_normalized_into_ready_revision(tmp_path: Path) -> None: +def test_plain_assistant_plan_text_cannot_create_a_revision(tmp_path: Path) -> None: session = AgentSession(AgentSessionConfig(model=_model(), cwd=str(tmp_path))) session.enter_plan_mode() - events: list[dict] = [] - session.on_event(events.append) - - session._capture_plan_revision( + session.session_manager.append_message( AssistantMessage(content=[TextContent(text=_bare_bilingual_plan_text())]), ) + session.refresh_plan_state_from_branch() - latest = session.plan_state.latest_revision - assert latest is not None - assert latest.title == "运行时性能优化计划" - assert "## Implementation Changes / 实现变更" in latest.markdown - assert session.plan_state.phase == "ready" - assert any(event.get("type") == "plan_ready" for event in events) + assert session.plan_state.latest_revision is None + assert session.plan_state.phase == "drafting" + assert session.plan_state.legacy_candidate is False def test_incomplete_markdown_discussion_does_not_become_ready(tmp_path: Path) -> None: session = AgentSession(AgentSessionConfig(model=_model(), cwd=str(tmp_path))) session.enter_plan_mode() - session._capture_plan_revision(AssistantMessage(content=[TextContent(text="# 一个想法\n\n还需要继续讨论。")])) + session.session_manager.append_message( + AssistantMessage(content=[TextContent(text="# 一个想法\n\n还需要继续讨论。")]), + ) + session.refresh_plan_state_from_branch() assert session.plan_state.phase == "drafting" assert session.plan_state.latest_revision is None -def test_resume_recovers_latest_complete_bare_plan(tmp_path: Path) -> None: +def test_resume_marks_legacy_envelope_as_candidate_without_importing_it(tmp_path: Path) -> None: manager = SessionManager.create(cwd=str(tmp_path), in_memory=True) manager.append_collaboration_mode_change("plan", plan_id="plan-resume") manager.append_message( - AssistantMessage(content=[TextContent(text=_bare_bilingual_plan_text())]), + AssistantMessage(content=[TextContent(text=_plan_text())]), ) session = AgentSession(AgentSessionConfig( model=_model(), cwd=str(tmp_path), session_manager=manager, )) - assert session.plan_state.phase == "ready" - assert session.plan_state.latest_revision is not None - assert any(isinstance(entry, PlanRevisionEntry) for entry in manager.entries) + assert session.plan_state.phase == "drafting" + assert session.plan_state.latest_revision is None + assert session.plan_state.legacy_candidate is True + assert not any(isinstance(entry, PlanRevisionEntry) for entry in manager.entries) def test_resume_does_not_revive_bare_plan_after_user_feedback(tmp_path: Path) -> None: @@ -241,17 +305,19 @@ def test_resume_does_not_revive_bare_plan_after_user_feedback(tmp_path: Path) -> assert session.plan_state.phase == "drafting" assert session.plan_state.latest_revision is None + assert session.plan_state.legacy_candidate is False def test_user_feedback_after_ready_revision_returns_to_drafting(tmp_path: Path) -> None: session = AgentSession(AgentSessionConfig(model=_model(), cwd=str(tmp_path))) session.enter_plan_mode() - session._capture_plan_revision(AssistantMessage(content=[TextContent(text=_plan_text())])) - latest = session.plan_state.latest_revision - assert latest is not None + latest = _submit_plan(session) + tool_names_after_feedback: set[str] = set() async def fake_agent_prompt(message: str) -> None: session.session_manager.append_message(UserMessage(content=message)) + session.refresh_plan_state_from_branch() + tool_names_after_feedback.update(tool.name for tool in session.tools) async def no_compaction() -> SimpleNamespace: return SimpleNamespace(need_retry=False) @@ -263,6 +329,8 @@ async def no_compaction() -> SimpleNamespace: assert session.plan_state.phase == "drafting" assert session.plan_state.latest_revision == latest + assert "submit_plan" in tool_names_after_feedback + assert "bash" not in tool_names_after_feedback restored = reduce_plan_state(session.session_manager.get_branch()) assert restored.phase == "drafting" assert restored.latest_revision == latest @@ -320,11 +388,9 @@ def test_deferred_question_reduces_and_resumes_same_episode(tmp_path: Path) -> N def test_exact_revision_execution_records_started_and_completed(tmp_path: Path) -> None: session = AgentSession(AgentSessionConfig(model=_model(), cwd=str(tmp_path))) session.enter_plan_mode() - session._capture_plan_revision(AssistantMessage(content=[TextContent(text=_plan_text())])) - latest = session.plan_state.latest_revision - assert latest is not None + latest = _submit_plan(session) - async def fake_prompt(_message) -> None: + async def fake_prompt(_message, **_kwargs) -> None: session._last_assistant_message = AssistantMessage(content=[TextContent(text="done")]) session.prompt = fake_prompt # type: ignore[method-assign] @@ -333,4 +399,4 @@ async def fake_prompt(_message) -> None: runs = [entry for entry in session.session_manager.entries if isinstance(entry, PlanRunEntry)] assert [entry.status for entry in runs] == ["started", "completed"] assert session.collaboration_mode == "default" - assert session.plan_state.phase == "completed" + assert session.plan_state.phase == "settled" diff --git a/packages/app/tests/test_plan_tui_state.py b/packages/app/tests/test_plan_tui_state.py new file mode 100644 index 0000000..ef38dc6 --- /dev/null +++ b/packages/app/tests/test_plan_tui_state.py @@ -0,0 +1,166 @@ +"""State projection tests for Plan controls in the terminal UI.""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from coding_agent.modes.interactive.interactive_mode import InteractiveMode + + +@pytest.mark.parametrize( + ("phase", "field", "expected"), + [ + ("awaiting_answer", "pending_question", "question"), + ("ready", "latest_revision", "ready"), + ("drafting", None, "drafting"), + ("executing", None, "executing"), + ("uncertain", None, "uncertain"), + ("recovery_error", None, "recovery_error"), + ("settled", None, "closed"), + ], +) +def test_rehydrate_routes_authoritative_plan_phase( + phase: str, field: str | None, expected: str, +) -> None: + marker = object() + state = SimpleNamespace( + phase=phase, + pending_question=marker if field == "pending_question" else None, + latest_revision=marker if field == "latest_revision" else None, + ) + calls: list[tuple[str, object | None]] = [] + mode = SimpleNamespace( + _session=SimpleNamespace(plan_state=state), + _mount_plan_question=lambda value: calls.append(("question", value)), + _mount_plan_ready=lambda value: calls.append(("ready", value)), + _mount_plan_phase_menu=lambda value: calls.append((value, None)), + _close_plan_controls=lambda: calls.append(("closed", None)), + ) + + InteractiveMode._render_plan_state_controls(mode) # type: ignore[arg-type] + + assert calls == [(expected, marker if field else None)] + + +def test_bare_plan_in_drafting_reopens_actions_without_new_episode() -> None: + calls: list[str] = [] + session = SimpleNamespace( + plan_state=SimpleNamespace(phase="drafting"), + enter_plan_mode=lambda: calls.append("enter"), + ) + mode = SimpleNamespace( + _session=session, + _render_plan_state_controls=lambda: calls.append("render"), + ) + + InteractiveMode._cmd_plan(mode) # type: ignore[arg-type] + + assert calls == ["render"] + + +def test_bare_plan_with_pending_question_offers_answer_or_cancel_menu() -> None: + calls: list[str] = [] + session = SimpleNamespace( + plan_state=SimpleNamespace(phase="awaiting_answer"), + enter_plan_mode=lambda: calls.append("enter"), + ) + mode = SimpleNamespace( + _session=session, + _mount_plan_phase_menu=lambda phase: calls.append(phase), + _render_plan_state_controls=lambda: calls.append("render"), + ) + + InteractiveMode._cmd_plan(mode) # type: ignore[arg-type] + + assert calls == ["awaiting_answer"] + + +def test_handoff_switches_to_clean_session_and_rehydrates_ready_controls() -> None: + calls: list[object] = [] + latest = SimpleNamespace(plan_id="plan-1", revision=2, digest="abc") + target = SimpleNamespace(header=SimpleNamespace(id="new-session")) + session = SimpleNamespace( + plan_state=SimpleNamespace(latest_revision=latest), + handoff_plan_to_new_session=lambda *args: calls.append(args) or target, + ) + mode = SimpleNamespace( + _session=session, + chat_container=SimpleNamespace(clear=lambda: calls.append("clear")), + _tool_cards={}, + _print_welcome=lambda: calls.append("welcome"), + _refresh_footer=lambda: calls.append("footer"), + _update_editor_border_color=lambda: calls.append("border"), + _add_system_message=lambda value: calls.append(value), + _render_plan_state_controls=lambda: calls.append("rehydrate"), + ) + + InteractiveMode._handoff_latest_plan(mode) # type: ignore[arg-type] + + assert ("plan-1", 2, "abc") in calls + assert "clear" in calls + assert "rehydrate" in calls + assert any("new-session" in value for value in calls if isinstance(value, str)) + + +@pytest.mark.parametrize( + ("event_type", "session_field"), + [ + ("plan.stateChanged", "sessionId"), + ("plan_state_changed", "session_id"), + ], +) +def test_full_plan_state_event_refreshes_footer_border_and_controls( + event_type: str, session_field: str, +) -> None: + calls: list[str] = [] + mode = SimpleNamespace( + _refresh_footer=lambda: calls.append("footer"), + _update_editor_border_color=lambda: calls.append("border"), + _render_plan_state_controls=lambda: calls.append("controls"), + ) + + InteractiveMode._on_agent_event(mode, { # type: ignore[arg-type] + "type": event_type, + session_field: "session-1", + "state": {"phase": "ready"}, + }) + + assert calls == ["footer", "border", "controls"] + + +def test_ready_body_is_visible_once_and_reappears_after_chat_rebuild() -> None: + from coding_agent.core.plan_mode import create_plan_revision + from agent_tui import load_theme + plan = create_plan_revision(plan_id="p", revision=1, title="Review title", markdown="exact body", + source_message_id="m", submitted_by_tool_call_id="t") + texts: list[str] = [] + mode = SimpleNamespace( + _session=SimpleNamespace(session_manager=SimpleNamespace(header=SimpleNamespace(id="child"))), + theme=load_theme("dark"), + _add_assistant_text=texts.append, + _swap_editor_for=lambda _component: None, + _restore_editor=lambda: None, + ) + InteractiveMode._mount_plan_ready(mode, plan) # type: ignore[arg-type] + InteractiveMode._mount_plan_ready(mode, plan) # type: ignore[arg-type] + assert len(texts) == 1 + assert "Review title" in texts[0] and "exact body" in texts[0] + mode._displayed_plan_key = None + InteractiveMode._mount_plan_ready(mode, plan) # type: ignore[arg-type] + assert len(texts) == 2 + + +def test_legacy_candidate_has_visible_resubmission_requirement() -> None: + texts: list[str] = [] + mode = SimpleNamespace( + _session=SimpleNamespace( + session_manager=SimpleNamespace(header=SimpleNamespace(id="old")), + plan_state=SimpleNamespace(phase="drafting", legacy_candidate=True, active_plan_id="p"), + ), + _add_system_message=texts.append, + _mount_plan_phase_menu=lambda _phase: None, + ) + InteractiveMode._render_plan_state_controls(mode) # type: ignore[arg-type] + InteractiveMode._render_plan_state_controls(mode) # type: ignore[arg-type] + assert len(texts) == 1 and "submit_plan" in texts[0] diff --git a/packages/app/tests/test_web_search.py b/packages/app/tests/test_web_search.py index 7af4e20..abfe9d5 100644 --- a/packages/app/tests/test_web_search.py +++ b/packages/app/tests/test_web_search.py @@ -312,6 +312,33 @@ def search_key(_provider: str) -> str: session.dispose() +@pytest.mark.parametrize("phase", ["drafting", "ready", "uncertain", "recovery_error", "memory"]) +def test_native_search_cannot_bypass_plan_or_background_context_tool_policy(monkeypatch, phase) -> None: + from coding_agent.core.plan_mode import PlanState + model = Model(id="deepseek-v4-flash", api="openai-responses", provider="deepseek", + context_window=16_000, cost=ModelCost(input=0, output=0, cache_read=0, cache_write=0)) + session = AgentSession(AgentSessionConfig( + model=model, web_search_enabled=True, web_search_backend=_FakeBackend(), + )) + captured = {} + def fake_stream(_model, context, options): + captured.update(context=context, options=options) + return object() + monkeypatch.setattr("agent_llm.compat.stream_simple", fake_stream) + monkeypatch.setattr("coding_agent.core.agent_session.retrying_stream", lambda factory, *_a, **_kw: factory()) + if phase != "memory": + session._plan_state = PlanState(mode="default" if phase == "uncertain" else "plan", phase=phase) + session._refresh_collaboration_runtime() + assert "web_search" not in {tool.name for tool in session.tools} + assert session.native_web_search_enabled is False + # Even a stale context advertising the tool cannot escape Plan restrictions. + context = Context(tools=None if phase == "memory" else [Tool(name="web_search")]) + session._create_stream_fn()(model, context, {"web_search": True}) + assert not (captured["options"] or {}).get("web_search") + assert "" not in (captured["context"].system_prompt or "") + session.dispose() + + def test_desktop_web_search_status_and_toggle(monkeypatch, tmp_path) -> None: backend = _FakeBackend() manager = SettingsManager(tmp_path / "settings.json") diff --git a/packages/core/src/agent_core/__init__.py b/packages/core/src/agent_core/__init__.py index 69355e6..0fbe379 100644 --- a/packages/core/src/agent_core/__init__.py +++ b/packages/core/src/agent_core/__init__.py @@ -25,6 +25,7 @@ BeforeToolCallContext, BeforeToolCallResult, QueueMode, + PlanAccess, StreamFn, ToolExecutionMode, ToolEffect, @@ -33,6 +34,7 @@ from agent_core.tools.edit import EditTool from agent_core.tools.find import FindTool from agent_core.tools.grep import GrepTool +from agent_core.tools.git import GitDiffTool, GitLogTool, GitShowTool, GitStatusTool from agent_core.tools.ls import LsTool from agent_core.tools.read import ReadTool from agent_core.tools.write import WriteTool @@ -60,6 +62,7 @@ "StreamFn", "ToolExecutionMode", "ToolEffect", + "PlanAccess", "QueueMode", "PendingMessageQueue", "BeforeToolCallContext", @@ -72,6 +75,10 @@ "BashRawResult", "EditTool", "GrepTool", + "GitStatusTool", + "GitLogTool", + "GitDiffTool", + "GitShowTool", "FindTool", "LsTool", # session persistence + compaction diff --git a/packages/core/src/agent_core/agent.py b/packages/core/src/agent_core/agent.py index f866bef..01eab6f 100644 --- a/packages/core/src/agent_core/agent.py +++ b/packages/core/src/agent_core/agent.py @@ -284,6 +284,7 @@ def _create_loop_config(self) -> AgentLoopConfig: # (after the inner loop stops) messages. get_steering_messages=self._steering_queue.drain, get_follow_up_messages=self._follow_up_queue.drain, + refresh_context=self._create_context_snapshot, ) async def _run_with_lifecycle(self, executor: Callable[[asyncio.Event], Awaitable[None]]) -> None: diff --git a/packages/core/src/agent_core/agent_loop.py b/packages/core/src/agent_core/agent_loop.py index 6d10fba..dc39fe3 100644 --- a/packages/core/src/agent_core/agent_loop.py +++ b/packages/core/src/agent_core/agent_loop.py @@ -106,6 +106,11 @@ async def run_agent_loop( await _emit(emit, {"type": "message_start", "message": p}) await _emit(emit, {"type": "message_end", "message": p}) + # Subscribers may persist/reduce a collaboration-state transition while + # handling the user message (for example Plan ready -> drafting). Adopt + # that authoritative runtime policy before the first model request. + await _refresh_runtime_context(current_context, config) + await _run_loop(current_context, config, emit, signal, stream_fn, new_messages, first_turn=True) return new_messages @@ -189,6 +194,7 @@ async def _run_loop( context.messages.append(msg) new_messages.append(msg) pending = [] + await _refresh_runtime_context(context, config) # (a)(b) Stream the assistant response. message = await _stream_assistant_response( @@ -226,6 +232,10 @@ async def _run_loop( await _emit(emit, {"type": "turn_end", "message": message, "tool_results": tool_results}) + if tool_calls and batch_terminate: + await _emit(emit, {"type": "agent_end", "messages": new_messages}) + return + # Post-turn steering poll. pending = await _drain_queue(config.get_steering_messages) # inner exited: no more tool calls AND no pending steering. @@ -241,6 +251,22 @@ async def _run_loop( await _emit(emit, {"type": "agent_end", "messages": new_messages}) +async def _refresh_runtime_context( + context: AgentContext, + config: AgentLoopConfig, +) -> None: + """Refresh policy fields without replacing the loop-owned transcript.""" + if config.refresh_context is None: + return + refreshed = config.refresh_context() + if hasattr(refreshed, "__await__"): + refreshed = await refreshed + if refreshed is None: + return + context.system_prompt = refreshed.system_prompt + context.tools = list(refreshed.tools) if refreshed.tools is not None else None + + # ─── stream + consume ────────────────────────── async def _stream_assistant_response( @@ -387,9 +413,9 @@ class _Finalized: is_error: bool -def _error_result(message: str) -> AgentToolResult: +def _error_result(message: str, details: Any = None) -> AgentToolResult: """Build a non-terminating error result for a failed tool call.""" - return AgentToolResult(content=[TextContent(text=message)]) + return AgentToolResult(content=[TextContent(text=message)], details=details) async def _execute_tool_calls( @@ -551,6 +577,18 @@ async def _prepare_tool_call( tool_map = {t.name: t for t in (context.tools or [])} tool = tool_map.get(tc.name) if tool is None: + if config.before_tool_call is not None: + before = await _maybe_await(config.before_tool_call( + BeforeToolCallContext( + assistant_message=assistant_message, tool_call=tc, + args=tc.arguments, context=context, + ), signal, + )) + if before is not None and before.block: + return { + "kind": "immediate", + "finalized": _Finalized(tc, _blocked_result(before), True), + } return { "kind": "immediate", "finalized": _Finalized( @@ -584,7 +622,7 @@ async def _prepare_tool_call( "kind": "immediate", "finalized": _Finalized( tc, - _error_result(before.reason or "Tool execution was blocked"), + _blocked_result(before), True, ), } @@ -602,6 +640,17 @@ async def _prepare_tool_call( } +def _blocked_result(before: Any) -> AgentToolResult: + details = None + if before.code is not None: + details = { + "code": before.code, + "reason": before.reason, + "alternatives": before.alternatives or [], + } + return _error_result(before.reason or "Tool execution was blocked", details) + + async def _execute_prepared_tool_call( tc: ToolCall, tool: AgentTool, diff --git a/packages/core/src/agent_core/session/session_manager.py b/packages/core/src/agent_core/session/session_manager.py index fb40340..3658fd8 100644 --- a/packages/core/src/agent_core/session/session_manager.py +++ b/packages/core/src/agent_core/session/session_manager.py @@ -12,6 +12,7 @@ import copy import os from pathlib import Path +from typing import Any from agent_llm import Message @@ -76,6 +77,7 @@ def __init__( agent_dir: Path | None = None, sessions_dir: Path | None = None, in_memory: bool = False, + load_issues: list[dict[str, Any]] | None = None, ) -> None: self.header = header self.entries: list[SessionEntry] = list(entries or []) @@ -84,9 +86,11 @@ def __init__( self.agent_dir: Path | None = agent_dir self.sessions_dir: Path | None = sessions_dir self.in_memory: bool = in_memory + self.load_issues: list[dict[str, Any]] = list(load_issues or []) # Flush-on-first-assistant buffering: # before the file is created we buffer entries in memory. self._flushed: bool = path is not None and (path.exists() if path else False) + self._persisted_count = len(self.entries) if self._flushed else 0 self._buffer: list[SessionEntry] = [] if not self._flushed else list(self.entries) # ─── factories ───────────────────────────────────────────────────── @@ -100,6 +104,7 @@ def create( sessions_dir: Path | None = None, in_memory: bool = False, session_id: str | None = None, + parent_session: str | None = None, ) -> "SessionManager": """Create a brand-new session.""" cwd = cwd or os.getcwd() @@ -109,7 +114,9 @@ def create( if not is_valid_session_id(session_id): raise ValueError(f"Invalid session id: {session_id!r}") ts = iso_now() - header = SessionHeader(id=session_id, timestamp=ts, cwd=cwd) + header = SessionHeader( + id=session_id, timestamp=ts, cwd=cwd, parent_session=parent_session, + ) path = None if in_memory else session_file_path( header, cwd, agent_dir, sessions_dir=sessions_dir, ) @@ -137,7 +144,8 @@ def open( header = read_header(path) if header is None: raise FileNotFoundError(f"Not a valid session file: {path}") - entries = read_entries(path) + load_issues: list[dict[str, Any]] = [] + entries = read_entries(path, issues=load_issues) # 用 compute_leaf_id:如果文件末尾有 LeafEntry,恢复到它指向的叶; # 否则退化为"最后一条 entry",与旧行为完全一致。 leaf_id = compute_leaf_id(entries) @@ -147,6 +155,7 @@ def open( agent_dir=agent_dir, sessions_dir=sessions_dir, in_memory=False, + load_issues=load_issues, ) sm._flushed = True return sm @@ -321,12 +330,16 @@ def set_name(self, name: str) -> SessionInfoEntry: def append_collaboration_mode_change( self, mode: str, *, plan_id: str | None = None, + reason: str | None = None, related_session_id: str | None = None, ) -> CollaborationModeChangeEntry: if mode not in {"default", "plan"}: raise ValueError(f"Invalid collaboration mode: {mode!r}") + if reason not in {None, "user", "handoff"}: + raise ValueError(f"Invalid collaboration transition reason: {reason!r}") entry = CollaborationModeChangeEntry( id=self._next_entry_id(), parent_id=self._parent_for_new_entry(), - timestamp=iso_now(), mode=mode, plan_id=plan_id, # type: ignore[arg-type] + timestamp=iso_now(), mode=mode, plan_id=plan_id, + reason=reason, related_session_id=related_session_id, # type: ignore[arg-type] ) self._commit_v4(entry) return entry @@ -357,13 +370,16 @@ def append_plan_question_answer( def append_plan_revision( self, *, plan_id: str, revision: int, title: str, markdown: str, - digest: str, source_message_id: str, + digest: str, source_message_id: str, schema_version: int = 0, + submitted_by_tool_call_id: str = "", origin_session_id: str | None = None, ) -> PlanRevisionEntry: entry = PlanRevisionEntry( id=self._next_entry_id(), parent_id=self._parent_for_new_entry(), timestamp=iso_now(), plan_id=plan_id, revision=revision, title=title, markdown=markdown, digest=digest, - source_message_id=source_message_id, + source_message_id=source_message_id, schema_version=schema_version, + submitted_by_tool_call_id=submitted_by_tool_call_id, + origin_session_id=origin_session_id, ) self._commit_v4(entry) return entry @@ -371,13 +387,18 @@ def append_plan_revision( def append_plan_run( self, *, plan_id: str, revision: int, digest: str, status: str, run_id: str | None = None, error: str | None = None, + assistant_message_id: str | None = None, stop_reason: str | None = None, ) -> PlanRunEntry: if status not in {"started", "completed", "failed", "aborted"}: raise ValueError(f"Invalid plan run status: {status!r}") + if not isinstance(run_id, str) or not run_id: + raise ValueError("run_id is required for new plan_run entries") entry = PlanRunEntry( id=self._next_entry_id(), parent_id=self._parent_for_new_entry(), timestamp=iso_now(), plan_id=plan_id, revision=revision, - digest=digest, status=status, run_id=run_id, error=error, # type: ignore[arg-type] + digest=digest, status=status, run_id=run_id, error=error, + assistant_message_id=assistant_message_id, + stop_reason=stop_reason, # type: ignore[arg-type] ) self._commit_v4(entry) return entry @@ -386,29 +407,32 @@ def append_plan_run( def _commit(self, entry: SessionEntry) -> None: """Add to memory and persist. Handles flush-on-first-assistant.""" + previous_leaf = self.leaf_id self.entries.append(entry) self.leaf_id = entry.id - if self.in_memory or self.path is None: - return - # Flush-on-first-assistant: only materialize the file once an assistant - # message lands. Until then buffer everything in memory. - is_first_assistant = ( - isinstance(entry, SessionMessageEntry) - and entry.message is not None - and getattr(entry.message, "role", None) == "assistant" - ) - if not self._flushed: - if not is_first_assistant: - # Buffer; nothing on disk yet. + try: + if self.in_memory or self.path is None: return - # First assistant message: create the file with header + all buffered entries. - write_header_line(self.path, self.header) - self._flushed = True - # Flush the buffer (everything except this just-added entry, which - # we append below). - for buffered in self.entries[:-1]: - append_entry_line(self.path, buffered) - append_entry_line(self.path, entry) + # Flush-on-first-assistant: only materialize the file once an assistant + # message lands. Until then buffer everything in memory. + is_first_assistant = ( + isinstance(entry, SessionMessageEntry) + and entry.message is not None + and getattr(entry.message, "role", None) == "assistant" + ) + if not self._flushed: + if not is_first_assistant: + # Buffer; nothing on disk yet. + return + self._persist_buffer() + except BaseException: + # A failed append must not advance the live reducer branch. The + # partially written disk line, if any, is detected as corruption + # when the session is reopened. + if self.entries and self.entries[-1] is entry: + self.entries.pop() + self.leaf_id = previous_leaf + raise def _commit_v4(self, entry: SessionEntry) -> None: """Commit a v4-only entry, upgrading an existing legacy header first.""" @@ -425,18 +449,29 @@ def has_meaningful_activity(self) -> bool: for entry in self.entries ) + def _persist_buffer(self) -> None: + """Resume a partially flushed buffer without losing or duplicating entries.""" + if self.path is None: + return + if not self._flushed: + write_header_line(self.path, self.header) + self._flushed = True + while self._persisted_count < len(self.entries): + append_entry_line(self.path, self.entries[self._persisted_count]) + self._persisted_count += 1 + def flush(self) -> None: - """Force-create the file even if no assistant message has landed yet. + """Force-create and durably flush the current JSONL file. - Useful for /save before any assistant reply. No-op if already flushed - or in-memory. + Useful for Plan authorization boundaries and /save. In-memory sessions + remain a no-op. """ - if self.in_memory or self.path is None or self._flushed: + if self.in_memory or self.path is None: return - write_header_line(self.path, self.header) - self._flushed = True - for buffered in self.entries: - append_entry_line(self.path, buffered) + self._persist_buffer() + with open(self.path, "a", encoding="utf-8") as stream: + stream.flush() + os.fsync(stream.fileno()) # ─── queries ─────────────────────────────────────────────────────── diff --git a/packages/core/src/agent_core/session/storage.py b/packages/core/src/agent_core/session/storage.py index 36632d9..5a83bd3 100644 --- a/packages/core/src/agent_core/session/storage.py +++ b/packages/core/src/agent_core/session/storage.py @@ -22,6 +22,7 @@ import warnings from datetime import datetime, timezone from pathlib import Path +from typing import Any from agent_core.session.serde import dict_to_message, message_to_dict from agent_core.session.types import ( @@ -193,6 +194,7 @@ def entry_to_line_dict(entry: SessionEntry) -> dict: "type": "collaboration_mode_change", "id": entry.id, "parentId": entry.parent_id, "timestamp": entry.timestamp, "mode": entry.mode, "planId": entry.plan_id, + "reason": entry.reason, "relatedSessionId": entry.related_session_id, } if isinstance(entry, PlanQuestionEntry): return { @@ -216,6 +218,9 @@ def entry_to_line_dict(entry: SessionEntry) -> dict: "planId": entry.plan_id, "revision": entry.revision, "title": entry.title, "markdown": entry.markdown, "digest": entry.digest, "sourceMessageId": entry.source_message_id, + "schemaVersion": entry.schema_version, + "submittedByToolCallId": entry.submitted_by_tool_call_id, + "originSessionId": entry.origin_session_id, } if isinstance(entry, PlanRunEntry): return { @@ -224,6 +229,8 @@ def entry_to_line_dict(entry: SessionEntry) -> dict: "planId": entry.plan_id, "revision": entry.revision, "digest": entry.digest, "status": entry.status, "runId": entry.run_id, "error": entry.error, + "assistantMessageId": entry.assistant_message_id, + "stopReason": entry.stop_reason, } raise TypeError(f"Cannot serialize entry of type {type(entry)!r}") @@ -238,6 +245,22 @@ def line_dict_to_entry(d: dict) -> SessionEntry: raise ValueError("Session entry id and timestamp must be strings") if parent_id is not None and not isinstance(parent_id, str): raise ValueError("Session entry parentId must be a string or null") + if etype in {"collaboration_mode_change", "plan_question", "plan_question_answer", "plan_revision", "plan_run"}: + # Accept absent legacy fields, never coerce tampered values (e.g. + # true or "1" into revision 1) into an apparently valid signature. + for field in ("planId", "questionId", "header", "question", "answer", "title", "markdown", + "digest", "sourceMessageId", "submittedByToolCallId"): + if field in d and not isinstance(d[field], str): + if field != "planId" or etype != "collaboration_mode_change" or d[field] is not None: + raise ValueError(f"{etype}.{field} must be a string") + for field in ("revision", "schemaVersion"): + if field in d and type(d[field]) is not int: + raise ValueError(f"{etype}.{field} must be an integer") + for field in ("runId", "error", "assistantMessageId", "stopReason", "originSessionId", "reason", "relatedSessionId"): + if d.get(field) is not None and not isinstance(d[field], str): + raise ValueError(f"{etype}.{field} must be a string or null") + if "allowCustom" in d and not isinstance(d["allowCustom"], bool): + raise ValueError("plan_question.allowCustom must be a boolean") if etype == "message": msg = d.get("message") return SessionMessageEntry( @@ -287,12 +310,17 @@ def line_dict_to_entry(d: dict) -> SessionEntry: raise ValueError(f"Invalid collaboration mode: {mode!r}") return CollaborationModeChangeEntry( mode=mode, plan_id=d.get("planId"), id=entry_id, + reason=d.get("reason"), related_session_id=d.get("relatedSessionId"), parent_id=parent_id, timestamp=timestamp, ) if etype == "plan_question": options = d.get("options") or [] if not isinstance(options, list): raise ValueError("plan_question.options must be a list") + if any(not isinstance(item, dict) or not all( + isinstance(item.get(key), str) for key in ("label", "description") + ) for item in options): + raise ValueError("plan_question.options must contain string labels and descriptions") return PlanQuestionEntry( plan_id=str(d.get("planId", "")), question_id=str(d.get("questionId", "")), @@ -314,6 +342,9 @@ def line_dict_to_entry(d: dict) -> SessionEntry: title=str(d.get("title", "")), markdown=str(d.get("markdown", "")), digest=str(d.get("digest", "")), source_message_id=str(d.get("sourceMessageId", "")), id=entry_id, + schema_version=int(d.get("schemaVersion", 0) or 0), + submitted_by_tool_call_id=str(d.get("submittedByToolCallId", "")), + origin_session_id=d.get("originSessionId"), parent_id=parent_id, timestamp=timestamp, ) if etype == "plan_run": @@ -324,6 +355,8 @@ def line_dict_to_entry(d: dict) -> SessionEntry: plan_id=str(d.get("planId", "")), revision=int(d.get("revision", 0) or 0), digest=str(d.get("digest", "")), status=status, run_id=d.get("runId"), error=d.get("error"), id=entry_id, + assistant_message_id=d.get("assistantMessageId"), + stop_reason=d.get("stopReason"), parent_id=parent_id, timestamp=timestamp, ) raise ValueError(f"Unknown entry type in session file: {etype!r}") @@ -384,9 +417,24 @@ def rewrite_header_line(path: Path, header: SessionHeader) -> None: def append_entry_line(path: Path, entry: SessionEntry) -> None: """Append one entry as a JSON line (append-only).""" - with open(path, "a", encoding="utf-8") as f: - f.write(json.dumps(entry_to_line_dict(entry), ensure_ascii=False)) - f.write("\n") + encoded = (json.dumps(entry_to_line_dict(entry), ensure_ascii=False) + "\n").encode("utf-8") + durable = isinstance(entry, ( + CollaborationModeChangeEntry, PlanQuestionEntry, PlanQuestionAnswerEntry, + PlanRevisionEntry, PlanRunEntry, + )) + with open(path, "ab", buffering=0) as stream: + offset = stream.tell() + try: + written = stream.write(encoded) + if written != len(encoded): + raise OSError("Incomplete session entry write") + if durable: + os.fsync(stream.fileno()) + except BaseException: + # Undo only this attempted JSONL append. Never rewrite earlier + # session history; a process crash remains detectable on replay. + stream.truncate(offset) + raise # ─── file read ──────────────────────────────────────────────────────── @@ -403,25 +451,75 @@ def read_header(path: Path) -> SessionHeader | None: return None -def read_entries(path: Path) -> list[SessionEntry]: +def read_entries( + path: Path, + *, + issues: list[dict[str, Any]] | None = None, +) -> list[SessionEntry]: """Read all valid entries and warn when damaged JSONL lines are skipped.""" entries: list[SessionEntry] = [] corrupt_lines: list[int] = [] + seen_ids: set[str] = set() + + def record_issue(line_number: int, code: str, message: str) -> None: + corrupt_lines.append(line_number) + if issues is not None: + issues.append({ + "line": line_number, + "code": code, + "message": message, + }) + try: - with open(path, "r", encoding="utf-8") as f: + # Decode line-by-line so one invalid UTF-8 tail cannot hide every + # otherwise valid entry that precedes or follows it. + with open(path, "rb") as f: first = True - for line_number, line in enumerate(f, start=1): + for line_number, raw_line in enumerate(f, start=1): if first: # skip header first = False continue - line = line.strip() + try: + line = raw_line.decode("utf-8").strip() + except UnicodeDecodeError as exc: + record_issue(line_number, "INVALID_UTF8", str(exc)) + continue if not line: continue try: - entries.append(line_dict_to_entry(json.loads(line))) - except (ValueError, KeyError): - # Skip corrupt/trailing entries but keep the rest. - corrupt_lines.append(line_number) + payload = json.loads(line) + if not isinstance(payload, dict): + raise ValueError("session entry must be a JSON object") + entry = line_dict_to_entry(payload) + if not entry.id: + raise ValueError("session entry id must not be empty") + if entry.id in seen_ids: + raise ValueError(f"duplicate session entry id: {entry.id}") + if entry.parent_id == entry.id: + raise ValueError("session entry cannot parent itself") + if entry.parent_id is not None and entry.parent_id not in seen_ids: + record_issue( + line_number, + "DANGLING_PARENT", + f"unknown parentId: {entry.parent_id}", + ) + if ( + isinstance(entry, LeafEntry) + and entry.target_id is not None + and entry.target_id not in seen_ids + ): + record_issue( + line_number, + "INVALID_LEAF_TARGET", + f"unknown leaf targetId: {entry.target_id}", + ) + setattr(entry, "_source_line", line_number) + entries.append(entry) + seen_ids.add(entry.id) + except (AttributeError, KeyError, TypeError, ValueError) as exc: + # Skip corrupt/trailing entries but keep the rest and make + # the damage available to the Plan recovery reducer. + record_issue(line_number, "CORRUPT_SESSION_ENTRY", str(exc)) continue except OSError: pass diff --git a/packages/core/src/agent_core/session/types.py b/packages/core/src/agent_core/session/types.py index 870bb4e..f2c2afc 100644 --- a/packages/core/src/agent_core/session/types.py +++ b/packages/core/src/agent_core/session/types.py @@ -141,6 +141,8 @@ class CollaborationModeChangeEntry(SessionEntry): type: Literal["collaboration_mode_change"] = "collaboration_mode_change" mode: Literal["default", "plan"] = "default" plan_id: str | None = None + reason: Literal["user", "handoff"] | None = None + related_session_id: str | None = None @dataclass(kw_only=True) @@ -177,6 +179,9 @@ class PlanRevisionEntry(SessionEntry): markdown: str = "" digest: str = "" source_message_id: str = "" + schema_version: int = 0 + submitted_by_tool_call_id: str = "" + origin_session_id: str | None = None @dataclass(kw_only=True) @@ -190,6 +195,8 @@ class PlanRunEntry(SessionEntry): status: Literal["started", "completed", "failed", "aborted"] = "started" run_id: str | None = None error: str | None = None + assistant_message_id: str | None = None + stop_reason: str | None = None # ─── 会话树节点(供 UI 渲染) ────────────────────────────────────────── diff --git a/packages/core/src/agent_core/tools/__init__.py b/packages/core/src/agent_core/tools/__init__.py index 776c745..036a8e4 100644 --- a/packages/core/src/agent_core/tools/__init__.py +++ b/packages/core/src/agent_core/tools/__init__.py @@ -3,6 +3,16 @@ from agent_core.tools.edit import EDIT_SCHEMA, EditTool from agent_core.tools.find import FIND_SCHEMA, FindTool from agent_core.tools.grep import GREP_SCHEMA, GrepTool +from agent_core.tools.git import ( + GIT_DIFF_SCHEMA, + GIT_LOG_SCHEMA, + GIT_SHOW_SCHEMA, + GIT_STATUS_SCHEMA, + GitDiffTool, + GitLogTool, + GitShowTool, + GitStatusTool, +) from agent_core.tools.ls import LS_SCHEMA, LsTool from agent_core.tools.read import READ_SCHEMA, ReadTool from agent_core.tools.write import WRITE_SCHEMA, WriteTool @@ -18,6 +28,14 @@ "EDIT_SCHEMA", "GrepTool", "GREP_SCHEMA", + "GitStatusTool", + "GIT_STATUS_SCHEMA", + "GitLogTool", + "GIT_LOG_SCHEMA", + "GitDiffTool", + "GIT_DIFF_SCHEMA", + "GitShowTool", + "GIT_SHOW_SCHEMA", "FindTool", "FIND_SCHEMA", "LsTool", diff --git a/packages/core/src/agent_core/tools/bash.py b/packages/core/src/agent_core/tools/bash.py index b40bdba..64c2198 100644 --- a/packages/core/src/agent_core/tools/bash.py +++ b/packages/core/src/agent_core/tools/bash.py @@ -20,7 +20,7 @@ from agent_llm import TextContent from agent_core.shell import ShellConfig, get_shell_config -from agent_core.types import AgentToolResult +from agent_core.types import AgentToolResult, PlanAccess BASH_SCHEMA: dict = { @@ -93,6 +93,7 @@ class BashTool: name: str = "bash" effect: str = "shell" + plan_access: PlanAccess = "deny" label: str = "bash" description: str = ( f"Execute a bash command in the current working directory. Returns " diff --git a/packages/core/src/agent_core/tools/edit.py b/packages/core/src/agent_core/tools/edit.py index 9367703..258f2ed 100644 --- a/packages/core/src/agent_core/tools/edit.py +++ b/packages/core/src/agent_core/tools/edit.py @@ -27,7 +27,7 @@ from agent_llm import TextContent from agent_core.tools._mutation import file_mutation_lock -from agent_core.types import AgentToolResult +from agent_core.types import AgentToolResult, PlanAccess EDIT_SCHEMA: dict = { "type": "object", @@ -306,6 +306,7 @@ class EditTool: name: str = "edit" effect: str = "write" + plan_access: PlanAccess = "deny" label: str = "edit" description: str = ( "Edit a single file using exact text replacement. Every edits[].oldText " diff --git a/packages/core/src/agent_core/tools/find.py b/packages/core/src/agent_core/tools/find.py index 770c408..e574853 100644 --- a/packages/core/src/agent_core/tools/find.py +++ b/packages/core/src/agent_core/tools/find.py @@ -13,7 +13,7 @@ from agent_llm import TextContent -from agent_core.types import AgentToolResult +from agent_core.types import AgentToolResult, PlanAccess from agent_core.tools._subprocess import find_in_path, head_truncate_bytes from agent_core.tools._gitignore import is_ignored @@ -52,6 +52,7 @@ class FindTool: name: str = "find" effect: str = "read" + plan_access: PlanAccess = "observe" label: str = "find" description: str = ( f"Search for files by glob pattern. Returns matching file paths relative " @@ -64,9 +65,18 @@ class FindTool: "Use find to locate files by name or extension before searching their contents.", ] - def __init__(self, cwd: str = ".", *, limit: int = DEFAULT_LIMIT) -> None: + def __init__( + self, + cwd: str = ".", + *, + limit: int = DEFAULT_LIMIT, + prefer_external: bool = True, + ) -> None: self.cwd = cwd self.limit = limit + # Plan mode sets this to False so repository-controlled PATH entries + # can never turn a read-only search into external code execution. + self.prefer_external = prefer_external async def execute( self, @@ -83,7 +93,7 @@ async def execute( if not os.path.isdir(full_path): raise FileNotFoundError(f"Path not found: {path}") - fd = find_in_path("fd") + fd = find_in_path("fd") if self.prefer_external else None if fd: try: text = await self._run_with_fd(fd, pattern, full_path, limit) diff --git a/packages/core/src/agent_core/tools/git.py b/packages/core/src/agent_core/tools/git.py new file mode 100644 index 0000000..0bfe485 --- /dev/null +++ b/packages/core/src/agent_core/tools/git.py @@ -0,0 +1,379 @@ +"""Structured, read-only Git tools for Plan mode. + +The tools in this module never invoke a shell and expose no free-form Git +arguments. Each command is assembled from a fixed argv template, with +repository-controlled pagers, prompts, fsmonitor helpers, external diff +drivers, and textconv helpers disabled where applicable. +""" +from __future__ import annotations + +import asyncio +import os +import re +from typing import Any + +from agent_llm import TextContent + +from agent_core.tools._subprocess import find_in_path +from agent_core.tools.bash import _decode_output +from agent_core.types import AgentToolResult, PlanAccess + +DEFAULT_TIMEOUT = 30.0 +DEFAULT_MAX_BYTES = 100 * 1024 +MAX_STDERR_BYTES = 16 * 1024 +MAX_REVISION_LENGTH = 256 +MAX_PATH_LENGTH = 4096 +MAX_LOG_ENTRIES = 200 + +_REVISION_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._/@{}~^+\-]*\Z") +_WINDOWS_ABSOLUTE_RE = re.compile(r"\A[A-Za-z]:[\\/]") + +_PATH_PROPERTY = { + "type": "string", + "description": "Optional literal repository-relative path (not a Git pathspec).", + "minLength": 1, + "maxLength": MAX_PATH_LENGTH, +} +_REVISION_PROPERTY = { + "type": "string", + "description": "Optional Git revision or revision range, such as HEAD, HEAD~1, or main..topic.", + "minLength": 1, + "maxLength": MAX_REVISION_LENGTH, +} + +GIT_STATUS_SCHEMA: dict = { + "type": "object", + "properties": {"path": _PATH_PROPERTY}, + "additionalProperties": False, +} + +GIT_LOG_SCHEMA: dict = { + "type": "object", + "properties": { + "revision": _REVISION_PROPERTY, + "path": _PATH_PROPERTY, + "limit": { + "type": "integer", + "description": f"Maximum commits to return (default 20, maximum {MAX_LOG_ENTRIES}).", + "minimum": 1, + "maximum": MAX_LOG_ENTRIES, + }, + }, + "additionalProperties": False, +} + +GIT_DIFF_SCHEMA: dict = { + "type": "object", + "properties": { + "revision": _REVISION_PROPERTY, + "path": _PATH_PROPERTY, + }, + "additionalProperties": False, +} + +GIT_SHOW_SCHEMA: dict = { + "type": "object", + "properties": { + "revision": { + **_REVISION_PROPERTY, + "description": "Git revision to inspect (default HEAD).", + }, + "path": _PATH_PROPERTY, + }, + "additionalProperties": False, +} + + +def _contains_control(value: str) -> bool: + return any(ord(char) < 32 or ord(char) == 127 for char in value) + + +def _validate_revision(value: Any, *, default: str | None = None) -> str | None: + if value is None: + return default + if not isinstance(value, str): + raise ValueError("revision must be a string") + if not value or len(value) > MAX_REVISION_LENGTH: + raise ValueError(f"revision must contain 1-{MAX_REVISION_LENGTH} characters") + if value.startswith("-") or _contains_control(value) or not _REVISION_RE.fullmatch(value): + raise ValueError("revision contains unsupported characters") + return value + + +def _validate_path(value: Any) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise ValueError("path must be a string") + if not value or len(value) > MAX_PATH_LENGTH: + raise ValueError(f"path must contain 1-{MAX_PATH_LENGTH} characters") + if value.startswith("-") or _contains_control(value): + raise ValueError("path contains unsupported characters") + normalized = value.replace("\\", "/") + if normalized.startswith("/") or _WINDOWS_ABSOLUTE_RE.match(value): + raise ValueError("path must be repository-relative") + if any(part == ".." for part in normalized.split("/")): + raise ValueError("path must not escape the repository") + return value + + +def _validate_limit(value: Any) -> int: + if value is None: + return 20 + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError("limit must be an integer") + if not 1 <= value <= MAX_LOG_ENTRIES: + raise ValueError(f"limit must be between 1 and {MAX_LOG_ENTRIES}") + return value + + +async def _read_limited( + stream: asyncio.StreamReader | None, + max_bytes: int, +) -> tuple[bytes, bool]: + if stream is None: + return b"", False + retained = bytearray() + truncated = False + while True: + chunk = await stream.read(64 * 1024) + if not chunk: + break + remaining = max_bytes - len(retained) + if remaining > 0: + retained.extend(chunk[:remaining]) + if len(chunk) > remaining: + truncated = True + return bytes(retained), truncated + + +class _StructuredGitTool: + effect: str = "read" + plan_access: PlanAccess = "observe" + + def __init__( + self, + cwd: str = ".", + *, + timeout: float = DEFAULT_TIMEOUT, + max_bytes: int = DEFAULT_MAX_BYTES, + git_path: str | None = None, + ) -> None: + if timeout <= 0: + raise ValueError("timeout must be positive") + if max_bytes <= 0: + raise ValueError("max_bytes must be positive") + self.cwd = os.path.abspath(cwd) + self.timeout = float(timeout) + self.max_bytes = int(max_bytes) + self.git_path = git_path + + def _argv(self, command_args: list[str]) -> list[str]: + git_path = self.git_path or find_in_path("git") + if not git_path: + raise RuntimeError("Git executable not found on PATH") + return [ + git_path, + "--no-pager", + "--literal-pathspecs", + "-c", + "core.fsmonitor=false", + *command_args, + ] + + async def _run(self, command_args: list[str]) -> AgentToolResult: + argv = self._argv(command_args) + env = os.environ.copy() + env.update( + { + "GIT_PAGER": "cat", + "GIT_TERMINAL_PROMPT": "0", + "GIT_OPTIONAL_LOCKS": "0", + } + ) + try: + process = await asyncio.create_subprocess_exec( + *argv, + cwd=self.cwd, + env=env, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError as exc: + raise RuntimeError(f"Failed to run Git: {exc}") from exc + + stdout_task = asyncio.create_task(_read_limited(process.stdout, self.max_bytes)) + stderr_task = asyncio.create_task(_read_limited(process.stderr, MAX_STDERR_BYTES)) + try: + stdout_result, stderr_result = await asyncio.wait_for( + asyncio.gather(stdout_task, stderr_task), + timeout=self.timeout, + ) + await process.wait() + except TimeoutError as exc: + try: + process.kill() + except ProcessLookupError: + pass + await process.wait() + await asyncio.gather(stdout_task, stderr_task, return_exceptions=True) + raise RuntimeError(f"Git command timed out after {self.timeout:g}s") from exc + except asyncio.CancelledError: + try: + process.kill() + except ProcessLookupError: + pass + await process.wait() + await asyncio.gather(stdout_task, stderr_task, return_exceptions=True) + raise + + stdout_bytes, stdout_truncated = stdout_result + stderr_bytes, stderr_truncated = stderr_result + stdout = _decode_output(stdout_bytes) + stderr = _decode_output(stderr_bytes) + exit_code = process.returncode if process.returncode is not None else -1 + if exit_code != 0: + reason = stderr.strip() or stdout.strip() or "no diagnostic output" + if stderr_truncated: + reason += f"\n[stderr truncated at {MAX_STDERR_BYTES} bytes]" + raise RuntimeError(f"Git command failed with exit code {exit_code}: {reason}") + + text = stdout.rstrip() + if stdout_truncated: + marker = f"[output truncated at {self.max_bytes} bytes]" + text = f"{text}\n\n{marker}" if text else marker + if not text: + text = "(no output)" + return AgentToolResult( + content=[TextContent(text=text)], + details={ + "exit_code": exit_code, + "output_truncated": stdout_truncated, + }, + ) + + +class GitStatusTool(_StructuredGitTool): + """Show a bounded, machine-stable summary of repository status.""" + + name: str = "git_status" + label: str = "git status" + description: str = "Show branch and working-tree status without invoking a shell, pager, or fsmonitor helper." + parameters: dict = GIT_STATUS_SCHEMA + prompt_snippet: str = "Inspect Git branch and working-tree status safely" + + async def execute( + self, + tool_call_id: str, + params: dict, + signal: Any = None, + ) -> AgentToolResult: + path = _validate_path(params.get("path")) + args = ["status", "--short", "--branch", "--untracked-files=normal"] + if path is not None: + args += ["--", path] + return await self._run(args) + + +class GitLogTool(_StructuredGitTool): + """Show commit metadata without patches or caller-provided formatting.""" + + name: str = "git_log" + label: str = "git log" + description: str = "Show bounded commit metadata; patches and arbitrary Git arguments are disabled." + parameters: dict = GIT_LOG_SCHEMA + prompt_snippet: str = "Inspect Git commit history safely" + + async def execute( + self, + tool_call_id: str, + params: dict, + signal: Any = None, + ) -> AgentToolResult: + revision = _validate_revision(params.get("revision")) + path = _validate_path(params.get("path")) + limit = _validate_limit(params.get("limit")) + args = [ + "log", + "--no-patch", + "--decorate=no", + "--date=iso-strict", + f"--max-count={limit}", + "--pretty=format:%H%x09%ad%x09%an%x09%s", + ] + if revision is not None: + args.append(revision) + if path is not None: + args += ["--", path] + return await self._run(args) + + +class GitDiffTool(_StructuredGitTool): + """Show a patch with external diff and text conversion disabled.""" + + name: str = "git_diff" + label: str = "git diff" + description: str = "Show a bounded Git diff without shell, external diff drivers, or textconv helpers." + parameters: dict = GIT_DIFF_SCHEMA + prompt_snippet: str = "Inspect Git changes safely" + + async def execute( + self, + tool_call_id: str, + params: dict, + signal: Any = None, + ) -> AgentToolResult: + revision = _validate_revision(params.get("revision")) + path = _validate_path(params.get("path")) + args = ["diff", "--no-ext-diff", "--no-textconv", "--no-color"] + if revision is not None: + args.append(revision) + if path is not None: + args += ["--", path] + return await self._run(args) + + +class GitShowTool(_StructuredGitTool): + """Show a revision with external diff and text conversion disabled.""" + + name: str = "git_show" + label: str = "git show" + description: str = "Show a bounded Git revision without shell, external diff drivers, or textconv helpers." + parameters: dict = GIT_SHOW_SCHEMA + prompt_snippet: str = "Inspect a Git revision safely" + + async def execute( + self, + tool_call_id: str, + params: dict, + signal: Any = None, + ) -> AgentToolResult: + revision = _validate_revision(params.get("revision"), default="HEAD") + path = _validate_path(params.get("path")) + assert revision is not None + args = [ + "show", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "--decorate=no", + "--date=iso-strict", + "--format=fuller", + revision, + ] + if path is not None: + args += ["--", path] + return await self._run(args) + + +__all__ = [ + "GIT_STATUS_SCHEMA", + "GIT_LOG_SCHEMA", + "GIT_DIFF_SCHEMA", + "GIT_SHOW_SCHEMA", + "GitStatusTool", + "GitLogTool", + "GitDiffTool", + "GitShowTool", +] diff --git a/packages/core/src/agent_core/tools/grep.py b/packages/core/src/agent_core/tools/grep.py index f55c442..ee35598 100644 --- a/packages/core/src/agent_core/tools/grep.py +++ b/packages/core/src/agent_core/tools/grep.py @@ -19,7 +19,7 @@ from agent_llm import TextContent -from agent_core.types import AgentToolResult +from agent_core.types import AgentToolResult, PlanAccess from agent_core.tools._subprocess import ( find_in_path, head_truncate_bytes, @@ -79,6 +79,7 @@ class GrepTool: name: str = "grep" effect: str = "read" + plan_access: PlanAccess = "observe" label: str = "grep" description: str = ( f"Search file contents for a pattern. Returns matching lines with file " @@ -92,9 +93,18 @@ class GrepTool: "Use grep to find code, definitions, or usages instead of reading files blindly.", ] - def __init__(self, cwd: str = ".", *, limit: int = DEFAULT_LIMIT) -> None: + def __init__( + self, + cwd: str = ".", + *, + limit: int = DEFAULT_LIMIT, + prefer_external: bool = True, + ) -> None: self.cwd = cwd self.limit = limit + # Plan mode sets this to False so repository-controlled PATH entries + # can never turn a read-only search into external code execution. + self.prefer_external = prefer_external async def execute( self, @@ -116,7 +126,7 @@ async def execute( raise FileNotFoundError(f"Path not found: {path}") # Prefer ripgrep; fall back to pure Python. - rg = find_in_path("rg") + rg = find_in_path("rg") if self.prefer_external else None if rg: try: text = await self._run_with_rg( @@ -149,7 +159,16 @@ async def _run_with_rg( literal: bool, limit: int, ) -> str: - args = [rg, "--json", "--line-number", "--color=never", "--hidden"] + args = [ + rg, + "--json", + "--line-number", + "--color=never", + "--hidden", + # Respect .gitignore files even when the searched directory is not + # itself inside a Git worktree (for example, temporary test dirs). + "--no-require-git", + ] if ignore_case: args.append("--ignore-case") if literal: diff --git a/packages/core/src/agent_core/tools/ls.py b/packages/core/src/agent_core/tools/ls.py index 6b1e7e7..6bcc94c 100644 --- a/packages/core/src/agent_core/tools/ls.py +++ b/packages/core/src/agent_core/tools/ls.py @@ -11,7 +11,7 @@ from agent_llm import TextContent -from agent_core.types import AgentToolResult +from agent_core.types import AgentToolResult, PlanAccess LS_SCHEMA: dict = { "type": "object", @@ -43,6 +43,7 @@ class LsTool: name: str = "ls" effect: str = "read" + plan_access: PlanAccess = "observe" label: str = "ls" description: str = ( f"List directory contents. Returns entries sorted alphabetically, with " diff --git a/packages/core/src/agent_core/tools/read.py b/packages/core/src/agent_core/tools/read.py index 17aeffe..d7f3057 100644 --- a/packages/core/src/agent_core/tools/read.py +++ b/packages/core/src/agent_core/tools/read.py @@ -11,7 +11,7 @@ from agent_llm import TextContent -from agent_core.types import AgentToolResult +from agent_core.types import AgentToolResult, PlanAccess READ_SCHEMA: dict = { @@ -47,6 +47,7 @@ class ReadTool: name: str = "read" effect: str = "read" + plan_access: PlanAccess = "observe" label: str = "read" description: str = "Read the contents of a file." parameters: dict = READ_SCHEMA diff --git a/packages/core/src/agent_core/tools/write.py b/packages/core/src/agent_core/tools/write.py index 3b67d91..1631c84 100644 --- a/packages/core/src/agent_core/tools/write.py +++ b/packages/core/src/agent_core/tools/write.py @@ -15,7 +15,7 @@ from agent_llm import TextContent from agent_core.tools._mutation import file_mutation_lock -from agent_core.types import AgentToolResult +from agent_core.types import AgentToolResult, PlanAccess WRITE_SCHEMA: dict = { @@ -46,6 +46,7 @@ class WriteTool: name: str = "write" effect: str = "write" + plan_access: PlanAccess = "deny" label: str = "write" description: str = ( "Write content to a file. Creates the file if it doesn't exist, " diff --git a/packages/core/src/agent_core/types.py b/packages/core/src/agent_core/types.py index f0632e4..6e942e4 100644 --- a/packages/core/src/agent_core/types.py +++ b/packages/core/src/agent_core/types.py @@ -50,6 +50,10 @@ class AgentToolResult: #: Coarse side-effect classification used by collaboration-mode policy. ToolEffect = Literal["read", "write", "shell", "control", "unknown"] +#: Explicit Plan-mode access classification. Callers must treat a missing +#: declaration as ``"deny"`` so third-party/custom tools fail closed. +PlanAccess = Literal["observe", "control", "deny"] + #: Queue drain policy. "all" drains every queued message in one #: drain call; "one-at-a-time" drains only the oldest, leaving the rest for a #: later drain. Both steering and follow-up queues default to "one-at-a-time". @@ -87,6 +91,7 @@ class AgentTool(Protocol): prepare_arguments: Callable[[dict], dict] execution_mode: ToolExecutionMode effect: ToolEffect + plan_access: PlanAccess async def execute( self, @@ -142,6 +147,8 @@ class BeforeToolCallResult: args.""" block: bool = False reason: str | None = None + code: str | None = None + alternatives: list[dict[str, Any]] | None = None @dataclass @@ -190,6 +197,11 @@ class AgentLoopConfig: # naturally stops. Sync or async return values both accepted. get_steering_messages: "Callable[[], Awaitable[list] | list] | None" = None get_follow_up_messages: "Callable[[], Awaitable[list] | list] | None" = None + # Re-read mutable runtime policy after newly emitted user/steering messages + # have been persisted and reduced by subscribers. The active loop keeps + # its transcript, but adopts the refreshed system prompt and tool set + # before the next model request. + refresh_context: "Callable[[], Awaitable[AgentContext] | AgentContext] | None" = None # ─── Events ─────────────────────────────────── diff --git a/packages/core/tests/test_plan_tool_access.py b/packages/core/tests/test_plan_tool_access.py new file mode 100644 index 0000000..ff93f38 --- /dev/null +++ b/packages/core/tests/test_plan_tool_access.py @@ -0,0 +1,78 @@ +"""Plan-mode access metadata and subprocess-free search profiles.""" +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from agent_core.tools import ( + BashTool, + EditTool, + FindTool, + GitDiffTool, + GitLogTool, + GitShowTool, + GitStatusTool, + GrepTool, + LsTool, + ReadTool, + WriteTool, +) + + +@pytest.mark.parametrize( + ("tool_type", "expected"), + [ + (ReadTool, "observe"), + (GrepTool, "observe"), + (FindTool, "observe"), + (LsTool, "observe"), + (GitStatusTool, "observe"), + (GitLogTool, "observe"), + (GitDiffTool, "observe"), + (GitShowTool, "observe"), + (WriteTool, "deny"), + (EditTool, "deny"), + (BashTool, "deny"), + ], +) +def test_builtin_tools_declare_plan_access(tool_type, expected: str) -> None: + assert tool_type.plan_access == expected + + +def test_missing_plan_access_fails_closed_by_default() -> None: + class CustomTool: + pass + + assert getattr(CustomTool(), "plan_access", "deny") == "deny" + + +def test_grep_plan_profile_never_resolves_external_binary( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "source.py").write_text("needle\n", encoding="utf-8") + + def unexpected_lookup(_binary: str) -> str: + raise AssertionError("Plan-safe grep must not search PATH") + + monkeypatch.setattr("agent_core.tools.grep.find_in_path", unexpected_lookup) + tool = GrepTool(cwd=str(tmp_path), prefer_external=False) + result = asyncio.run(tool.execute("grep-1", {"pattern": "needle"})) + assert "source.py:1: needle" in result.content[0].text + + +def test_find_plan_profile_never_resolves_external_binary( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "source.py").write_text("content\n", encoding="utf-8") + + def unexpected_lookup(_binary: str) -> str: + raise AssertionError("Plan-safe find must not search PATH") + + monkeypatch.setattr("agent_core.tools.find.find_in_path", unexpected_lookup) + tool = FindTool(cwd=str(tmp_path), prefer_external=False) + result = asyncio.run(tool.execute("find-1", {"pattern": "*.py"})) + assert result.content[0].text == "source.py" diff --git a/packages/core/tests/test_tools_git.py b/packages/core/tests/test_tools_git.py new file mode 100644 index 0000000..0ee5c9e --- /dev/null +++ b/packages/core/tests/test_tools_git.py @@ -0,0 +1,223 @@ +"""Tests for shell-free, read-only Git tools.""" +from __future__ import annotations + +import asyncio +import os +import shlex +import subprocess +import sys +from pathlib import Path + +import pytest + +from agent_core.tools._subprocess import find_in_path +from agent_core.tools.git import ( + GitDiffTool, + GitLogTool, + GitShowTool, + GitStatusTool, +) + +GIT = find_in_path("git") +pytestmark = pytest.mark.skipif(GIT is None, reason="Git is not installed") + + +def _run(coro): + return asyncio.run(coro) + + +def _git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + assert GIT is not None + env = os.environ.copy() + env.update({"GIT_TERMINAL_PROMPT": "0", "GIT_CONFIG_NOSYSTEM": "1"}) + return subprocess.run( + [GIT, *args], + cwd=repo, + env=env, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + _git(tmp_path, "init", "--quiet") + _git(tmp_path, "config", "user.name", "Plan Tool Tests") + _git(tmp_path, "config", "user.email", "plan-tools@example.invalid") + (tmp_path / "tracked.txt").write_text("before\n", encoding="utf-8") + _git(tmp_path, "add", "tracked.txt") + _git(tmp_path, "commit", "--quiet", "-m", "initial") + return tmp_path + + +def test_structured_git_tools_return_expected_views(repository: Path) -> None: + (repository / "tracked.txt").write_text("after\n", encoding="utf-8") + + status = _run(GitStatusTool(str(repository)).execute("status", {})).content[0].text + log = _run(GitLogTool(str(repository)).execute("log", {"limit": 1})).content[0].text + diff = _run(GitDiffTool(str(repository)).execute("diff", {"path": "tracked.txt"})).content[0].text + show = _run(GitShowTool(str(repository)).execute("show", {"revision": "HEAD"})).content[0].text + + assert "tracked.txt" in status + assert "initial" in log + assert "-before" in diff and "+after" in diff + assert "commit " in show and "initial" in show + + +@pytest.mark.parametrize( + ("tool", "params", "message"), + [ + (GitLogTool(), {"revision": "--all"}, "revision"), + (GitDiffTool(), {"revision": "HEAD\n--output=x"}, "revision"), + (GitShowTool(), {"revision": "HEAD:secret"}, "revision"), + (GitStatusTool(), {"path": "-outside"}, "path"), + (GitDiffTool(), {"path": "../outside"}, "path"), + (GitShowTool(), {"path": "C:/outside"}, "path"), + (GitLogTool(), {"limit": 201}, "limit"), + ], +) +def test_structured_git_tools_reject_unsafe_inputs(tool, params: dict, message: str) -> None: + with pytest.raises(ValueError, match=message): + _run(tool.execute("unsafe", params)) + + +def _helper_command(helper: Path) -> str: + # Git executes configured helpers through its shell. POSIX quoting also + # works with Git for Windows' bundled sh, and forward slashes avoid escape + # interpretation in Windows paths. + return shlex.join([Path(sys.executable).as_posix(), helper.as_posix()]) + + +def _write_sentinel_helper(helper: Path, sentinel: Path) -> None: + helper.write_text( + "from pathlib import Path\n" + f"Path({str(sentinel)!r}).write_text('called', encoding='utf-8')\n" + "print('helper output')\n", + encoding="utf-8", + ) + + +def test_git_diff_disables_external_diff_driver(repository: Path) -> None: + sentinel = repository / "external-diff-called" + helper = repository / "external_diff.py" + _write_sentinel_helper(helper, sentinel) + _git(repository, "config", "diff.external", _helper_command(helper)) + (repository / "tracked.txt").write_text("changed\n", encoding="utf-8") + + _git(repository, "diff", "--ext-diff") + assert sentinel.exists(), "control check: Git did not invoke configured diff.external" + sentinel.unlink() + + _run(GitDiffTool(str(repository)).execute("diff", {})) + assert not sentinel.exists() + + +def test_git_show_disables_textconv_helper(repository: Path) -> None: + sentinel = repository / "textconv-called" + helper = repository / "textconv.py" + _write_sentinel_helper(helper, sentinel) + (repository / ".gitattributes").write_text("*.txt diff=unsafe\n", encoding="utf-8") + _git(repository, "config", "diff.unsafe.textconv", _helper_command(helper)) + (repository / "tracked.txt").write_text("after\n", encoding="utf-8") + _git(repository, "add", ".gitattributes", "tracked.txt") + _git(repository, "commit", "--quiet", "-m", "textconv target") + + _git(repository, "show", "--textconv", "HEAD") + assert sentinel.exists(), "control check: Git did not invoke configured textconv" + sentinel.unlink() + + _run(GitShowTool(str(repository)).execute("show", {"revision": "HEAD"})) + assert not sentinel.exists() + + +class _FakeStream: + def __init__(self, data: bytes) -> None: + self._data = data + + async def read(self, _size: int) -> bytes: + data, self._data = self._data, b"" + return data + + +class _FakeProcess: + def __init__(self, stdout: bytes = b"ok", stderr: bytes = b"") -> None: + self.stdout = _FakeStream(stdout) + self.stderr = _FakeStream(stderr) + self.returncode = 0 + + async def wait(self) -> int: + return self.returncode + + def kill(self) -> None: + self.returncode = -9 + + +class _HangingStream: + async def read(self, _size: int) -> bytes: + await asyncio.Event().wait() + return b"" + + +class _HangingProcess(_FakeProcess): + def __init__(self) -> None: + super().__init__() + self.stdout = _HangingStream() + self.stderr = _HangingStream() + self.killed = False + + def kill(self) -> None: + self.killed = True + super().kill() + + +def test_git_runner_uses_fixed_argv_environment_and_output_cap( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict = {} + + async def fake_create(*argv, **kwargs): + captured["argv"] = argv + captured["kwargs"] = kwargs + return _FakeProcess(stdout=b"0123456789") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create) + tool = GitDiffTool(str(tmp_path), git_path="trusted-git", max_bytes=5) + result = _run(tool.execute("diff", {"revision": "HEAD", "path": "safe.txt"})) + + argv = captured["argv"] + assert argv[:6] == ( + "trusted-git", + "--no-pager", + "--literal-pathspecs", + "-c", + "core.fsmonitor=false", + "diff", + ) + assert "--no-ext-diff" in argv + assert "--no-textconv" in argv + assert "--no-color" in argv + assert argv[-2:] == ("--", "safe.txt") + env = captured["kwargs"]["env"] + assert env["GIT_PAGER"] == "cat" + assert env["GIT_TERMINAL_PROMPT"] == "0" + assert env["GIT_OPTIONAL_LOCKS"] == "0" + assert "output truncated at 5 bytes" in result.content[0].text + + +def test_git_runner_kills_process_on_timeout( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + process = _HangingProcess() + + async def fake_create(*_argv, **_kwargs): + return process + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create) + tool = GitStatusTool(str(tmp_path), git_path="trusted-git", timeout=0.01) + with pytest.raises(RuntimeError, match="timed out"): + _run(tool.execute("status", {})) + assert process.killed is True From d43ec0c2f4b9bccaba9cc741a7472fccc412592b Mon Sep 17 00:00:00 2001 From: JiuXiang <904085642@qq.com> Date: Tue, 8 Sep 2026 00:20:05 +0800 Subject: [PATCH 2/2] fix(plan): restore handoff review context and JSONL boundaries Handoff revisions were persisted outside model messages, leaving review prompts without the source plan. Project the active handed-off revision into planning context without copying source dialogue or authorizing execution. Unterminated JSONL tails swallowed later recovery entries. Append a separator when needed while preserving damaged history and rolling back the separator on write failure. Add regression coverage for attached and reopened handoff review, branch-local context, truncated-tail recovery, valid tail endings, and failed append rollback. Local validation: 1011 Python tests passed (1 skipped), 54 desktop tests passed; Ruff, Pyright, Python builds, desktop typecheck/build, version checks and CLI smoke passed. --- .../src/coding_agent/core/agent_session.py | 23 +++++ packages/app/tests/test_plan_lifecycle.py | 85 ++++++++++++++++++- .../core/src/agent_core/session/storage.py | 11 ++- packages/core/tests/test_session.py | 17 ++++ 4 files changed, 132 insertions(+), 4 deletions(-) diff --git a/packages/app/src/coding_agent/core/agent_session.py b/packages/app/src/coding_agent/core/agent_session.py index b8bb0de..600f152 100644 --- a/packages/app/src/coding_agent/core/agent_session.py +++ b/packages/app/src/coding_agent/core/agent_session.py @@ -569,6 +569,29 @@ def _build_effective_system_prompt(self, shell_kind: str | None = None) -> str: ) if self._plan_state.mode == "plan": prompt = f"{prompt.rstrip()}\n\n{PLAN_MODE_OVERLAY}\n" + revision = self._plan_state.latest_revision + if ( + self._plan_state.phase in {"drafting", "awaiting_answer", "ready"} + and revision is not None + and revision.origin_session_id is not None + ): + # Handoffs persist a revision, not source messages. Project the + # active branch's validated revision into review context on every + # refresh, including resume, without copying the source dialogue. + prompt += ( + "\n\n" + "The following plan is reference material for review and revision, " + "not authorization to execute. Stay in Plan Mode; submit changes " + "with submit_plan. Execution requires explicit host confirmation " + "of the latest revision in this session.\n" + f"Origin session: {revision.origin_session_id}\n" + f"Plan ID: {revision.plan_id}\n" + f"Revision: {revision.revision}\n" + f"Digest: {revision.digest}\n" + f"Title: {revision.title}\n\n" + f"{revision.markdown}\n" + "\n" + ) return prompt def _refresh_collaboration_runtime(self) -> None: diff --git a/packages/app/tests/test_plan_lifecycle.py b/packages/app/tests/test_plan_lifecycle.py index a17918e..888791f 100644 --- a/packages/app/tests/test_plan_lifecycle.py +++ b/packages/app/tests/test_plan_lifecycle.py @@ -117,6 +117,71 @@ def test_end_to_end_question_revision_stale_handoff_and_settled(tmp_path): assert events[-2]["state"]["phase"] == "settled" +@pytest.mark.parametrize("reopen", ["no", "ready", "drafting"]) +def test_handoff_revision_is_available_to_review_without_source_history(tmp_path, reopen): + session = session_at(tmp_path, disk=True) + title = "Unique handoff title" + markdown = "Unique handoff body\n1. Preserve the original files." + private_message = "Source-only private planning conversation" + asyncio.run(drive(session, private_message, submission(title=title, markdown=markdown))) + plan = session.plan_state.latest_revision + target = session.handoff_plan_to_new_session(plan.plan_id, plan.revision, plan.digest) + assert session.plan_state.phase == "ready" + assert session.state.messages == [] + if reopen == "drafting": + asyncio.run(drive(session, "Continue reviewing", AssistantMessage( + content=[TextContent(text="I will review the plan.")], stop_reason="stop", + ))) + assert session.plan_state.phase == "drafting" + if reopen != "no": + session = AgentSession(AgentSessionConfig( + model=session.model, cwd=str(tmp_path), session_manager=SessionManager.open(target.path), + )) + contexts = [] + asyncio.run(drive( + session, "Review the handed-off plan and add rollback steps", + submission("review-submit", title=title, markdown=markdown + "\n2. Roll back on failure."), + seen=contexts, + )) + assert len(contexts) == 1 + context = contexts[0] + assert title in context.system_prompt + assert markdown in context.system_prompt + assert plan.digest in context.system_prompt + assert private_message not in context.system_prompt + repr(context.messages) + tool_names = {tool.name for tool in context.tools} + assert "submit_plan" in tool_names + assert not tool_names.intersection({"bash", "write", "edit"}) + latest = session.plan_state.latest_revision + assert session.plan_state.phase == "ready" + assert latest.revision == plan.revision + 1 + assert latest.digest != plan.digest + assert not any(isinstance(entry, PlanRunEntry) for entry in session.session_manager.entries) + with pytest.raises(PlanModeError) as error: + asyncio.run(session.execute_plan(plan.plan_id, plan.revision, plan.digest)) + assert error.value.code == "STALE_PLAN_REVISION" + session.cancel_plan_mode() + assert "Unique handoff body" not in session._build_effective_system_prompt() + session.enter_plan_mode() + assert "Unique handoff body" not in session._build_effective_system_prompt() + + +def test_handoff_review_context_follows_active_branch(tmp_path): + session = session_at(tmp_path) + plan = ready(session) + target = session.handoff_plan_to_new_session(plan.plan_id, plan.revision, plan.digest) + handoff_leaf = target.leaf_id + assert "" in session.state.system_prompt + target.branch(target.entries[0].id) + session.refresh_plan_state_from_branch() + assert session.plan_state.phase == "drafting" + assert "" not in session.state.system_prompt + target.branch(handoff_leaf) + session.refresh_plan_state_from_branch() + assert session.plan_state.phase == "ready" + assert "" in session.state.system_prompt + + @pytest.mark.parametrize("stop", ["aborted", "error", "length"]) def test_incomplete_assistant_cannot_submit(tmp_path, stop): session = session_at(tmp_path) @@ -286,12 +351,14 @@ def test_legacy_digest_validated_without_rewrite(tmp_path): @pytest.mark.parametrize("bad_line", ['{"type":', "[]", '"text"', '{"type":"plan_run","status":"bad"}']) -def test_corrupt_jsonl_requires_recovery_and_explicit_exit(tmp_path, bad_line): +@pytest.mark.parametrize("ending", ["\n", ""]) +def test_corrupt_jsonl_requires_recovery_and_explicit_exit(tmp_path, bad_line, ending): session = session_at(tmp_path, disk=True) ready(session) path = session.session_manager.path with path.open("a", encoding="utf-8") as stream: - stream.write(bad_line + "\n") + stream.write(bad_line + ending) + damaged_bytes = path.read_bytes() with pytest.warns(RuntimeWarning): manager = SessionManager.open(path) restored = AgentSession(AgentSessionConfig(model=session.model, session_manager=manager)) @@ -303,6 +370,15 @@ def test_corrupt_jsonl_requires_recovery_and_explicit_exit(tmp_path, bad_line): with pytest.warns(RuntimeWarning): reopened = SessionManager.open(path) assert reduce_plan_state(reopened.get_branch(), load_issues=reopened.load_issues).phase == "cancelled" + assert path.read_bytes().startswith(damaged_bytes) + restored.enter_plan_mode() + asyncio.run(drive(restored, "plan again", submission("recovery-submit"))) + plan = restored.plan_state.latest_revision + with pytest.warns(RuntimeWarning): + replanned = SessionManager.open(path) + state = reduce_plan_state(replanned.get_branch(), load_issues=replanned.load_issues) + assert state.phase == "ready" + assert state.latest_revision.digest == plan.digest @pytest.mark.parametrize("stage", ["target_flush", "source_append"]) @@ -336,11 +412,14 @@ def append(manager, mode, **kwargs): assert not any(isinstance(item, PlanRunEntry) for item in child.entries) -def test_entry_append_failure_does_not_advance_memory_or_file(tmp_path, monkeypatch): +@pytest.mark.parametrize("tail", [b"", b'{"type":']) +def test_entry_append_failure_does_not_advance_memory_or_file(tmp_path, monkeypatch, tail): import agent_core.session.storage as storage session = session_at(tmp_path, disk=True) ready(session) manager = session.session_manager + with manager.path.open("ab") as stream: + stream.write(tail) original_bytes = manager.path.read_bytes() previous_leaf = manager.leaf_id def fail_fsync(_fd): diff --git a/packages/core/src/agent_core/session/storage.py b/packages/core/src/agent_core/session/storage.py index 5a83bd3..ba2a5cc 100644 --- a/packages/core/src/agent_core/session/storage.py +++ b/packages/core/src/agent_core/session/storage.py @@ -422,8 +422,17 @@ def append_entry_line(path: Path, entry: SessionEntry) -> None: CollaborationModeChangeEntry, PlanQuestionEntry, PlanQuestionAnswerEntry, PlanRevisionEntry, PlanRunEntry, )) - with open(path, "ab", buffering=0) as stream: + with open(path, "a+b", buffering=0) as stream: + stream.seek(0, os.SEEK_END) offset = stream.tell() + if offset: + stream.seek(-1, os.SEEK_END) + if stream.read(1) != b"\n": + # Preserve an unterminated (possibly crash-truncated) last line, + # but never merge a new record into it. Rollback includes this + # separator so a failed append leaves the original bytes intact. + encoded = b"\n" + encoded + stream.seek(0, os.SEEK_END) try: written = stream.write(encoded) if written != len(encoded): diff --git a/packages/core/tests/test_session.py b/packages/core/tests/test_session.py index 5e6db35..8553127 100644 --- a/packages/core/tests/test_session.py +++ b/packages/core/tests/test_session.py @@ -8,6 +8,8 @@ from datetime import datetime from pathlib import Path +import pytest + from agent_llm import AssistantMessage, TextContent, ToolResultMessage, UserMessage from agent_core.session.session_manager import SessionManager @@ -65,6 +67,21 @@ def test_new_session_timestamps_are_iso_8601_but_filename_is_safe(tmp_path: Path # agent_dir. Encoding the real Windows tmp_path into a directory name would # blow past MAX_PATH=260; the agent_dir controls where files actually land. +@pytest.mark.parametrize("ending", [b"\n", b"\r\n", b"", b"\r"]) +def test_append_preserves_record_boundary_after_valid_tail(tmp_path: Path, ending: bytes): + sm = SessionManager.create(cwd="/test/proj", agent_dir=tmp_path) + sm.append_message(_msg_user("hello")) + sm.append_message(_msg_asst("reply")) + assert sm.path is not None + original = sm.path.read_bytes().rstrip(b"\r\n") + ending + sm.path.write_bytes(original) + sm.append_message(_msg_user("next turn")) + assert sm.path.read_bytes().startswith(original) + restored = SessionManager.open(sm.path) + assert not restored.load_issues + assert restored.entries == sm.entries + + def test_flush_on_first_assistant(tmp_path: Path): sm = SessionManager.create(cwd="/test/proj", agent_dir=tmp_path) sm.append_message(_msg_user("buffered")) # no file yet