diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 3f2a7d41..25d7c552 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -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; + /** 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; /** 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 { // 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 { 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 diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index c25befc7..7b348ef5 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -131,6 +131,7 @@ function mockSessionState(overrides: Record = {}) { 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) { + 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 };