Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,4 @@ Desktop.ini
.zcode/
.codex/

# 本地开发文档
/docs/
# Plan Mode 规范和架构决策属于产品契约,随代码版本化。
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <session-id> --answer-plan-question <question-id> "answer"
uv run coding-agent --session <session-id> --execute-plan <revision>
uv run coding-agent --session <session-id> --handoff-plan [revision]
uv run coding-agent --session <session-id> --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

Expand Down Expand Up @@ -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 |
Expand Down
7 changes: 4 additions & 3 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Coding Agent 是一个面向本地开发工作的编程 Agent。项目以 Python
- **项目上下文**:支持发现 `AGENTS.md`、`CLAUDE.md`、Skills 和提示词模板。
- **终端界面**:支持 Markdown、流式内容、工具卡片、模型选择和按行差分渲染。
- **桌面端 MVP**:支持项目选择、会话列表、流式消息、工具审批、模型切换和斜杠命令面板。
- **跨端 Plan Mode**:CLI 与桌面端共享只读探索、结构化问题、不可变计划 revision 和显式执行确认
- **跨端 Plan Mode**:CLI 与桌面端共享 Runtime 强制的观察工具、结构化问题、不可变 revision、恢复状态和显式执行确认

## 界面形态

Expand Down Expand Up @@ -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 <revision>
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 和模型

Expand Down Expand Up @@ -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` | 手动压缩上下文 |
Expand Down
203 changes: 198 additions & 5 deletions apps/desktop/src/renderer/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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(<App />);
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(<App />);
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(<App />);
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(<App />);

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 () => {
Expand Down Expand Up @@ -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(<App />);
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(<App />);
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(<App />);
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(<App />);
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");
Expand Down
Loading