Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Brandt
766fd142f6 feat: send the checklist as a plan operation and remove plans on reset (#1554)
## Summary

Follow-up to the [plan operations
RFD](https://agentclientprotocol.com/rfds/plan-operations). The adapter
already sent approved ExitPlanMode plans as `markdown` plans to clients
with the `plan` capability, but two parts of the RFD were unused:

- **The checklist is now a plan operation on v1 too.** Clients with
`plan` (every v2 client, and v1 clients that opt in) get the
TodoWrite/Task checklist as `plan_update` with type `items` and the ID
`plan`. Before, v1 clients with the capability still got an ID-less
legacy `plan` next to the `plan-mode` plan. Clients without the
capability still get the legacy `plan`.
- **`plan_removed` is now sent.**
- An empty checklist sends `plan_removed` for `plan`. v2 clients used to
get a `plan_update` with no entries.
- A conversation reset (`/clear`) removes the checklist and the old
conversation's approved `plan-mode` plan. AIR keeps its approved plans,
which are joined to their approval cards by ID (#1469).

The new `src/plans.ts` builds the checklist updates. All three places
that send the checklist use it, and so does the v2 conversion of a
legacy `plan`, so v1 and v2 follow one rule.

The reset sends `plan_removed` for `plan-mode` even when the session
never published an approved plan. Tracking whether one was published
would be state that a restart loses, so the reset would behave
differently before and after one.

`file` plans are left out. A `file://` URI only works for a client on
the same machine, and v2 has no client filesystem.

## Tests

- `tools.test.ts`: TodoWrite with and without todos, and a TaskCreate
snapshot, for a v1 client with `plan`.
- `acp-agent.test.ts`: the reset test now runs for a client without plan
operations, a client with them, and AIR.
- `acp-v2.test.ts`: v2 end to end, TodoWrite then `/clear`, and an empty
legacy plan converting to `plan_removed`.
2026-11-09 14:58:36 +00:00
6 changed files with 276 additions and 61 deletions

View File

@@ -106,6 +106,7 @@ import {
toGoalSnapshot,
} from "./goal-extension.js";
import { blobResourceToClaude } from "./blob-resources.js";
import { checklistPlanUpdate, resetPlanUpdates } from "./plans.js";
import { promptSuggestionsMeta, promptSuggestionText } from "./prompt-suggestions.js";
import { sanitizeTitle, SessionTitles } from "./session-titles.js";
import {
@@ -4461,7 +4462,7 @@ export class ClaudeAcpAgent {
if (!entries) return;
await this.client.sessionUpdate({
sessionId,
update: { sessionUpdate: "plan", entries },
update: checklistPlanUpdate(entries, this.clientCapabilities?.plan != null),
});
}
@@ -8747,7 +8748,13 @@ export class ClaudeAcpAgent {
session.toolCallFields?.clear();
clearHookCallbacks(params.sessionId);
session.taskState.clear();
await this.publishTaskPlan(params.sessionId, session.taskState);
forgetPublishedTaskPlan(session.taskState);
for (const update of resetPlanUpdates(
this.clientCapabilities?.plan != null,
this.toolCallCapabilities.air.client,
)) {
await this.client.sessionUpdate({ sessionId: params.sessionId, update });
}
// A reset mounts a fresh transcript (`new_conversation_id`), so our
// cached title no longer describes the session: drop it and
// re-evaluate at the next turn-end.
@@ -14365,10 +14372,10 @@ export function toAcpNotifications(
if (chunk.name === "TodoWrite") {
// @ts-expect-error - sometimes input is empty object or undefined
if (Array.isArray(chunk.input?.todos)) {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
};
update = checklistPlanUpdate(
planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
options?.clientCapabilities?.plan != null,
);
}
} else if (isTaskTool(chunk.name)) {
// Task* tool_use is suppressed; the plan update is emitted at
@@ -14603,7 +14610,9 @@ export function toAcpNotifications(
const entries = shouldEmitTaskPlan
? changedTaskPlanEntries(taskState, renderer.capabilities.air.client)
: undefined;
if (entries) update = { sessionUpdate: "plan", entries };
if (entries) {
update = checklistPlanUpdate(entries, options?.clientCapabilities?.plan != null);
}
} else if (toolUse.name !== "TodoWrite") {
// A command sends its output first, then the exit and the status.
const [finalUpdate, ...rest] = renderer

58
src/plans.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* The session updates that show the session's plans. A client with the `plan`
* capability (every v2 client, and a v1 client that asks for it) tracks plans
* by ID and gets `plan_update` and `plan_removed`; any other client gets the
* legacy `plan` update, which holds one ID-less plan per session.
*/
import type {
PlanEntry,
PlanItems,
PlanRemoved,
SessionNotification,
} from "@agentclientprotocol/sdk/unstable";
import { PLAN_MODE_PLAN_ID } from "./exit-plan.js";
type PlanRemovalUpdate = PlanRemoved & { sessionUpdate: "plan_removed" };
/** The ID of the session's checklist, from TodoWrite and the Task tools. Every
* update replaces it whole. An approved ExitPlanMode plan is a separate plan
* ({@link PLAN_MODE_PLAN_ID}). */
export const CHECKLIST_PLAN_ID = "plan";
/** The plan operation that shows the checklist `entries`: `plan_update`, or
* `plan_removed` for an empty checklist. */
export function checklistPlanOperation(
entries: PlanEntry[],
): { sessionUpdate: "plan_update"; plan: PlanItems & { type: "items" } } | PlanRemovalUpdate {
if (entries.length === 0) return { sessionUpdate: "plan_removed", planId: CHECKLIST_PLAN_ID };
return {
sessionUpdate: "plan_update",
plan: { type: "items", planId: CHECKLIST_PLAN_ID, entries },
};
}
/** The update that shows the checklist `entries`: a plan operation (see
* {@link checklistPlanOperation}), else a legacy `plan`, which with no entries
* clears the client's plan. */
export function checklistPlanUpdate(
entries: PlanEntry[],
planOperations: boolean,
): SessionNotification["update"] {
return planOperations ? checklistPlanOperation(entries) : { sessionUpdate: "plan", entries };
}
/** The updates that dismiss the plans of a conversation that a reset (`/clear`)
* replaced: the checklist, and the approved ExitPlanMode plan. AIR keeps its
* approved plans, each joined to its approval card in the transcript (see
* planModeUpdate). Without plan operations, a legacy empty `plan` clears the
* one plan the client holds. */
export function resetPlanUpdates(
planOperations: boolean,
airClient: boolean,
): SessionNotification["update"][] {
const updates = [checklistPlanUpdate([], planOperations)];
if (planOperations && !airClient) {
updates.push({ sessionUpdate: "plan_removed", planId: PLAN_MODE_PLAN_ID });
}
return updates;
}

View File

@@ -346,49 +346,81 @@ describe("task plan lifecycle", () => {
);
});
it("clears the task plan on a conversation reset", async () => {
const taskState = new Map([["task-1", { subject: "Run tests", status: "in_progress" }]]);
const { agent, updates } = setup(taskState, async function* (input) {
const iter = input[Symbol.asyncIterator]();
const first = await iter.next();
yield userEcho(first.value);
yield {
type: "conversation_reset",
new_conversation_id: randomUUID(),
uuid: randomUUID(),
session_id: "test-session",
};
yield successfulResult();
const inProgressPlan = [{ content: "Run tests", status: "in_progress", priority: "medium" }];
const planUpdateKinds = new Set(["plan", "plan_update", "plan_removed"]);
const second = await iter.next();
yield userEcho(second.value);
yield successfulResult();
});
await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "/clear" }],
});
await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "Start fresh" }],
});
expect(taskState.size).toBe(0);
expect(updates.filter((notification) => notification.update.sessionUpdate === "plan")).toEqual([
{
sessionId: "test-session",
update: {
sessionUpdate: "plan",
entries: [{ content: "Run tests", status: "in_progress", priority: "medium" }],
it.each([
{
client: "a client without plan operations",
clientCapabilities: {},
expected: [
{ sessionUpdate: "plan", entries: inProgressPlan },
{ sessionUpdate: "plan", entries: [] },
],
},
{
client: "a client with plan operations",
clientCapabilities: { plan: {} },
expected: [
{
sessionUpdate: "plan_update",
plan: { type: "items", planId: "plan", entries: inProgressPlan },
},
},
{
{ sessionUpdate: "plan_removed", planId: "plan" },
{ sessionUpdate: "plan_removed", planId: "plan-mode" },
],
},
{
// AIR keeps each approved plan with its approval card.
client: "AIR with plan operations",
clientCapabilities: { ...AIR_CLIENT_CAPABILITIES, plan: {} },
expected: [
{
sessionUpdate: "plan_update",
plan: { type: "items", planId: "plan", entries: inProgressPlan },
},
{ sessionUpdate: "plan_removed", planId: "plan" },
],
},
])(
"clears the plans on a conversation reset for $client",
async ({ clientCapabilities, expected }) => {
const taskState = new Map([["task-1", { subject: "Run tests", status: "in_progress" }]]);
const { agent, updates } = setup(taskState, async function* (input) {
const iter = input[Symbol.asyncIterator]();
const first = await iter.next();
yield userEcho(first.value);
yield {
type: "conversation_reset",
new_conversation_id: randomUUID(),
uuid: randomUUID(),
session_id: "test-session",
};
yield successfulResult();
const second = await iter.next();
yield userEcho(second.value);
yield successfulResult();
});
await initializeClient(agent, clientCapabilities);
await agent.prompt({
sessionId: "test-session",
update: { sessionUpdate: "plan", entries: [] },
},
]);
});
prompt: [{ type: "text", text: "/clear" }],
});
await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "Start fresh" }],
});
expect(taskState.size).toBe(0);
expect(
updates
.map((notification) => notification.update)
.filter((update) => planUpdateKinds.has(update.sessionUpdate)),
).toEqual(expected);
},
);
});
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {

View File

@@ -1574,6 +1574,59 @@ describe("ACP v2 prompts", () => {
]);
});
it("removes the checklist and the approved plan on a conversation reset", async () => {
scriptTurns(
async function* (options) {
const todoWrite = assistantText(options, "", "msg_todo");
todoWrite.message.content = [
{
type: "tool_use",
id: "toolu_todo",
name: "TodoWrite",
input: { todos: [{ content: "Do X", status: "pending", activeForm: "Doing X" }] },
},
] as never;
yield todoWrite;
yield result(options);
},
async function* (options) {
yield {
type: "conversation_reset",
new_conversation_id: randomUUID(),
uuid: randomUUID(),
session_id: options.sessionId,
};
yield result(options);
},
);
const client = v2Client();
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("plan") });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("/clear") });
await vi.waitFor(() => expect(client.updates("plan_removed")).toHaveLength(2));
await client.authUpdate(1);
});
expect(
client.sessionUpdates
.map(({ update }) => update)
.filter(({ sessionUpdate }) => sessionUpdate.startsWith("plan_")),
).toEqual([
{
sessionUpdate: "plan_update",
plan: {
type: "items",
planId: "plan",
entries: [{ content: "Do X", status: "pending", priority: "medium" }],
},
},
{ sessionUpdate: "plan_removed", planId: "plan" },
{ sessionUpdate: "plan_removed", planId: "plan-mode" },
]);
});
it("reports Claude Code's warnings as live notices", async () => {
scriptTurns(async function* (options) {
yield {
@@ -6562,6 +6615,13 @@ describe("ACP v2 session translation", () => {
});
});
it("reports an empty legacy plan as the removal of the session's v2 plan", () => {
expect(v2SessionUpdate({ sessionUpdate: "plan", entries: [] })).toEqual({
sessionUpdate: "plan_removed",
planId: "plan",
});
});
it("passes the agent's plan operations through, which have v2's shape", () => {
const update = {
sessionUpdate: "plan_update" as const,

View File

@@ -2684,6 +2684,36 @@ describe("toAcpNotifications - TodoWrite with undefined input regression", () =>
const planUpdates = notifications.filter((n) => (n.update as any).sessionUpdate === "plan");
expect(planUpdates).toHaveLength(1);
});
it.each([
{
todos: [{ content: "Do X", status: "pending", activeForm: "Doing X" }],
expected: {
sessionUpdate: "plan_update",
plan: {
type: "items",
planId: "plan",
entries: [{ content: "Do X", status: "pending", priority: "medium" }],
},
},
},
{ todos: [], expected: { sessionUpdate: "plan_removed", planId: "plan" } },
])(
"sends $todos.length todos as a plan operation to a client with plan operations",
({ todos, expected }) => {
const notifications = toAcpNotifications(
[{ type: "tool_use" as const, id: "toolu_todo", name: "TodoWrite", input: { todos } }],
"assistant",
"test-session",
{},
mockClient,
mockLogger,
{ clientCapabilities: { plan: {} } },
);
expect(notifications.map(({ update }) => update)).toEqual([expected]);
},
);
});
describe("parseTaskCreateOutput", () => {
@@ -2923,6 +2953,39 @@ describe("toAcpNotifications - Task* tools", () => {
expect(taskState.size).toBe(0);
});
it("sends the task snapshot as a plan operation to a client with plan operations", () => {
const toolUseCache: ToolUseCache = {
"1": { type: "tool_use", id: "1", name: "TaskCreate", input: { subject: "First" } },
};
const created = toAcpNotifications(
[
{
type: "tool_result",
tool_use_id: "1",
content: JSON.stringify({ task: { id: "1", subject: "First" } }),
is_error: false,
},
] as any,
"user",
"test-session",
toolUseCache,
mockClient,
mockLogger,
{ taskState: new Map(), clientCapabilities: { plan: {} } },
);
expect(created.map(({ update }) => update)).toEqual([
{
sessionUpdate: "plan_update",
plan: {
type: "items",
planId: "plan",
entries: [{ content: "First", status: "pending", priority: "medium" }],
},
},
]);
});
it("emits a plan snapshot after a TaskCreate tool_result and accumulates state", () => {
const toolUseCache: ToolUseCache = {};
const taskState: TaskState = new Map();

View File

@@ -5,15 +5,9 @@
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { v2AvailableCommands, v2ConfigOptions } from "./session.js";
import { checklistPlanOperation } from "../plans.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.
@@ -48,13 +42,12 @@ 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 "plan": {
// Every v2 client has the `plan` capability, so the agent sends the
// checklist as `plan_update`; a legacy plan converts the same way.
const converted = checklistPlanOperation(update.entries);
return update._meta != null ? { ...converted, _meta: update._meta } : converted;
}
case "plan_update":
case "plan_removed":
// v1's draft plan operations have v2's shape.