From 63aa3b84283f163cd2d7baad43fcaf89dd085b72 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 2 Oct 2026 09:21:00 -0700 Subject: [PATCH] refactor: report plans on the experimental ACP v2 surface (#1235) Second part of step 5 of opt-in ACP v2 support (after #1234): the v2 surface now sends the agent's plan. Nothing changes on ACP v1. ## Change - v1 `plan` becomes v2 `plan_update` with an items plan: `{ type: "items", planId, entries }`. Plan entries are identical in both versions, and a v2 items plan is replaced whole on each update, as a v1 plan is. The update's `_meta` passes through. - **One plan id per session** (`SESSION_PLAN_ID`). v1 has one plan per session, which every update replaces, whether it comes from TodoWrite or from the Task tools. Several plans, such as one per subagent (a subagent's TodoWrite replaces the session's plan today, marked by `_meta.claudeCode.parentToolUseId`), and v2's unstable markdown plans wait for step 6. ## Scenario suite `todo-write` and `task-create-update` pass, with golden files. That leaves only `session-load-replay` in the status table. ## Verification A unit test covers the mapping; build, lint and all 2383 tests pass. --- src/tests/acp-scenarios-v2.test.ts | 6 ++--- .../__snapshots__/v2/task-create-update.jsonl | 26 +++++++++++++++++++ .../__snapshots__/v2/todo-write.jsonl | 11 ++++++++ src/tests/acp-v2.test.ts | 16 +++++++++--- src/v2/session-update.ts | 14 ++++++++++ 5 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 src/tests/acp-scenarios/__snapshots__/v2/task-create-update.jsonl create mode 100644 src/tests/acp-scenarios/__snapshots__/v2/todo-write.jsonl diff --git a/src/tests/acp-scenarios-v2.test.ts b/src/tests/acp-scenarios-v2.test.ts index d9db6fd9..a1e76cba 100644 --- a/src/tests/acp-scenarios-v2.test.ts +++ b/src/tests/acp-scenarios-v2.test.ts @@ -61,8 +61,6 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const PASSES = "passes"; /** Step 4 of `docs/acp-v2.md`. */ const REPLAY = "Invalid params: The ACP v2 surface does not replay session history yet"; -/** Step 5 of `docs/acp-v2.md`. */ -const PLANS = "The ACP v2 surface does not translate plan session updates yet"; /** * How each scenario runs on the v2 surface: it passes, or stops for a reason @@ -91,8 +89,8 @@ const V2_STATUS: Record = { "subagent-nested": PASSES, "subagent-late-child-update": PASSES, "subagent-transcript-extension": PASSES, - "todo-write": PLANS, - "task-create-update": PLANS, + "todo-write": PASSES, + "task-create-update": PASSES, goal: PASSES, "network-permission": PASSES, "exit-plan-approve": PASSES, diff --git a/src/tests/acp-scenarios/__snapshots__/v2/task-create-update.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/task-create-update.jsonl new file mode 100644 index 00000000..7c08dc61 --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/task-create-update.jsonl @@ -0,0 +1,26 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"plan":{"entries":[{"content":"Write tests","priority":"medium","status":"pending"}],"planId":"plan","type":"items"},"sessionUpdate":"plan_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"plan":{"entries":[{"content":"Write tests","priority":"medium","status":"pending"}],"planId":"plan","type":"items"},"sessionUpdate":"plan_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"plan":{"entries":[{"content":"Write tests","priority":"medium","status":"in_progress"}],"planId":"plan","type":"items"},"sessionUpdate":"plan_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"plan":{"entries":[{"content":"Write tests","priority":"medium","status":"completed"}],"planId":"plan","type":"items"},"sessionUpdate":"plan_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"plan":{"entries":[{"content":"Write tests","priority":"medium","status":"completed"}],"planId":"plan","type":"items"},"sessionUpdate":"plan_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 2","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":{"text":"All ","type":"text"},"messageId":"msg_follow","sessionUpdate":"agent_message_chunk"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":{"text":"done.","type":"text"},"messageId":"msg_follow","sessionUpdate":"agent_message_chunk"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/todo-write.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/todo-write.jsonl new file mode 100644 index 00000000..afc3c6a2 --- /dev/null +++ b/src/tests/acp-scenarios/__snapshots__/v2/todo-write.jsonl @@ -0,0 +1,11 @@ +{"kind":"response","method":"initialize","payload":{"_meta":{"steering":{"supported":true}},"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"protocolVersion":2}} +{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"","sessionUpdate":"user_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} +{"kind":"response","method":"session/prompt","payload":{"messageId":""}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"plan":{"entries":[{"content":"Writing tests","priority":"medium","status":"in_progress"},{"content":"Ship","priority":"medium","status":"pending"}],"planId":"plan","type":"items"},"sessionUpdate":"plan_update"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}} diff --git a/src/tests/acp-v2.test.ts b/src/tests/acp-v2.test.ts index 983a03a5..dd5db898 100644 --- a/src/tests/acp-v2.test.ts +++ b/src/tests/acp-v2.test.ts @@ -1036,9 +1036,19 @@ describe("ACP v2 session translation", () => { }); it("fails on session updates that it does not translate yet", () => { - expect(() => v2SessionUpdate({ sessionUpdate: "plan", entries: [] })).toThrow( - "does not translate plan session updates yet", - ); + expect(() => + v2SessionUpdate({ sessionUpdate: "compaction_update", compactionId: "c", status: "started" }), + ).toThrow("does not translate compaction_update session updates yet"); + }); + + it("reports the plan of the session as one v2 plan", () => { + const entries = [{ content: "Test", priority: "high" as const, status: "pending" as const }]; + const meta = { claudeCode: { parentToolUseId: "toolu_agent" } }; + expect(v2SessionUpdate({ sessionUpdate: "plan", entries, _meta: meta })).toEqual({ + sessionUpdate: "plan_update", + plan: { type: "items", planId: "plan", entries }, + _meta: meta, + }); }); it("reports a new tool call with an upserting tool_call_update", () => { diff --git a/src/v2/session-update.ts b/src/v2/session-update.ts index 63861721..2909ea9b 100644 --- a/src/v2/session-update.ts +++ b/src/v2/session-update.ts @@ -7,6 +7,13 @@ import type { AcpSessionNotification } from "../acp-subagents.js"; import { v2AvailableCommands, v2ConfigOptions } from "./session.js"; import { v2ToolCallUpdate } from "./tool-call.js"; +/** + * The id of the plan of a session. v1 has one plan per session, which every + * plan update replaces whole, from TodoWrite and from the Task tools alike. + * v2 tracks plans by id, so that plan gets one. + */ +export const SESSION_PLAN_ID = "plan"; + /** * The v2 form of a v1 session update, or `undefined` when v2 has no * counterpart and the update tells a v2 client nothing new. @@ -39,6 +46,13 @@ export function v2SessionUpdate( case "tool_call": case "tool_call_update": return v2ToolCallUpdate(update); + case "plan": + // A v2 plan of items is replaced whole as well. + return { + sessionUpdate: "plan_update", + plan: { type: "items", planId: SESSION_PLAN_ID, entries: update.entries }, + ...(update._meta != null ? { _meta: update._meta } : {}), + }; case "current_mode_update": // v2 has no modes: the mode is the `mode` config option. Every mode // change also reaches the client as a `config_option_update` or in the