fix: Add subagent parent tool use attribution (#859)

Hopefully help address #851
This commit is contained in:
Ben Brandt
2026-07-10 11:10:55 +02:00
committed by GitHub
parent 3500ef736f
commit 9cd48c597a
2 changed files with 577 additions and 22 deletions

View File

@@ -309,6 +309,23 @@ type Session = {
* tool_use block streams; this set makes the two paths converge regardless of
* order. Pruned at `tool_result` time alongside `toolUseCache`. */
emittedToolCalls: Set<string>;
/** Maps a live subagent's agent id → the tool_use id of the Agent/Task call
* that spawned it. For subagent tasks the SDK keys its task registry by
* agent id, so `task_started.task_id` IS the `agentID` that `canUseTool`
* later receives, and `task_started.tool_use_id` is the spawning tool call.
* Populated from `task_started` and pruned when the task settles (a
* `task_notification` or a terminal `task_updated` patch). Lets the
* permission flow attribute a subagent's eagerly-emitted `tool_call` (and
* the permission request itself) to its parent tool call via
* `_meta.claudeCode.parentToolUseId`, matching the streamed subagent path.
* Best-effort: a `canUseTool` that races ahead of the consumer processing
* `task_started` omits the attribution from the eager tool_call, and the
* streamed tool_use chunk's refining `tool_call_update` — which carries the
* message-level `parent_tool_use_id` — restores it for merging clients;
* that recovery is what makes best-effort acceptable here. Non-subagent
* tasks (background Bash etc.) also pass through the map; see the
* `task_started` handler. */
subagentParentToolUseIds: Map<string, string>;
/** Maps the ACP `messageId` we expose to clients (see `messageIdForGrouping`)
* to the SDK message uuid that the Agent SDK's rewind/resume APIs key on
* (`Query.rewindFiles` takes a user-message uuid; `resumeSessionAt` takes an
@@ -407,6 +424,10 @@ export type ToolUpdateMeta = {
toolName: string;
/* The structured output provided by Claude Code. */
toolResponse?: unknown;
/* For a tool call made inside a subagent: the tool_use id of the
Agent/Task call that spawned the subagent. Mirrors the SDK's
`parent_tool_use_id` on streamed subagent messages. */
parentToolUseId?: string;
};
/* Terminal metadata for Bash tool execution, matching codex-acp's _meta protocol. */
terminal_info?: {
@@ -1688,10 +1709,38 @@ export class ClaudeAcpAgent {
case "hook_progress":
case "hook_response":
case "files_persisted":
case "task_started":
case "task_notification":
case "task_progress":
break;
case "task_started":
// For subagent tasks `task_id` is the subagent's agent id (the
// SDK keys its task registry by agent id) and `tool_use_id` is
// the Agent/Task tool_use that spawned it. Record the mapping so
// the subagent's permission requests — which reach canUseTool
// with only `agentID` — can attribute their eagerly-emitted
// tool_call to the parent tool call. Non-subagent tasks (e.g.
// background Bash) land here too; their task_ids never match an
// agentID, so the stray entries are inert until pruned below.
if (message.tool_use_id) {
session.subagentParentToolUseIds.set(message.task_id, message.tool_use_id);
}
break;
case "task_notification":
// The task settled — no further tool calls can originate from
// it, so the attribution mapping can be dropped.
session.subagentParentToolUseIds.delete(message.task_id);
break;
case "task_updated":
// terminal-status task_updated patch and a (deduplicated)
// task_notification when a task settles, but only the patch is
// guaranteed per transition — prune on it too so the map can't
// grow for the session's lifetime if a notification is skipped.
if (
message.patch.status === "completed" ||
message.patch.status === "failed" ||
message.patch.status === "killed"
) {
session.subagentParentToolUseIds.delete(message.task_id);
}
break;
case "worker_shutting_down":
// A Remote Control worker announced a graceful teardown. This is a
@@ -2840,6 +2889,7 @@ export class ClaudeAcpAgent {
params: RequestPermissionRequest,
toolName: string,
signal: AbortSignal,
parentToolUseId?: string,
): Promise<RequestPermissionResponse> {
// The SDK may invoke `canUseTool` (and therefore this permission request)
// before the assistant message's tool_use block streams to us. Some ACP clients
@@ -2852,6 +2902,7 @@ export class ClaudeAcpAgent {
toolName,
params.toolCall.toolCallId,
params.toolCall.rawInput,
parentToolUseId,
);
try {
return await this.client.requestPermission(params, signal);
@@ -2869,15 +2920,21 @@ export class ClaudeAcpAgent {
* instead of emitting a duplicate (see `emittedToolCalls`). Built via the same
* `toolCallNotification` helper as the streamed path so the two are identical.
* Tools the stream renders as a plan (TodoWrite) or suppresses (Task*) are
* skipped so a permission prompt for them never surfaces a stray tool_call. */
* emitted too: a permission request referencing a tool call the client has
* never seen can trip strict clients (issue #851), so the reference must
* always resolve. Since the streamed path never completes those calls, they
* are resolved at tool_result time instead (see `toAcpNotifications`).
* `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
* that spawned it, matching the streamed path's `_meta`. */
private async ensureToolCallEmitted(
sessionId: string,
toolName: string,
toolCallId: string,
toolInput: unknown,
parentToolUseId?: string,
): Promise<void> {
const session = this.sessions[sessionId];
if (!session || !shouldEmitToolCall(toolName)) {
if (!session) {
return;
}
if (session.emittedToolCalls.has(toolCallId)) {
@@ -2885,19 +2942,26 @@ export class ClaudeAcpAgent {
}
session.emittedToolCalls.add(toolCallId);
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
await this.client.sessionUpdate({
sessionId,
update: toolCallNotification(
{ id: toolCallId, name: toolName, input: toolInput },
toolInput,
supportsTerminalOutput,
session.cwd,
),
});
const update = toolCallNotification(
{ id: toolCallId, name: toolName, input: toolInput },
toolInput,
supportsTerminalOutput,
session.cwd,
);
if (parentToolUseId) {
update._meta = {
...update._meta,
claudeCode: {
...(update._meta?.claudeCode || {}),
parentToolUseId,
},
};
}
await this.client.sessionUpdate({ sessionId, update });
}
canUseTool(sessionId: string): CanUseTool {
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID }) => {
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
const session = this.sessions[sessionId];
@@ -2908,6 +2972,24 @@ export class ClaudeAcpAgent {
};
}
// When the tool call originates inside a subagent, attribute the eagerly
// emitted tool_call (and the permission request itself) to the Agent/Task
// tool call that spawned the subagent, mirroring the streamed subagent
// path's `_meta.claudeCode.parentToolUseId` (see `subagentParentToolUseIds`).
const parentToolUseId = agentID ? session.subagentParentToolUseIds.get(agentID) : undefined;
if (agentID && !parentToolUseId) {
// The attribution rests on an undocumented SDK invariant
// (task_started.task_id === canUseTool's agentID for subagent tasks;
// verified against the bundled CLI). Should an SDK bump break it — or
// the consumer lose the race with task_started — the lookup misses and
// the request goes out unattributed; log it so the regression is
// observable rather than silent.
this.logger.log(
`[claude-agent-acp] No parent tool_use recorded for subagent ${agentID}; ` +
`sending the ${toolName} permission request unattributed`,
);
}
// AskUserQuestion is surfaced to us as a normal permission check (the SDK
// routes it through canUseTool whenever a callback is registered, rather
// than the interactive dialog). Present it as an ACP form elicitation and
@@ -2915,7 +2997,13 @@ export class ClaudeAcpAgent {
if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
// Like permission requests, the elicitation references this toolUseID, so
// make sure the tool_call has surfaced to the client before we send it.
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
await this.ensureToolCallEmitted(
sessionId,
toolName,
toolUseID,
toolInput,
parentToolUseId,
);
return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
}
@@ -2958,10 +3046,16 @@ export class ClaudeAcpAgent {
supportsTerminalOutput,
session?.cwd,
),
// `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
// so clients can rely on one shape everywhere.
...(parentToolUseId
? { _meta: { claudeCode: { toolName, parentToolUseId } } satisfies ToolUpdateMeta }
: {}),
},
},
toolName,
signal,
parentToolUseId,
);
if (signal.aborted || response.outcome?.outcome === "cancelled") {
@@ -3031,10 +3125,16 @@ export class ClaudeAcpAgent {
supportsTerminalOutput,
session?.cwd,
),
// `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
// so clients can rely on one shape everywhere.
...(parentToolUseId
? { _meta: { claudeCode: { toolName, parentToolUseId } } satisfies ToolUpdateMeta }
: {}),
},
},
toolName,
signal,
parentToolUseId,
);
if (signal.aborted || response.outcome?.outcome === "cancelled") {
throw new Error("Tool use aborted");
@@ -4010,6 +4110,7 @@ export class ClaudeAcpAgent {
taskState,
toolUseCache: {},
emittedToolCalls: new Set(),
subagentParentToolUseIds: new Map(),
messageIdToUuid: new Map(),
};
@@ -5009,10 +5110,12 @@ function isTaskTool(toolName: string): boolean {
);
}
/** Whether a tool's tool_use surfaces to the client as a standalone
/** Whether the streamed tool_use path surfaces this tool as a standalone
* `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
* suppressed (their plan snapshot is emitted at tool_result time), so neither
* produces a tool_call. */
* produces a streamed tool_call/tool_call_update — which means a
* permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
* resolved explicitly at tool_result time. */
function shouldEmitToolCall(toolName: string): boolean {
return toolName !== "TodoWrite" && !isTaskTool(toolName);
}
@@ -5264,15 +5367,65 @@ export function toAcpNotifications(
case "bash_code_execution_tool_result":
case "text_editor_code_execution_tool_result":
case "mcp_tool_result": {
const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
options?.emittedToolCalls?.delete(chunk.tool_use_id);
const toolUse = toolUseCache[chunk.tool_use_id];
if (!toolUse) {
// The permission flow may have surfaced this tool_call even though
// its tool_use never reached the cache (e.g. the assistant message
// carrying it was dropped by the cancelled-turn guard and a straggler
// result landed later). Resolve the surfaced call anyway so it can't
// stay pending in the client forever; without the cache entry the
// tool name is unknown, so no claudeCode meta is attached.
if (wasEmitted) {
output.push({
sessionId,
update: {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update" as const,
status:
"is_error" in chunk && chunk.is_error
? ("failed" as const)
: ("completed" as const),
rawOutput: chunk.content,
},
});
}
logger.error(
`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
);
break;
}
// A permission request may have surfaced a plan-rendered (TodoWrite) or
// suppressed (Task*) tool as a real tool_call so the request referenced
// a tool call the client knows about (see `ensureToolCallEmitted`,
// issue #851). The branches below never emit a tool_call_update for
// those tools, which would leave the surfaced call pending in the
// client forever — resolve it here. `wasEmitted` is only ever true for
// these tools via the permission flow: the streamed plan/suppressed
// branches don't record emissions.
if (wasEmitted && !shouldEmitToolCall(toolUse.name)) {
output.push({
sessionId,
update: {
_meta: {
claudeCode: {
toolName: toolUse.name,
...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
},
} satisfies ToolUpdateMeta,
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update" as const,
status:
"is_error" in chunk && chunk.is_error
? ("failed" as const)
: ("completed" as const),
rawOutput: chunk.content,
},
});
}
if (isTaskTool(toolUse.name)) {
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
// TaskCreate / TaskUpdate mutate the accumulated task list; TaskList

View File

@@ -131,6 +131,7 @@ function mockSessionState(overrides: Record<string, any> = {}) {
taskState: new Map(),
toolUseCache: {},
emittedToolCalls: new Set(),
subagentParentToolUseIds: new Map(),
messageIdToUuid: new Map(),
...overrides,
} as any;
@@ -1830,6 +1831,7 @@ describe("permission request cancellation", () => {
taskState: new Map(),
toolUseCache: {},
emittedToolCalls: new Set(),
subagentParentToolUseIds: new Map(),
messageIdToUuid: new Map(),
} as any;
return agent.sessions[sessionId]!;
@@ -1968,7 +1970,11 @@ describe("tool_call emitted before permission request", () => {
expect(notifications[0].update.sessionUpdate).toBe("tool_call_update");
});
it("does not emit a tool_call for suppressed tools (TodoWrite) on a permission request", async () => {
it("emits a tool_call for plan-rendered tools (TodoWrite) so the permission request references a known id (issue #851)", async () => {
// Strict clients reject (or worse, drop without responding to) a
// permission request whose toolCallId they have never seen as a
// tool_call, so even tools the stream renders as a plan must surface one
// before the request goes out.
const { agent, events, session } = setup();
await agent.canUseTool("session-1")(
@@ -1977,10 +1983,134 @@ describe("tool_call emitted before permission request", () => {
{ signal: new AbortController().signal, suggestions: [], toolUseID: "todo-1" } as any,
);
expect(events).toEqual(["permission"]);
expect(events).toEqual(["update:tool_call", "permission"]);
expect(session.emittedToolCalls.has("todo-1")).toBe(true);
});
it("resolves a permission-surfaced TodoWrite tool_call at tool_result time", () => {
// The streamed path renders TodoWrite as `plan` updates and never sends a
// tool_call_update for it, so the permission-surfaced tool_call must be
// completed when its tool_result arrives or it would stay pending forever.
const { session } = setup();
session.emittedToolCalls.add("todo-1");
session.toolUseCache["todo-1"] = {
type: "tool_use",
id: "todo-1",
name: "TodoWrite",
input: { todos: [] },
};
const notifications = toAcpNotifications(
[{ type: "tool_result", tool_use_id: "todo-1", content: [{ type: "text", text: "ok" }] }],
"user",
"session-1",
session.toolUseCache,
{} as AcpClient,
console,
{ emittedToolCalls: session.emittedToolCalls },
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "todo-1",
status: "completed",
});
expect(session.emittedToolCalls.has("todo-1")).toBe(false);
});
it("marks a permission-surfaced TodoWrite tool_call failed on an is_error tool_result", () => {
// E.g. the user rejected the permission request: the SDK synthesizes an
// is_error tool_result, which must resolve the surfaced call as failed.
const { session } = setup();
session.emittedToolCalls.add("todo-1");
session.toolUseCache["todo-1"] = {
type: "tool_use",
id: "todo-1",
name: "TodoWrite",
input: { todos: [] },
};
const notifications = toAcpNotifications(
[
{
type: "tool_result",
tool_use_id: "todo-1",
is_error: true,
content: [{ type: "text", text: "User refused permission to run tool" }],
},
],
"user",
"session-1",
session.toolUseCache,
{} as AcpClient,
console,
{ emittedToolCalls: session.emittedToolCalls },
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "todo-1",
status: "failed",
});
});
it("resolves permission-surfaced Task* tool_calls too", () => {
// Task* tool_use/tool_result pairs are normally suppressed entirely (their
// state surfaces as plan snapshots), so a permission-surfaced tool_call for
// them also needs an explicit resolution.
const { session } = setup();
session.emittedToolCalls.add("task-1");
session.toolUseCache["task-1"] = {
type: "tool_use",
id: "task-1",
name: "TaskList",
input: {},
};
const notifications = toAcpNotifications(
[{ type: "tool_result", tool_use_id: "task-1", content: [{ type: "text", text: "[]" }] }],
"user",
"session-1",
session.toolUseCache,
{} as AcpClient,
console,
{ emittedToolCalls: session.emittedToolCalls },
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "task-1",
status: "completed",
});
});
it("stays silent on a TodoWrite tool_result when no tool_call was surfaced", () => {
// The common case: TodoWrite was never permission-gated, so nothing was
// emitted for it and its tool_result must remain suppressed as before.
const { session } = setup();
session.toolUseCache["todo-1"] = {
type: "tool_use",
id: "todo-1",
name: "TodoWrite",
input: { todos: [] },
};
const notifications = toAcpNotifications(
[{ type: "tool_result", tool_use_id: "todo-1", content: [{ type: "text", text: "ok" }] }],
"user",
"session-1",
session.toolUseCache,
{} as AcpClient,
console,
{ emittedToolCalls: session.emittedToolCalls },
);
expect(notifications).toHaveLength(0);
});
it("includes Bash terminal_info _meta in the eager tool_call so terminal output can attach", async () => {
const { agent, updates } = setup();
// Terminal-capable client (e.g. Zed). The eager tool_call must carry
@@ -2001,13 +2131,16 @@ describe("tool_call emitted before permission request", () => {
});
});
it("prunes the emission marker on a tool_result even when the tool_use was never cached", () => {
it("prunes the emission marker and resolves the call on a tool_result even when the tool_use was never cached", () => {
const { session } = setup();
// Eager-emitted via the permission flow, but the tool_use chunk never
// streamed (e.g. cancelled), so toolUseCache has no entry for it.
// streamed (e.g. the assistant message was dropped by the cancelled-turn
// guard), so toolUseCache has no entry for it. The surfaced tool_call must
// still resolve — the eager emission was its only surface — even though the
// tool name (and thus claudeCode meta) is unknowable here.
session.emittedToolCalls.add("tool-1");
toAcpNotifications(
const notifications = toAcpNotifications(
[{ type: "tool_result", tool_use_id: "tool-1", content: [{ type: "text", text: "x" }] }],
"user",
"session-1",
@@ -2019,6 +2152,269 @@ describe("tool_call emitted before permission request", () => {
);
expect(session.emittedToolCalls.has("tool-1")).toBe(false);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "completed",
});
});
it("does not resolve an uncached tool_result that was never surfaced", () => {
// Without an eager emission there is no pending tool_call to settle, so
// the untracked-result path must stay silent as before.
const { session } = setup();
const notifications = toAcpNotifications(
[{ type: "tool_result", tool_use_id: "tool-1", content: [{ type: "text", text: "x" }] }],
"user",
"session-1",
session.toolUseCache,
{} as AcpClient,
{ log: () => {}, error: () => {} },
{ emittedToolCalls: session.emittedToolCalls },
);
expect(notifications).toHaveLength(0);
});
});
describe("subagent permission attribution (issue #851)", () => {
// A background subagent's permission requests reach canUseTool with only an
// `agentID`; the streamed subagent messages carry `parent_tool_use_id`
// instead. `task_started` bridges the two (for subagent tasks its `task_id`
// IS the agent id and its `tool_use_id` is the spawning Agent/Task call), so
// the eagerly-emitted tool_call and the permission request can be attributed
// to the parent tool call the same way the streamed path attributes updates.
function setup() {
const updates: SessionNotification[] = [];
const requests: RequestPermissionRequest[] = [];
const log = vi.fn();
const mockClient = {
sessionUpdate: async (n: SessionNotification) => {
updates.push(n);
},
requestPermission: async (params: RequestPermissionRequest) => {
requests.push(params);
return { outcome: { outcome: "selected", optionId: "allow" } };
},
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(mockClient, { log, error: () => {} });
agent.sessions["session-1"] = mockSessionState();
return { agent, updates, requests, log, session: agent.sessions["session-1"]! };
}
it("attributes a subagent tool's eager tool_call and permission request to the spawning tool call", async () => {
const { agent, updates, requests, session } = setup();
session.subagentParentToolUseIds.set("agent-42", "toolu_parent");
await agent.canUseTool("session-1")("Bash", { command: "ls" }, {
signal: new AbortController().signal,
suggestions: [],
toolUseID: "toolu_sub",
agentID: "agent-42",
} as any);
expect(updates[0].update).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "toolu_sub",
_meta: { claudeCode: { parentToolUseId: "toolu_parent" } },
});
// The request's claudeCode meta keeps the shape every other claudeCode
// meta has (toolName is required by ToolUpdateMeta).
expect(requests[0].toolCall._meta).toMatchObject({
claudeCode: { toolName: "Bash", parentToolUseId: "toolu_parent" },
});
});
it("omits the attribution (and logs the miss) when the agent id has no recorded parent", async () => {
const { agent, updates, requests, log } = setup();
await agent.canUseTool("session-1")("Bash", { command: "ls" }, {
signal: new AbortController().signal,
suggestions: [],
toolUseID: "toolu_sub",
agentID: "agent-unknown",
} as any);
const meta = updates[0].update._meta as { claudeCode?: { parentToolUseId?: string } };
expect(meta.claudeCode?.parentToolUseId).toBeUndefined();
expect(requests[0].toolCall._meta).toBeUndefined();
// The task_id === agentID invariant is undocumented SDK behavior; a miss
// must be observable so an SDK bump that breaks it doesn't regress silently.
expect(log).toHaveBeenCalledWith(expect.stringContaining("agent-unknown"));
});
it("restores the attribution via the streamed refinement when the eager emission raced task_started", () => {
// If canUseTool wins the race against the consumer processing
// task_started, the eager tool_call goes out unattributed. The streamed
// tool_use chunk — whose message carries parent_tool_use_id — then refines
// it with a tool_call_update that restores the parent meta for merging
// clients. This recovery is what makes the best-effort lookup acceptable.
const { session } = setup();
session.emittedToolCalls.add("toolu_sub");
const notifications = toAcpNotifications(
[{ type: "tool_use", id: "toolu_sub", name: "Bash", input: { command: "ls" } }],
"assistant",
"session-1",
session.toolUseCache,
{} as AcpClient,
console,
{ emittedToolCalls: session.emittedToolCalls, parentToolUseId: "toolu_parent" },
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_sub",
_meta: { claudeCode: { parentToolUseId: "toolu_parent" } },
});
});
function taskStarted(taskId: string, toolUseId: string) {
return {
type: "system",
subtype: "task_started",
task_id: taskId,
tool_use_id: toolUseId,
description: "Investigate",
uuid: randomUUID(),
session_id: "test-session",
};
}
function successResult() {
return {
type: "result" as const,
subtype: "success" as const,
stop_reason: null,
is_error: false,
result: "",
errors: [],
duration_ms: 0,
duration_api_ms: 0,
num_turns: 1,
total_cost_usd: 0,
usage: {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
modelUsage: {},
permission_denials: [],
uuid: randomUUID(),
session_id: "test-session",
};
}
/** Replays the prompt's user echo (so the turn activates), then the given
* messages, then settles the turn. */
function makeGenerator(messages: unknown[]) {
return async function* (input: Pushable<any>) {
const iter = input[Symbol.asyncIterator]();
const { value: userMessage, done } = await iter.next();
if (!done && userMessage) {
yield userEcho(userMessage);
}
yield* messages as any;
yield { type: "system", subtype: "session_state_changed", state: "idle" };
};
}
it("records task_started's task_id → tool_use_id mapping while consuming the stream", async () => {
const agent = new ClaudeAcpAgent(
{ sessionUpdate: vi.fn(async () => {}) } as unknown as AcpClient,
{
log: () => {},
error: () => {},
},
);
injectGeneratorSession(
agent,
makeGenerator([taskStarted("agent-42", "toolu_parent"), successResult()]),
);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
expect(agent.sessions["test-session"]!.subagentParentToolUseIds.get("agent-42")).toBe(
"toolu_parent",
);
});
it("prunes the mapping when the task settles (task_notification)", async () => {
const agent = new ClaudeAcpAgent(
{ sessionUpdate: vi.fn(async () => {}) } as unknown as AcpClient,
{
log: () => {},
error: () => {},
},
);
injectGeneratorSession(
agent,
makeGenerator([
taskStarted("agent-42", "toolu_parent"),
{
type: "system",
subtype: "task_notification",
task_id: "agent-42",
tool_use_id: "toolu_parent",
status: "completed",
output_file: "",
summary: "done",
uuid: randomUUID(),
session_id: "test-session",
},
successResult(),
]),
);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
expect(agent.sessions["test-session"]!.subagentParentToolUseIds.size).toBe(0);
});
it("prunes the mapping on a terminal task_updated patch (belt and braces)", async () => {
const agent = new ClaudeAcpAgent(
{ sessionUpdate: vi.fn(async () => {}) } as unknown as AcpClient,
{
log: () => {},
error: () => {},
},
);
injectGeneratorSession(
agent,
makeGenerator([
taskStarted("agent-42", "toolu_parent"),
taskStarted("agent-43", "toolu_parent_2"),
{
type: "system",
subtype: "task_updated",
task_id: "agent-42",
patch: { status: "completed" },
uuid: randomUUID(),
session_id: "test-session",
},
// A non-terminal patch must NOT prune: the task keeps running and its
// permission requests still need the attribution.
{
type: "system",
subtype: "task_updated",
task_id: "agent-43",
patch: { status: "running", is_backgrounded: true },
uuid: randomUUID(),
session_id: "test-session",
},
successResult(),
]),
);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
const map = agent.sessions["test-session"]!.subagentParentToolUseIds;
expect(map.has("agent-42")).toBe(false);
expect(map.get("agent-43")).toBe("toolu_parent_2");
});
});
@@ -3177,6 +3573,7 @@ describe("session/close", () => {
taskState: new Map(),
toolUseCache: {},
emittedToolCalls: new Set(),
subagentParentToolUseIds: new Map(),
messageIdToUuid: new Map(),
};
return agent.sessions[sessionId]!;
@@ -3264,6 +3661,7 @@ describe("session/delete", () => {
taskState: new Map(),
toolUseCache: {},
emittedToolCalls: new Set(),
subagentParentToolUseIds: new Map(),
messageIdToUuid: new Map(),
};
return agent.sessions[sessionId]!;
@@ -3368,6 +3766,7 @@ describe("getOrCreateSession param change detection", () => {
taskState: new Map(),
toolUseCache: {},
emittedToolCalls: new Set(),
subagentParentToolUseIds: new Map(),
messageIdToUuid: new Map(),
};
return agent.sessions[sessionId]!;
@@ -5605,6 +6004,7 @@ describe("post-error recovery", () => {
taskState: new Map(),
toolUseCache: {},
emittedToolCalls: new Set(),
subagentParentToolUseIds: new Map(),
messageIdToUuid: new Map(),
};
return { interrupt };
@@ -6375,6 +6775,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
taskState: new Map(),
toolUseCache: {},
emittedToolCalls: new Set(),
subagentParentToolUseIds: new Map(),
messageIdToUuid: new Map(),
};
return { interrupt };
@@ -7133,6 +7534,7 @@ describe("agent selection config option", () => {
taskState: new Map(),
toolUseCache: {},
emittedToolCalls: new Set(),
subagentParentToolUseIds: new Map(),
messageIdToUuid: new Map(),
};
return { session: agent.sessions[sessionId]!, applyFlagSettings };