mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 11:21:06 +00:00
fix: give subagent checklists their own plan ID (#1558)
A subagent's TodoWrite checklist was published with the main checklist's `planId: "plan"`, so it replaced the main conversation's plan. This only happened on ACP v1 clients that advertise `plan` but not `subagents`. With native subagent sessions (every v2 client, AIR, and v1 with `subagents`), the checklist already goes to the child session, and that's unchanged. Changes: - In that case, a subagent's TodoWrite gets `planId: "plan:<parentToolUseId>"`, so it sits next to the main checklist (`todoWritePlanUpdate` in `src/plans.ts`). - An empty subagent TodoWrite sends `plan_removed` for its own plan ID, not `"plan"`. - `/clear` also removes the subagent checklist plans it published. - Nothing is removed when a subagent finishes. The plan operations RFD now says Agents remove cancelled plans, not finished ones. Not changed: - Task* tools keep `"plan"`. Claude Code shares one task list across all agents in a session, so a per-subagent ID would show the same list twice. - Clients without the `plan` capability can hold only one plan, so a subagent's checklist still replaces it there, as before. Tests cover v1 (main and subagent plans side by side, then `/clear`) and v2 (the checklist arrives as `"plan"` in the child session, and the root's plan is untouched).
This commit is contained in:
@@ -106,7 +106,12 @@ import {
|
||||
toGoalSnapshot,
|
||||
} from "./goal-extension.js";
|
||||
import { blobResourceToClaude } from "./blob-resources.js";
|
||||
import { checklistPlanUpdate, resetPlanUpdates } from "./plans.js";
|
||||
import {
|
||||
checklistPlanUpdate,
|
||||
resetPlanUpdates,
|
||||
todoWritePlanUpdate,
|
||||
type SubagentChecklists,
|
||||
} from "./plans.js";
|
||||
import { promptSuggestionsMeta, promptSuggestionText } from "./prompt-suggestions.js";
|
||||
import { sanitizeTitle, SessionTitles } from "./session-titles.js";
|
||||
import {
|
||||
@@ -1026,7 +1031,12 @@ type Turn = {
|
||||
* while the CLI starts. The session record then takes the same objects. */
|
||||
type ReplayState = Pick<
|
||||
Session,
|
||||
"cwd" | "taskState" | "forwardSubagentText" | "messageIdToUuid" | "sessionFailureState"
|
||||
| "cwd"
|
||||
| "taskState"
|
||||
| "subagentChecklists"
|
||||
| "forwardSubagentText"
|
||||
| "messageIdToUuid"
|
||||
| "sessionFailureState"
|
||||
>;
|
||||
|
||||
/** A replay that runs before its session record exists. */
|
||||
@@ -1341,6 +1351,9 @@ export type Session = {
|
||||
/** Accumulated task list for the session, keyed by task ID. Task IDs are
|
||||
* per-session, so this state must not be shared across sessions. */
|
||||
taskState: TaskState;
|
||||
/** The subagent TodoWrite checklists that the client holds in this session,
|
||||
* which a conversation reset removes. */
|
||||
subagentChecklists: SubagentChecklists;
|
||||
/** Caches `tool_use` blocks by id so the matching `tool_result` can recover
|
||||
* the tool name/input when mapping it to a `tool_call_update`. Per-session
|
||||
* (tool_use ids are only unique within a session) and pruned at
|
||||
@@ -3165,6 +3178,7 @@ export class ClaudeAcpAgent {
|
||||
state: {
|
||||
cwd: params.cwd,
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
forwardSubagentText: this.forwardsSubagentText(params._meta),
|
||||
messageIdToUuid: new Map(),
|
||||
sessionFailureState: createSessionFailureState(),
|
||||
@@ -5613,6 +5627,7 @@ export class ClaudeAcpAgent {
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
subagentChecklists: session.subagentChecklists,
|
||||
emittedToolCalls: session.emittedToolCalls,
|
||||
toolCallFields: toolCallFieldsOf(session),
|
||||
messageId: messageIdForGrouping(message),
|
||||
@@ -8104,6 +8119,7 @@ export class ClaudeAcpAgent {
|
||||
toolCallCapabilities: this.toolCallCapabilities,
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
subagentChecklists: session.subagentChecklists,
|
||||
emittedToolCalls: session.emittedToolCalls,
|
||||
toolCallFields: toolCallFieldsOf(session),
|
||||
messageId: currentStreamMessageId,
|
||||
@@ -8419,6 +8435,7 @@ export class ClaudeAcpAgent {
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
subagentChecklists: session.subagentChecklists,
|
||||
messageId: messageIdForGrouping(message),
|
||||
},
|
||||
)) {
|
||||
@@ -8619,6 +8636,7 @@ export class ClaudeAcpAgent {
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
subagentChecklists: session.subagentChecklists,
|
||||
emittedToolCalls: session.emittedToolCalls,
|
||||
toolCallFields: toolCallFieldsOf(session),
|
||||
messageId: messageIdForGrouping(message),
|
||||
@@ -8754,6 +8772,7 @@ export class ClaudeAcpAgent {
|
||||
for (const update of resetPlanUpdates(
|
||||
this.clientCapabilities?.plan != null,
|
||||
this.toolCallCapabilities.air.client,
|
||||
session.subagentChecklists,
|
||||
)) {
|
||||
await this.client.sessionUpdate({ sessionId: params.sessionId, update });
|
||||
}
|
||||
@@ -10119,6 +10138,7 @@ export class ClaudeAcpAgent {
|
||||
toolCallCapabilities: this.toolCallCapabilities,
|
||||
cwd: replayState()?.cwd,
|
||||
taskState: replayState()?.taskState,
|
||||
subagentChecklists: replayState()?.subagentChecklists,
|
||||
messageId: replayMessageId,
|
||||
parentToolUseId,
|
||||
},
|
||||
@@ -12770,6 +12790,7 @@ export class ClaudeAcpAgent {
|
||||
contextWindowAuthoritative: seededWindow.authoritative,
|
||||
providerCacheKey,
|
||||
taskState,
|
||||
subagentChecklists: creationOpts.replayState?.subagentChecklists ?? new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
eagerToolCallSessions: new Map(),
|
||||
@@ -14245,6 +14266,9 @@ export function toAcpNotifications(
|
||||
parentToolUseId?: string | null;
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
// The subagent TodoWrite checklists that the client holds in the session.
|
||||
// Mutated in place.
|
||||
subagentChecklists?: SubagentChecklists;
|
||||
// Tracks tool_use ids already emitted as a `tool_call` so a permission
|
||||
// request (which emits the tool_call eagerly) and the streamed tool_use
|
||||
// chunk don't both emit one — whichever arrives second emits a
|
||||
@@ -14374,10 +14398,14 @@ export function toAcpNotifications(
|
||||
if (chunk.name === "TodoWrite") {
|
||||
// @ts-expect-error - sometimes input is empty object or undefined
|
||||
if (Array.isArray(chunk.input?.todos)) {
|
||||
update = checklistPlanUpdate(
|
||||
planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
|
||||
options?.clientCapabilities?.plan != null,
|
||||
);
|
||||
update = todoWritePlanUpdate(planEntries(chunk.input as { todos: ClaudePlanEntry[] }), {
|
||||
planOperations: options?.clientCapabilities?.plan != null,
|
||||
parentToolUseId: options?.parentToolUseId,
|
||||
// With native subagent sessions, an update of a subagent goes
|
||||
// to the subagent's session (see NativeSubagentRuntime).
|
||||
childSessions: clientSupportsSubagents(options?.clientCapabilities),
|
||||
shown: options?.subagentChecklists,
|
||||
});
|
||||
}
|
||||
} else if (isTaskTool(chunk.name)) {
|
||||
// Task* tool_use is suppressed; the plan update is emitted at
|
||||
@@ -14716,6 +14744,7 @@ export function streamEventToAcpNotifications(
|
||||
toolCallCapabilities?: ToolCallClientCapabilities;
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
subagentChecklists?: SubagentChecklists;
|
||||
emittedToolCalls?: Set<string>;
|
||||
toolCallFields?: ToolCallFieldTracker;
|
||||
messageId?: string;
|
||||
@@ -14731,6 +14760,7 @@ export function streamEventToAcpNotifications(
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
taskState: options?.taskState,
|
||||
subagentChecklists: options?.subagentChecklists,
|
||||
emittedToolCalls: options?.emittedToolCalls,
|
||||
toolCallFields: options?.toolCallFields,
|
||||
messageId: options?.messageId,
|
||||
|
||||
70
src/plans.ts
70
src/plans.ts
@@ -16,19 +16,28 @@ 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}). */
|
||||
* ({@link PLAN_MODE_PLAN_ID}), and so is a subagent's TodoWrite checklist in
|
||||
* its parent's session ({@link subagentChecklistPlanId}). */
|
||||
export const CHECKLIST_PLAN_ID = "plan";
|
||||
|
||||
/** The plan operation that shows the checklist `entries`: `plan_update`, or
|
||||
* `plan_removed` for an empty checklist. */
|
||||
/** The ID of the TodoWrite checklist of the subagent that the tool call
|
||||
* `parentToolUseId` launched, when the checklist goes to its parent's session.
|
||||
* Claude Code keeps a TodoWrite list per agent, so it is a separate plan from
|
||||
* the main conversation's checklist. The Task tools share one task list
|
||||
* between all agents of a session, so their checklist is always
|
||||
* {@link CHECKLIST_PLAN_ID}. */
|
||||
export function subagentChecklistPlanId(parentToolUseId: string): string {
|
||||
return `${CHECKLIST_PLAN_ID}:${parentToolUseId}`;
|
||||
}
|
||||
|
||||
/** The plan operation that shows the checklist `entries` as the plan `planId`:
|
||||
* `plan_update`, or `plan_removed` for an empty checklist. */
|
||||
export function checklistPlanOperation(
|
||||
entries: PlanEntry[],
|
||||
planId: string = CHECKLIST_PLAN_ID,
|
||||
): { 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 },
|
||||
};
|
||||
if (entries.length === 0) return { sessionUpdate: "plan_removed", planId };
|
||||
return { sessionUpdate: "plan_update", plan: { type: "items", planId, entries } };
|
||||
}
|
||||
|
||||
/** The update that shows the checklist `entries`: a plan operation (see
|
||||
@@ -41,16 +50,59 @@ export function checklistPlanUpdate(
|
||||
return planOperations ? checklistPlanOperation(entries) : { sessionUpdate: "plan", entries };
|
||||
}
|
||||
|
||||
/** The plan IDs of the subagent checklists that the client holds in a session
|
||||
* (see {@link subagentChecklistPlanId}). */
|
||||
export type SubagentChecklists = Set<string>;
|
||||
|
||||
/**
|
||||
* The update that shows the TodoWrite checklist `entries` of the agent that
|
||||
* made the call: the main conversation, or the subagent that the tool call
|
||||
* `parentToolUseId` launched.
|
||||
*
|
||||
* A subagent's checklist gets its own plan ID, recorded in `shown` while the
|
||||
* client holds it, when it goes to the parent's session. It does not with
|
||||
* `childSessions` (native subagent sessions), where it goes to the subagent's
|
||||
* own session, which holds no other checklist. A client without plan
|
||||
* operations holds one plan per session, so there a subagent's checklist
|
||||
* replaces the main one.
|
||||
*/
|
||||
export function todoWritePlanUpdate(
|
||||
entries: PlanEntry[],
|
||||
options: {
|
||||
planOperations: boolean;
|
||||
parentToolUseId?: string | null;
|
||||
childSessions: boolean;
|
||||
shown?: SubagentChecklists;
|
||||
},
|
||||
): SessionNotification["update"] {
|
||||
const { planOperations, parentToolUseId, childSessions, shown } = options;
|
||||
if (!planOperations || !parentToolUseId || childSessions) {
|
||||
return checklistPlanUpdate(entries, planOperations);
|
||||
}
|
||||
const planId = subagentChecklistPlanId(parentToolUseId);
|
||||
if (entries.length === 0) shown?.delete(planId);
|
||||
else shown?.add(planId);
|
||||
return checklistPlanOperation(entries, planId);
|
||||
}
|
||||
|
||||
/** The updates that dismiss the plans of a conversation that a reset (`/clear`)
|
||||
* replaced: the checklist, and the approved ExitPlanMode plan. AIR keeps its
|
||||
* replaced: the checklist, the subagent checklists in `subagentChecklists`
|
||||
* (which it then empties), 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,
|
||||
subagentChecklists?: SubagentChecklists,
|
||||
): SessionNotification["update"][] {
|
||||
const updates = [checklistPlanUpdate([], planOperations)];
|
||||
if (planOperations) {
|
||||
for (const planId of subagentChecklists ?? []) {
|
||||
updates.push({ sessionUpdate: "plan_removed", planId });
|
||||
}
|
||||
}
|
||||
subagentChecklists?.clear();
|
||||
if (planOperations && !airClient) {
|
||||
updates.push({ sessionUpdate: "plan_removed", planId: PLAN_MODE_PLAN_ID });
|
||||
}
|
||||
|
||||
@@ -421,6 +421,74 @@ describe("task plan lifecycle", () => {
|
||||
).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a subagent's checklist apart from the main one, and removes both on a conversation reset", async () => {
|
||||
const todoWrite = (id: string, content: string, parentToolUseId: string | null) => ({
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: `msg_${id}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id,
|
||||
name: "TodoWrite",
|
||||
input: { todos: [{ content, status: "in_progress", activeForm: content }] },
|
||||
},
|
||||
],
|
||||
stop_reason: "tool_use",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
});
|
||||
const { agent, updates } = setup(new Map(), async function* (input) {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const first = await iter.next();
|
||||
yield userEcho(first.value);
|
||||
yield todoWrite("toolu_main_todo", "Main step", null);
|
||||
yield todoWrite("toolu_agent_todo", "Agent step", "toolu_agent");
|
||||
yield successfulResult();
|
||||
|
||||
const second = await iter.next();
|
||||
yield userEcho(second.value);
|
||||
yield {
|
||||
type: "conversation_reset",
|
||||
new_conversation_id: randomUUID(),
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
yield successfulResult();
|
||||
});
|
||||
await initializeClient(agent, { plan: {} });
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "Plan" }] });
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "/clear" }] });
|
||||
|
||||
const entries = (content: string) => [{ content, status: "in_progress", priority: "medium" }];
|
||||
expect(
|
||||
updates
|
||||
.map((notification) => notification.update)
|
||||
.filter((update) => planUpdateKinds.has(update.sessionUpdate)),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "plan_update",
|
||||
plan: { type: "items", planId: "plan", entries: entries("Main step") },
|
||||
},
|
||||
{
|
||||
sessionUpdate: "plan_update",
|
||||
plan: { type: "items", planId: "plan:toolu_agent", entries: entries("Agent step") },
|
||||
_meta: { claudeCode: { parentToolUseId: "toolu_agent" } },
|
||||
},
|
||||
{ sessionUpdate: "plan_removed", planId: "plan" },
|
||||
{ sessionUpdate: "plan_removed", planId: "plan:toolu_agent" },
|
||||
{ sessionUpdate: "plan_removed", planId: "plan-mode" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
|
||||
@@ -5225,6 +5293,7 @@ describe("permission request cancellation", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
@@ -15023,6 +15092,7 @@ describe("session/close", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
@@ -15146,6 +15216,7 @@ describe("session/delete", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
@@ -15310,6 +15381,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
@@ -19847,6 +19919,7 @@ describe("post-error recovery", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
@@ -28359,6 +28432,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
@@ -30268,6 +30342,7 @@ describe("agent selection config option", () => {
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
|
||||
@@ -4402,6 +4402,96 @@ describe("ACP v2 requests that subagents make", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a subagent's checklist in the subagent's session, apart from the root's", async () => {
|
||||
const todoWrite = (
|
||||
options: Options,
|
||||
id: string,
|
||||
content: string,
|
||||
parentToolUseId: string | null,
|
||||
) => ({
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: `msg_${id}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id,
|
||||
name: "TodoWrite",
|
||||
input: { todos: [{ content, status: "pending", activeForm: content }] },
|
||||
},
|
||||
],
|
||||
stop_reason: "tool_use",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 3, output_tokens: 2 },
|
||||
},
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId,
|
||||
});
|
||||
scriptTurns(async function* (options) {
|
||||
yield todoWrite(options, "toolu_main_todo", "Main step", null);
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: "msg_agent",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_agent-1",
|
||||
name: "Agent",
|
||||
input: { description: "Explore the project", prompt: "Look around" },
|
||||
},
|
||||
],
|
||||
stop_reason: "tool_use",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 3, output_tokens: 2 },
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId,
|
||||
};
|
||||
yield taskStarted(options, "agent-1", { prompt: "Look around" });
|
||||
yield todoWrite(options, "toolu_agent_todo", "Agent step", "toolu_agent-1");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptToIdle(v2Client());
|
||||
|
||||
const root = client.sessionUpdates.find(
|
||||
({ update }) => update.sessionUpdate === "user_message",
|
||||
)!.sessionId;
|
||||
expect(
|
||||
client.sessionUpdates.flatMap(({ sessionId, update }) =>
|
||||
update.sessionUpdate === "plan_update"
|
||||
? [{ on: sessionId === root ? "root" : sessionId, plan: update.plan }]
|
||||
: [],
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
on: "root",
|
||||
plan: {
|
||||
type: "items",
|
||||
planId: "plan",
|
||||
entries: [{ content: "Main step", status: "pending", priority: "medium" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
on: "agent-1",
|
||||
plan: {
|
||||
type: "items",
|
||||
planId: "plan",
|
||||
entries: [{ content: "Agent step", status: "pending", priority: "medium" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("background work", () => {
|
||||
/** Claude Code's live background task set. */
|
||||
const level = (options: Options, tasks: Array<Record<string, unknown>>) => ({
|
||||
|
||||
@@ -89,6 +89,7 @@ export function mockSessionState(
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
subagentChecklists: new Set(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
|
||||
@@ -2714,6 +2714,72 @@ describe("toAcpNotifications - TodoWrite with undefined input regression", () =>
|
||||
expect(notifications.map(({ update }) => update)).toEqual([expected]);
|
||||
},
|
||||
);
|
||||
|
||||
const subagentTodos = [{ content: "Do Y", status: "pending", activeForm: "Doing Y" }];
|
||||
const subagentEntries = [{ content: "Do Y", status: "pending", priority: "medium" }];
|
||||
const subagentMeta = { claudeCode: { parentToolUseId: "toolu_agent" } };
|
||||
|
||||
it.each([
|
||||
{
|
||||
client: "a client with plan operations",
|
||||
clientCapabilities: { plan: {} },
|
||||
todos: subagentTodos,
|
||||
shownBefore: [],
|
||||
expected: {
|
||||
sessionUpdate: "plan_update",
|
||||
plan: { type: "items", planId: "plan:toolu_agent", entries: subagentEntries },
|
||||
_meta: subagentMeta,
|
||||
},
|
||||
shownAfter: ["plan:toolu_agent"],
|
||||
},
|
||||
{
|
||||
client: "a client with plan operations",
|
||||
clientCapabilities: { plan: {} },
|
||||
todos: [],
|
||||
shownBefore: ["plan:toolu_agent", "plan:toolu_other"],
|
||||
expected: { sessionUpdate: "plan_removed", planId: "plan:toolu_agent", _meta: subagentMeta },
|
||||
shownAfter: ["plan:toolu_other"],
|
||||
},
|
||||
{
|
||||
// The update goes to the subagent's own session, which holds no other checklist.
|
||||
client: "a client with native subagent sessions",
|
||||
clientCapabilities: { plan: {}, subagents: {} },
|
||||
todos: subagentTodos,
|
||||
shownBefore: [],
|
||||
expected: {
|
||||
sessionUpdate: "plan_update",
|
||||
plan: { type: "items", planId: "plan", entries: subagentEntries },
|
||||
_meta: subagentMeta,
|
||||
},
|
||||
shownAfter: [],
|
||||
},
|
||||
{
|
||||
// The client holds one plan per session.
|
||||
client: "a client without plan operations",
|
||||
clientCapabilities: {},
|
||||
todos: subagentTodos,
|
||||
shownBefore: [],
|
||||
expected: { sessionUpdate: "plan", entries: subagentEntries, _meta: subagentMeta },
|
||||
shownAfter: [],
|
||||
},
|
||||
])(
|
||||
"sends $todos.length todos of a subagent to $client",
|
||||
({ clientCapabilities, todos, shownBefore, expected, shownAfter }) => {
|
||||
const shown = new Set(shownBefore);
|
||||
const notifications = toAcpNotifications(
|
||||
[{ type: "tool_use" as const, id: "toolu_todo", name: "TodoWrite", input: { todos } }],
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ clientCapabilities, parentToolUseId: "toolu_agent", subagentChecklists: shown },
|
||||
);
|
||||
|
||||
expect(notifications.map(({ update }) => update)).toEqual([expected]);
|
||||
expect([...shown]).toEqual(shownAfter);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("parseTaskCreateOutput", () => {
|
||||
@@ -2953,38 +3019,58 @@ 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(
|
||||
[
|
||||
// Claude Code shares one task list between the agents of a session, so a
|
||||
// subagent's Task* call shows the same checklist as the main conversation's.
|
||||
it.each([
|
||||
{ caller: "the main conversation", parentToolUseId: undefined, meta: {} },
|
||||
{
|
||||
caller: "a subagent",
|
||||
parentToolUseId: "toolu_agent",
|
||||
meta: { _meta: { claudeCode: { parentToolUseId: "toolu_agent" } } },
|
||||
},
|
||||
])(
|
||||
"sends the task snapshot of $caller as the checklist plan operation to a client with plan operations",
|
||||
({ parentToolUseId, meta }) => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
"1": { type: "tool_use", id: "1", name: "TaskCreate", input: { subject: "First" } },
|
||||
};
|
||||
const subagentChecklists = new Set<string>();
|
||||
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,
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "1",
|
||||
content: JSON.stringify({ task: { id: "1", subject: "First" } }),
|
||||
is_error: false,
|
||||
taskState: new Map(),
|
||||
clientCapabilities: { plan: {} },
|
||||
parentToolUseId,
|
||||
subagentChecklists,
|
||||
},
|
||||
] 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" }],
|
||||
expect(created.map(({ update }) => update)).toEqual([
|
||||
{
|
||||
sessionUpdate: "plan_update",
|
||||
plan: {
|
||||
type: "items",
|
||||
planId: "plan",
|
||||
entries: [{ content: "First", status: "pending", priority: "medium" }],
|
||||
},
|
||||
...meta,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
]);
|
||||
expect(subagentChecklists.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("emits a plan snapshot after a TaskCreate tool_result and accumulates state", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
|
||||
Reference in New Issue
Block a user