mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-09 19:02:05 +00:00
Compare commits
1 Commits
v0.101.2-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
766fd142f6 |
@@ -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
58
src/plans.ts
Normal 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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user