mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
fix: Add subagent parent tool use attribution (#859)
Hopefully help address #851
This commit is contained in:
187
src/acp-agent.ts
187
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<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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user