diff --git a/README.md b/README.md index 72590f10..0608ae9c 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,18 @@ Task subagent as its own child session, following the [subagents RFD](https://gi `end_turn` or `cancelled` when the SDK gives the reason. A failure is `idle` with the `error` stop reason and the SDK's error text as its `error`. The child keeps its session for every delegation to it. -- The prompt of the Agent or Task call that launches the child, and the message of each - SendMessage call that resumes it, is a `session_message` in the child's transcript. The tool - call stays a tool call of the session that made it. +- Session messages replace the Agent or Task call that launches the child, which its parent is + not shown: + - The launch's prompt is a `session_message` in both transcripts: the parent's outgoing view and + the child's incoming one. Both use the launch's tool use id, and each names the other with the + draft RFD's `recipientMessageId` and `senderMessageId`. + - A foreground child's answer, from the launch's result, is the child's `session_message` to + the parent. In Claude Code's hand-back mode, the child's `SubagentHandback` call is its + message to the parent instead, in both transcripts and linked the same way. + - A launch whose child is never exposed is shown as a tool call after all, with its final + status. +- The message of a SendMessage call that resumes the child is a `session_message` in the child's + transcript. The SendMessage call stays a tool call of the session that made it. - `session/cancel` on the parent interrupts Claude Code, which stops the foreground children that the turn waits on. Background children keep running, as the RFD prefers; a client stops one with its own `cancel`. Each stopped child reports `idle` with `cancelled` from Claude Code's own @@ -65,8 +74,8 @@ Task subagent as its own child session, following the [subagents RFD](https://gi exits is `idle` with no stop reason. - A child whose parent is not yet known when it ends is not exposed, since the RFD forbids guessing its parent. -- A replay restores each child under its live session id, from its own transcript: its prompt and - each SendMessage message are `session_message`s, and its end is an `idle` before the response. +- A replay restores each child under its live session id, from its own transcript, with the same + session messages as live, and its end is an `idle` before the response. A launch without Claude Code's metadata file for the child stays an ordinary tool call of the parent, and a notice, for a client that takes notices, says that subagent history is missing. - After a load or resume, with or without replay, a SendMessage that resumes an agent from before diff --git a/src/acp-agent.ts b/src/acp-agent.ts index ee9da94f..e9049160 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -9602,6 +9602,7 @@ export class ClaudeAcpAgent { ) { continue; } + if (rfdReplay && (await rfdReplay.toolCallUpdate(notification.update, child))) continue; await this.client.sessionUpdate({ ...notification, sessionId: replayTargetSessionId }); } diff --git a/src/native-subagents.ts b/src/native-subagents.ts index 3faed88e..c68890b9 100644 --- a/src/native-subagents.ts +++ b/src/native-subagents.ts @@ -1,6 +1,7 @@ import { RequestError, type SessionNotification } from "@agentclientprotocol/sdk/unstable"; import type { AcpSessionNotification, SubagentForm, SubagentState } from "./acp-subagents.js"; import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js"; +import { isFinalToolCallStatus } from "./tool-calls/renderer.js"; /** The work state of a child, as the RFD's `subagent_update.state` carries it. */ export type WorkState = NonNullable< @@ -44,6 +45,8 @@ export type NativeSubagent = { promptIds?: Set; /** The RFD form: the calls that delegated to the child, whose runs ended. */ endedRuns?: Set; + /** The RFD form: the calls that delegated to the child, whose runs handed back a report. */ + handedBack?: Set; announced?: boolean; terminalState?: SubagentState; /** Connection-local single-flight state; never serialized on the wire. */ @@ -144,6 +147,21 @@ export class NativeSubagentRuntime { private readonly taskFinishPromises = new Map>(); /** The session that made each SendMessage call. */ private readonly senderByToolUse = new Map(); + /** + * The RFD form: the Agent/Task calls that the client is not shown, until + * their final update. Kept by id because the adapter's own report that + * closes an unfinished call has no `toolName` to recognize it by. + */ + private readonly hiddenLaunches = new Map(); + /** + * The RFD form: the SubagentHandback calls of announced children that the + * client is not shown yet, with their first report, the report from their + * input, and the call that delegated the run. + */ + private readonly handbacks = new Map< + string, + { initial: AcpSessionNotification; message?: string; delegationId?: string } + >(); private readonly generationByTaskId = new Map(); private readonly pending = new Map(); private pendingCount = 0; @@ -206,7 +224,7 @@ export class NativeSubagentRuntime { const { update } = notification; const claudeMeta = update._meta?.claudeCode as { parentToolUseId?: string | null; toolName?: string } | undefined; - const isControl = isNativeSubagentControlUpdate(update); + const isControl = this.isControlUpdate(update); if (!this.enabled) return notification; @@ -253,7 +271,7 @@ export class NativeSubagentRuntime { if (forcedSessionId) { return { ...notification, sessionId: forcedSessionId }; } - return failedControlFallback( + return unexposedControlFallback( initial, notification, parentSessionId ?? this.rootSessionId, @@ -261,8 +279,19 @@ export class NativeSubagentRuntime { } return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null; } - // The RFD form: the Agent/Task tool call is the parent's record of the - // delegation, so it stays a tool call of the session that made it. + // The RFD form: the parent's session messages replace the Agent/Task + // tool call, its prompt as the parent's outgoing message (see + // `linkedSessionMessages`) and its result as the child's reply (see + // `launchReply`). A call made inside a child that is not announced yet + // is buffered below, as any update of that child. + if (!parentPending) { + return this.routeRfdLaunch( + notification, + child, + parentSessionId ?? this.rootSessionId, + forcedSessionId, + ); + } } const toolCallId = @@ -281,6 +310,15 @@ export class NativeSubagentRuntime { } const owner = toolCallId ? this.childByToolCall.get(toolCallId) : undefined; + const announcedChild = + owner ?? + (claudeMeta?.parentToolUseId + ? this.childByParentToolUse.get(claudeMeta.parentToolUseId) + : undefined); + if (this.form === "rfd" && announcedChild?.announced) { + const handback = await this.routeRfdHandback(notification, announcedChild); + if (handback !== undefined) return handback; + } if (owner) return this.toChild(owner, notification, toolCallId); if (this.enabled && claudeMeta?.parentToolUseId) { @@ -973,6 +1011,109 @@ export class NativeSubagentRuntime { } } + /** + * The RFD form: an update of the Agent/Task call that launches `child`, + * which the parent's session messages replace. Only a launch whose child + * was never exposed stays a tool call of the parent, with its final status, + * since the RFD lets the Agent represent unexposed work through the parent. + * A background launch completes before its child starts, so its completion + * is not such a case. + */ + private async routeRfdLaunch( + notification: AcpSessionNotification, + child: NativeSubagent | undefined, + parentSessionId: string, + forcedSessionId: string | undefined, + ): Promise { + const { update } = notification; + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return null; + } + const toolCallId = update.toolCallId; + // A permission request already showed the call to the client, so it + // keeps getting its updates. + if (forcedSessionId) return { ...notification, sessionId: forcedSessionId }; + const launch = this.hiddenLaunches.get(toolCallId) ?? { background: false }; + if (isBackgroundLaunch(update.rawInput)) launch.background = true; + if (!isFinalToolCallStatus(update.status)) { + this.hiddenLaunches.set(toolCallId, launch); + return null; + } + this.hiddenLaunches.delete(toolCallId); + if (child?.announcePromise) await child.announcePromise; + const background = launch.background; + if (child?.announced) { + const reply = + update.status === "completed" && !background && !child.handedBack?.has(toolCallId) + ? launchReply(toolCallId, child.sessionId, child.parentSessionId, update) + : undefined; + if (reply) await this.publish(reply); + return null; + } + if (background && update.status === "completed") return null; + this.takePending(toolCallId); + const initial = this.controlByToolUse.get(toolCallId); + this.cleanupControl(toolCallId); + return unexposedControlFallback(initial, notification, parentSessionId); + } + + /** + * The RFD form: an update of a SubagentHandback call of the announced + * `child`, which its report replaces: the child's message to its parent, + * see {@link linkedSessionMessages}. A failed call delivered nothing, so the + * child is shown the call after all. `undefined` for any other update. + */ + private async routeRfdHandback( + notification: AcpSessionNotification, + child: NativeSubagent, + ): Promise { + const { update } = notification; + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return undefined; + } + const claudeMeta = update._meta?.claudeCode as + { parentToolUseId?: string | null; toolName?: string } | undefined; + let handback = this.handbacks.get(update.toolCallId); + if (!handback) { + if (claudeMeta?.toolName !== HANDBACK_TOOL) return undefined; + handback = { + initial: notification, + delegationId: claudeMeta.parentToolUseId ?? child.parentToolUseId, + }; + this.handbacks.set(update.toolCallId, handback); + } + handback.message = handbackMessage(update.rawInput) ?? handback.message; + if (!isFinalToolCallStatus(update.status)) return null; + this.handbacks.delete(update.toolCallId); + if (update.status === "completed" && handback.message) { + for (const view of linkedSessionMessages( + update.toolCallId, + child.sessionId, + child.parentSessionId, + handback.message, + )) { + await this.publish(view); + } + if (handback.delegationId) (child.handedBack ??= new Set()).add(handback.delegationId); + return null; + } + return unexposedControlFallback(handback.initial, notification, child.sessionId); + } + + /** Whether `update` is of an Agent/Task call, by its tool name or, in the + * RFD form, by the id of a call the client is not shown. */ + private isControlUpdate( + update: AcpSessionNotification["update"], + ): update is Extract< + AcpSessionNotification["update"], + { sessionUpdate: "tool_call" | "tool_call_update" } + > { + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return false; + } + return this.hiddenLaunches.has(update.toolCallId) || isNativeSubagentControlUpdate(update); + } + private rememberToolCallOwner(toolCallId: string, child: NativeSubagent): void { this.childByToolCall.delete(toolCallId); this.childByToolCall.set(toolCallId, child); @@ -1019,6 +1160,8 @@ export class NativeSubagentRuntime { this.childByParentToolUse.clear(); this.taskFinishPromises.clear(); this.senderByToolUse.clear(); + this.hiddenLaunches.clear(); + this.handbacks.clear(); this.generationByTaskId.clear(); this.pending.clear(); this.pendingCount = 0; @@ -1196,6 +1339,7 @@ export async function announceNativeSubagent( child.parentToolUseId, child.parentSessionId, publish, + { replacesLaunch: true }, ); } return; @@ -1256,6 +1400,10 @@ export async function finishNativeSubagent( * Agent/Task call or a SendMessage, so a prompt whose id is already in the * transcript is the same delegation and is not sent again. `sender` is the * session that made that call, when known. + * + * A launch's prompt (`replacesLaunch`) also goes to the parent, see + * {@link linkedSessionMessages}. A SendMessage call stays a tool call of the + * sender, so its message has only the incoming view. */ async function publishPrompt( child: NativeSubagent, @@ -1263,11 +1411,18 @@ async function publishPrompt( toolUseId: string | undefined, sender: string | undefined, publish: Publish, + { replacesLaunch = false }: { replacesLaunch?: boolean } = {}, ): Promise { child.promptIds ??= new Set(); const messageId = toolUseId ?? `${child.sessionId}:prompt:${child.promptIds.size + 1}`; if (child.promptIds.has(messageId)) return; child.promptIds.add(messageId); + if (replacesLaunch && sender !== undefined && toolUseId !== undefined) { + for (const view of linkedSessionMessages(toolUseId, sender, child.sessionId, prompt)) { + await publish(view); + } + return; + } await publish({ sessionId: child.sessionId, update: { @@ -1280,6 +1435,114 @@ async function publishPrompt( }); } +/** + * The RFD form: a message that a tool call sent, which the client is not + * shown, as two session messages: the sender's outgoing view, then the + * recipient's incoming one. That call is a launch's Agent/Task call, whose + * prompt goes from the parent to the child, or a child's SubagentHandback + * call, whose report goes from the child to the parent. Both views use the + * call's tool use id, and each names the other with the draft RFD's + * `recipientMessageId` and `senderMessageId`. + */ +export function linkedSessionMessages( + toolUseId: string, + senderSessionId: string, + recipientSessionId: string, + text: string, +): AcpSessionNotification[] { + const message = { + sessionUpdate: "session_message" as const, + messageId: toolUseId, + senderSessionId, + recipientSessionId, + content: [{ type: "text" as const, text }], + }; + return [ + { + sessionId: senderSessionId, + update: { ...message, recipientMessageId: toolUseId } as AcpSessionNotification["update"], + }, + { + sessionId: recipientSessionId, + update: { ...message, senderMessageId: toolUseId } as AcpSessionNotification["update"], + }, + ]; +} + +/** + * The tool with which a child hands its report to its parent, when Claude + * Code runs in hand-back mode. The Agent/Task result then only says that the + * report was delivered as a message, with `handback: "send"` in its + * structured result. + */ +export const HANDBACK_TOOL = "SubagentHandback"; + +/** The report of a SubagentHandback call, from its input. */ +export function handbackMessage(input: unknown): string | undefined { + const message = (input as { message?: unknown } | null | undefined)?.message; + return typeof message === "string" && message.trim() ? message : undefined; +} + +/** + * The RFD form: the answer of a foreground child, from the completed result + * of the Agent/Task call `launchId` that launched it, as the child's message + * in the parent's transcript (its incoming view). It replaces that call's + * result, which the parent is not shown. The child's transcript has its + * answer as its own agent message, not as a session message, so this view + * links to no other. `undefined` for a result without text. Not used for a + * run whose child handed back its report: the result has no report then. + */ +export function launchReply( + launchId: string, + childSessionId: string, + parentSessionId: string, + result: Extract< + AcpSessionNotification["update"], + { sessionUpdate: "tool_call_update" | "tool_call" } + >, +): AcpSessionNotification | undefined { + const content = replyContent(result); + if (content.length === 0) return undefined; + return { + sessionId: parentSessionId, + update: { + sessionUpdate: "session_message", + messageId: `${launchId}:reply`, + senderSessionId: childSessionId, + recipientSessionId: parentSessionId, + content, + }, + }; +} + +/** + * The text of a completed launch's result, from the tool call's `content`: + * `AgentReporter` removed the parts meant for the model there (the + * `agentId: … (use SendMessage …)` line, the `` block and the hand-back + * frame), which `rawOutput` still has. + */ +function replyContent( + result: Extract< + AcpSessionNotification["update"], + { sessionUpdate: "tool_call_update" | "tool_call" } + >, +): Array<{ type: "text"; text: string }> { + return (result.content ?? []).flatMap((item) => + item.type === "content" && item.content.type === "text" && item.content.text.trim() + ? [{ type: "text" as const, text: item.content.text }] + : [], + ); +} + +/** Whether an Agent/Task call's input runs its child in the background. */ +function isBackgroundLaunch(rawInput: unknown): boolean { + return ( + typeof rawInput === "object" && + rawInput !== null && + (rawInput as { run_in_background?: unknown }).run_in_background === true + ); +} + /** The RFD form: whether the child's foreground work is in progress. */ function isWorking(child: NativeSubagent): boolean { return child.workState === "running" || child.workState === "requires_action"; @@ -1443,7 +1706,13 @@ function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boole ); } -function failedControlFallback( +/** + * A tool call that the client was not shown, as one `tool_call` with its + * final status, shown after all: an Agent/Task call whose child was never + * exposed (AIR's draft shows only a failed one, the RFD form also a completed + * one), or a failed SubagentHandback call. + */ +function unexposedControlFallback( initial: AcpSessionNotification | undefined, terminal: AcpSessionNotification, sessionId: string, @@ -1462,7 +1731,7 @@ function failedControlFallback( update: { ...terminal.update, sessionUpdate: "tool_call", - status: "failed", + status: terminal.update.status ?? "failed", // The synthesized tool_call is this call's first report, so give it // the standard `name` the initial one would have carried. ...(typeof claudeMeta?.toolName === "string" ? { name: claudeMeta.toolName } : {}), @@ -1483,7 +1752,7 @@ function failedControlFallback( ...initial.update, ...terminal.update, sessionUpdate: "tool_call", - status: "failed", + status: terminal.update.status ?? "failed", title: typeof terminal.update.title === "string" && terminal.update.title.trim().length > 0 ? terminal.update.title diff --git a/src/subagent-replay.ts b/src/subagent-replay.ts index 45ad4754..22539abd 100644 --- a/src/subagent-replay.ts +++ b/src/subagent-replay.ts @@ -8,6 +8,13 @@ * history to the child session. Every child's end goes out before the replay * ends, since no subagent from before the reload still runs. * + * As live, the parent is not shown the Agent or Task call of a child that the + * replay announces: the launch's prompt is the parent's outgoing message, and + * a foreground child's answer is its reply to the parent. A child's + * successful SubagentHandback call is its message to the parent instead. + * Unlike live, the replay does not tell runs apart, so a child that handed + * back any report gets no reply from its launch's result. + * * A launch without metadata has no known id, so it is not announced: the RFD * forbids inventing one. Its Agent call stays an ordinary tool call of the * parent, and a notice says that subagent history is missing. @@ -15,7 +22,11 @@ import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk"; import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js"; import { + HANDBACK_TOOL, + handbackMessage, isNativeSubagentControlTool, + launchReply, + linkedSessionMessages, nativeSubagentState, rfdEndState, rfdLabels, @@ -43,6 +54,8 @@ export type ReplayChild = { notified?: SubagentState; announced: boolean; promptSent: boolean; + /** Whether it handed a report to its parent with a SubagentHandback call. */ + handedBack?: boolean; }; type SendMessageCall = { @@ -62,6 +75,11 @@ export class RfdSubagentReplay { private readonly omitted = new Set(); /** Children whose transcript could not be read: only their association is replayed. */ private withoutHistory = 0; + /** The SubagentHandback calls, with their report and whether their result failed. */ + private readonly handbacks = new Map< + string, + { message: string; failed: boolean; sent: boolean } + >(); constructor( private readonly history: SubagentHistory, @@ -78,6 +96,9 @@ export class RfdSubagentReplay { if (block.type === "tool_use" && typeof block.id === "string") { if (isNativeSubagentControlTool(block.name)) { this.registerLaunch(block.id, inputOf(block), ownerSessionId); + } else if (block.name === HANDBACK_TOOL) { + const message = handbackMessage(block.input); + if (message) this.handbacks.set(block.id, { message, failed: false, sent: false }); } else if (block.name === "SendMessage") { const input = inputOf(block); if (typeof input.to === "string" && typeof input.message === "string") { @@ -91,6 +112,8 @@ export class RfdSubagentReplay { } } } else if (block.type === "tool_result" && typeof block.tool_use_id === "string") { + const handback = this.handbacks.get(block.tool_use_id); + if (handback && block.is_error === true) handback.failed = true; const child = this.children.get(block.tool_use_id); // A background launch answers at once; its result is not the end. if (child && !child.record.background) { @@ -167,6 +190,51 @@ export class RfdSubagentReplay { } } + /** + * Takes a replayed update of a tool call, from the transcript of `owner` + * when it is a child's. Returns whether the client is not shown it: an + * update of the launch of a child that the replay shows, or of a successful + * SubagentHandback call of `owner`. A foreground launch's completed result + * goes out as the child's reply, and a hand-back as the child's message to + * its parent. + */ + async toolCallUpdate( + update: AcpSessionNotification["update"], + owner: ReplayChild | undefined, + ): Promise { + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return false; + } + const handback = owner ? this.handbacks.get(update.toolCallId) : undefined; + if (owner && handback && !handback.failed) { + if (!handback.sent) { + handback.sent = true; + owner.handedBack = true; + for (const view of linkedSessionMessages( + update.toolCallId, + owner.sessionId, + owner.parentSessionId, + handback.message, + )) { + await this.publish(view); + } + } + return true; + } + const child = this.children.get(update.toolCallId); + if (!child) return false; + if ( + update.status === "completed" && + child.announced && + !child.record.background && + !child.handedBack + ) { + const reply = launchReply(child.launchId, child.sessionId, child.parentSessionId, update); + if (reply) await this.publish(reply); + } + return true; + } + /** * Takes a persisted task notification. Returns whether it was a child's: * its task id is the agent id. @@ -234,10 +302,17 @@ export class RfdSubagentReplay { this.byAgentId.set(record.agentId, child); } - /** The prompt of the launch, with the launch's id, as live. */ + /** The prompt of the launch, with the launch's id, to the parent and the child, as live. */ private async sendPrompt(child: ReplayChild, text: string): Promise { child.promptSent = true; - await this.sendMessage(child, child.launchId, text, child.parentSessionId); + for (const view of linkedSessionMessages( + child.launchId, + child.parentSessionId, + child.sessionId, + text, + )) { + await this.publish(view); + } } /** diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 66b0b986..95e63bed 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -3803,10 +3803,19 @@ describe("subagent transcript replay", () => { switch (update.sessionUpdate) { case "subagent_update": return [`${on} subagent_update ${u.sessionId} ${JSON.stringify(u.state)}`]; - case "session_message": - return [ - `${on} session_message ${u.messageId} from=${u.senderSessionId ?? "-"} ${u.content[0].text}`, + case "session_message": { + const references = [ + ...(u.senderMessageId ? [`senderMessageId=${u.senderMessageId}`] : []), + ...(u.recipientMessageId ? [`recipientMessageId=${u.recipientMessageId}`] : []), ]; + return [ + [ + `${on} session_message ${u.messageId} from=${u.senderSessionId ?? "-"}`, + ...references, + u.content[0].text, + ].join(" "), + ]; + } case "tool_call": return [`${on} tool_call ${u.toolCallId}`]; case "notice": @@ -3877,11 +3886,12 @@ describe("subagent transcript replay", () => { expect(trace).toEqual([ "root user_message_chunk Check it", "root agent_message_chunk I'll fork a worker.", - "root tool_call toolu_fork", "root tool_call toolu_read", 'root subagent_update fork1 {"state":"running"}', - `fork1 session_message toolu_fork from=${sessionId} Inspect the parser`, + `root session_message toolu_fork from=${sessionId} recipientMessageId=toolu_fork Inspect the parser`, + `fork1 session_message toolu_fork from=${sessionId} senderMessageId=toolu_fork Inspect the parser`, "fork1 agent_message_chunk Parser is fine.", + "root session_message toolu_fork:reply from=fork1 Parser is fine.", "root agent_message_chunk Done.", 'root subagent_update fork1 {"state":"idle","stopReason":"end_turn"}', ]); @@ -3959,20 +3969,25 @@ describe("subagent transcript replay", () => { expect(trace).toEqual([ "root user_message_chunk Explore it", - "root tool_call toolu_agent", + // The launch without metadata stays a tool call; the other one is + // replaced by the parent's session messages. "root tool_call toolu_lost", // The child is announced under its agent id, right after its launch. 'root subagent_update abc123 {"state":"running"}', - "abc123 session_message toolu_agent from=" + sessionId + " Look around", + `root session_message toolu_agent from=${sessionId} recipientMessageId=toolu_agent Look around`, + `abc123 session_message toolu_agent from=${sessionId} senderMessageId=toolu_agent Look around`, "abc123 agent_message_chunk Child report.", - "abc123 tool_call toolu_nested", // A nested child is announced on the child that launched it. 'abc123 subagent_update def456 {"state":"running"}', - "def456 session_message toolu_nested from=abc123 Read spec.md", + "abc123 session_message toolu_nested from=abc123 recipientMessageId=toolu_nested Read spec.md", + "def456 session_message toolu_nested from=abc123 senderMessageId=toolu_nested Read spec.md", "def456 agent_message_chunk X.", - // The delivered message has the id, text, and sender of its SendMessage call. + "abc123 session_message toolu_nested:reply from=def456 The spec says X.", + // The delivered message has the id, text, and sender of its SendMessage + // call, which stays a tool call of its sender. "abc123 session_message toolu_send from=" + sessionId + " Check the tests too", "abc123 agent_message_chunk Tests pass.", + "root session_message toolu_agent:reply from=abc123 Found it.", "root tool_call toolu_send", "root agent_message_chunk Done.", // Every end comes before the response. @@ -3982,6 +3997,45 @@ describe("subagent transcript replay", () => { ]); }); + it("replays a child's hand-back as its message to the parent, instead of the launch's result", async () => { + const trace = await replayRfd( + [ + user("u1", "Ask it"), + assistant("a1", [ + toolUse("toolu_agent", "Agent", { subagent_type: "Explore", prompt: "Say PONG" }), + ]), + // In hand-back mode the launch's result only says where the report went. + user("u2", [toolResult("toolu_agent", "The report was delivered as a message.")]), + assistant("a2", [{ type: "text", text: "It said PONG." }]), + ], + { + abc123: { + meta: { agentType: "Explore", toolUseId: "toolu_agent", requestShape: "foreground" }, + records: [ + user("c1", "Say PONG"), + assistant("c2", [toolUse("toolu_handback", "SubagentHandback", { message: "PONG" })]), + user("c3", [toolResult("toolu_handback", "Report delivered to your caller.")]), + // A failed hand-back delivered nothing, so it stays a tool call. + assistant("c4", [toolUse("toolu_lost", "SubagentHandback", { message: "Again" })]), + user("c5", [{ ...toolResult("toolu_lost", "Not delivered"), is_error: true }]), + ], + }, + }, + ); + + expect(trace).toEqual([ + "root user_message_chunk Ask it", + 'root subagent_update abc123 {"state":"running"}', + `root session_message toolu_agent from=${sessionId} recipientMessageId=toolu_agent Say PONG`, + `abc123 session_message toolu_agent from=${sessionId} senderMessageId=toolu_agent Say PONG`, + "abc123 session_message toolu_handback from=abc123 recipientMessageId=toolu_handback PONG", + "root session_message toolu_handback from=abc123 senderMessageId=toolu_handback PONG", + "abc123 tool_call toolu_lost", + "root agent_message_chunk It said PONG.", + 'root subagent_update abc123 {"state":"idle","stopReason":"end_turn"}', + ]); + }); + it("ends a child that the user stopped as cancelled, and one without a known end as idle", async () => { const trace = await replayRfd( [ @@ -6402,7 +6456,7 @@ describe("subagent permission attribution (issue #851)", () => { expect(childEvents).toEqual(["tool_call pending agent-42", "subagent cancelled"]); }); - it("keeps the Agent call's own heartbeat and result on the session that made the call (the subagents RFD)", async () => { + it("replaces the Agent call with the parent's session messages, and keeps its heartbeat out of the child (the subagents RFD)", async () => { const updates: AcpSessionNotification[] = []; const agent = new ClaudeAcpAgent( { @@ -6462,16 +6516,36 @@ describe("subagent permission attribution (issue #851)", () => { await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] }); - const agentCall = updates.flatMap(({ sessionId, update }) => - (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") && - update.toolCallId === "toolu_parent" - ? [{ sessionId, status: update.status }] - : [], - ); - expect(agentCall.map(({ status }) => status)).toEqual( - expect.arrayContaining(["in_progress", "completed"]), - ); - expect(new Set(agentCall.map(({ sessionId }) => sessionId))).toEqual(new Set(["test-session"])); + // Neither the call nor its heartbeat reaches any session, the child's + // included: its prompt and result are the parent's session messages. + expect( + updates.filter( + ({ update }) => + (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") && + update.toolCallId.startsWith("toolu_parent"), + ), + ).toEqual([]); + expect( + updates.flatMap(({ sessionId, update }) => + sessionId === "test-session" && update.sessionUpdate === "session_message" ? [update] : [], + ), + ).toEqual([ + { + sessionUpdate: "session_message", + messageId: "toolu_parent", + senderSessionId: "test-session", + recipientSessionId: "agent-42", + recipientMessageId: "toolu_parent", + content: [{ type: "text", text: "Find the parser" }], + }, + { + sessionUpdate: "session_message", + messageId: "toolu_parent:reply", + senderSessionId: "agent-42", + recipientSessionId: "test-session", + content: [{ type: "text", text: "It is in x.ts." }], + }, + ]); }); it("routes a native child AskUserQuestion and its tool call to the child session", async () => { @@ -7121,6 +7195,17 @@ describe("subagent permission attribution (issue #851)", () => { state: { state: "running" }, }, }, + { + sessionId: "test-session", + update: { + sessionUpdate: "session_message", + messageId: "toolu_parent", + senderSessionId: "test-session", + recipientSessionId: "agent-42", + recipientMessageId: "toolu_parent", + content: [{ type: "text", text: "Go" }], + }, + }, { sessionId: "agent-42", update: { @@ -7128,6 +7213,7 @@ describe("subagent permission attribution (issue #851)", () => { messageId: "toolu_parent", senderSessionId: "test-session", recipientSessionId: "agent-42", + senderMessageId: "toolu_parent", content: [{ type: "text", text: "Go" }], }, }, @@ -7469,6 +7555,17 @@ describe("subagent permission attribution (issue #851)", () => { state: { state: "running" }, }, }, + { + sessionId: "test-session", + update: { + sessionUpdate: "session_message", + messageId: "toolu_parent", + senderSessionId: "test-session", + recipientSessionId: "agent-42", + recipientMessageId: "toolu_parent", + content: [{ type: "text", text: "Go" }], + }, + }, { sessionId: "agent-42", update: { @@ -7476,6 +7573,7 @@ describe("subagent permission attribution (issue #851)", () => { messageId: "toolu_parent", senderSessionId: "test-session", recipientSessionId: "agent-42", + senderMessageId: "toolu_parent", content: [{ type: "text", text: "Go" }], }, }, @@ -7832,7 +7930,7 @@ describe("subagent permission attribution (issue #851)", () => { } }); - it("ends the Agent call with Claude Code's result, and the stopped child's tool call in the child's session", async () => { + it("reports the stop as the child's idle state, and closes its tool call in the child's session", async () => { const log: string[] = []; const updates: AcpSessionNotification[] = []; const agent = new ClaudeAcpAgent( @@ -7916,25 +8014,21 @@ describe("subagent permission attribution (issue #851)", () => { ).resolves.toMatchObject({ stopReason: "cancelled" }); await agent.sessions["test-session"]?.consumer; - // In the Agent call's own session, Claude Code's result; in the child's - // session, the report that closes its command, after the child went idle. + // The parent does not show the Agent call, so the child's idle state + // reports the stop. In the child's session, the report that closes its + // command comes after the child went idle. expect(log).toEqual([ 'agent-42 {"state":"running"}', 'agent-42 {"state":"idle","stopReason":"cancelled"}', - "tool @test-session toolu_agent failed", "tool @agent-42 toolu_child failed", ]); - const endOf = (toolCallId: string) => - updates.find( - ({ update }) => - update.sessionUpdate === "tool_call_update" && - update.toolCallId === toolCallId && - update.status === "failed", - )?.update; - expect(endOf("toolu_agent")).toMatchObject({ - _meta: { claudeCode: { nonExecutionKind: "user-rejected" } }, - }); - expect(JSON.stringify(endOf("toolu_child"))).toContain("The turn was cancelled"); + const endOfChildCommand = updates.find( + ({ update }) => + update.sessionUpdate === "tool_call_update" && + update.toolCallId === "toolu_child" && + update.status === "failed", + )?.update; + expect(JSON.stringify(endOfChildCommand)).toContain("The turn was cancelled"); for (const payload of updates) { expect(validateRecorded({ kind: "sessionUpdate", payload }, new Set())).toEqual([]); } @@ -8044,9 +8138,9 @@ describe("subagent permission attribution (issue #851)", () => { await agent.sessions["test-session"]?.consumer; // The command ends in the child's session, after the child reported idle. + // The background launch's own result is not shown in the parent. expect(log).toEqual([ 'agent-42 {"state":"running"}', - "tool @test-session toolu_agent completed", "prompt cancelled", 'agent-42 {"state":"idle","stopReason":"cancelled"}', "tool @agent-42 toolu_child failed", diff --git a/src/tests/acp-scenarios/__snapshots__/v2/subagent-agent-async.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/subagent-agent-async.jsonl index ca0dced6..22e557ab 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/subagent-agent-async.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/subagent-agent-async.jsonl @@ -4,17 +4,11 @@ {"kind":"response","method":"session/prompt","payload":{"messageId":""}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_agent"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Background review"},"sessionUpdate":"tool_call_update","title":"Background review","toolCallId":"toolu_agent"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Background review","prompt":"Review the diff"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Background review","prompt":"Review the diff","subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"content":{"text":"Review the diff","type":"text"},"type":"content"}],"rawInput":{"description":"Background review","prompt":"Review the diff","run_in_background":true,"subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"content":{"text":"Async agent launched successfully.","type":"text"},"type":"content"}],"rawOutput":"Async agent launched successfully.\nagentId: a2 (use SendMessage to continue)","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_agent"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolResponse":{"isAsync":true,"status":"async_launched"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"capabilities":{"cancel":{}},"description":"Background review","sessionId":"a2","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"general-purpose"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"sessionUpdate":"state_update","state":"running"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientSessionId":"a2","senderSessionId":"","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientMessageId":"toolu_agent","recipientSessionId":"a2","senderSessionId":"","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientSessionId":"a2","senderMessageId":"toolu_agent","senderSessionId":"","sessionUpdate":"session_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"Reviewi","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"ng now.","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionId":"a2","sessionUpdate":"subagent_update","state":{"state":"idle","stopReason":"end_turn"}}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/subagent-late-child-update.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/subagent-late-child-update.jsonl index 5a991515..9dea511d 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/subagent-late-child-update.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/subagent-late-child-update.jsonl @@ -4,17 +4,14 @@ {"kind":"response","method":"session/prompt","payload":{"messageId":""}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_late"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Check the logs"},"sessionUpdate":"tool_call_update","title":"Check the logs","toolCallId":"toolu_late"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Check the logs","prompt":"Check logs"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_late"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"content":{"text":"Check logs","type":"text"},"type":"content"}],"rawInput":{"description":"Check the logs","prompt":"Check logs","subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_late"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"capabilities":{"cancel":{}},"description":"Check the logs","sessionId":"agent_late","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"general-purpose"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"sessionUpdate":"state_update","state":"running"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientSessionId":"agent_late","senderSessionId":"","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientMessageId":"toolu_late","recipientSessionId":"agent_late","senderSessionId":"","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientSessionId":"agent_late","senderMessageId":"toolu_late","senderSessionId":"","sessionUpdate":"session_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_late_read"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"locations":[{"line":1,"path":"/var/log/app.log"}],"rawInput":{"file_path":"/var/log/app.log"},"sessionUpdate":"tool_call_update","title":"Read /var/log/app.log","toolCallId":"toolu_late_read"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"content":{"text":"The logs are clean.","type":"text"},"type":"content"}],"rawOutput":[{"text":"The logs are clean.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_late"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"The logs are clean.","type":"text"}],"messageId":"toolu_late:reply","recipientSessionId":"","senderSessionId":"agent_late","sessionUpdate":"session_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionId":"agent_late","sessionUpdate":"subagent_update","state":{"state":"idle","stopReason":"end_turn"}}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late","toolResponse":{"elapsedTimeSeconds":4}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_late_read"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl index 4a6d0225..a0f5b9c8 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl @@ -4,14 +4,11 @@ {"kind":"response","method":"session/prompt","payload":{"messageId":""}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_native"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Fix the build"},"sessionUpdate":"tool_call_update","title":"Fix the build","toolCallId":"toolu_native"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Fix the build","prompt":"Make the build pass"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"content":{"text":"Make the build pass","type":"text"},"type":"content"}],"rawInput":{"description":"Fix the build","prompt":"Make the build pass","subagent_type":"general-purpose"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"capabilities":{"cancel":{}},"description":"Fix the build","sessionId":"agent_n","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"general-purpose"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"state_update","state":"running"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientSessionId":"agent_n","senderSessionId":"","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientMessageId":"toolu_native","recipientSessionId":"agent_n","senderSessionId":"","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientSessionId":"agent_n","senderMessageId":"toolu_native","senderSessionId":"","sessionUpdate":"session_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"Running t","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"he build.","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":[],"kind":"search","name":"Grep","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}} @@ -33,7 +30,6 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"output":{"data":"bWFrZTogKioqIEVycm9yIDI="},"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native_bash"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"content":{"text":"```\nThe build still fails.\n```","type":"text"},"type":"content"}],"rawOutput":[{"text":"The build still fails.","type":"text"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionId":"agent_n","sessionUpdate":"subagent_update","state":{"state":"idle","stopReason":"error"}}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"state_update","state":"idle","stopReason":"error"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/subagent-nested.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/subagent-nested.jsonl index 3b9951d6..cea40fa2 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/subagent-nested.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/subagent-nested.jsonl @@ -4,30 +4,24 @@ {"kind":"response","method":"session/prompt","payload":{"messageId":""}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_outer"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Plan the work"},"sessionUpdate":"tool_call_update","title":"Plan the work","toolCallId":"toolu_outer"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Plan the work","prompt":"Plan it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_outer"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"content":{"text":"Plan it","type":"text"},"type":"content"}],"rawInput":{"description":"Plan the work","prompt":"Plan it","subagent_type":"Plan"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_outer"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"capabilities":{"cancel":{}},"description":"Plan the work","sessionId":"agent_outer","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Plan"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"sessionUpdate":"state_update","state":"running"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientSessionId":"agent_outer","senderSessionId":"","sessionUpdate":"session_message"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"content":[],"kind":"think","name":"Agent","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_inner"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"rawInput":{"description":"Read the spec"},"sessionUpdate":"tool_call_update","title":"Read the spec","toolCallId":"toolu_inner"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"rawInput":{"description":"Read the spec","prompt":"Read spec.md"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_inner"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"content":{"text":"Read spec.md","type":"text"},"type":"content"}],"rawInput":{"description":"Read the spec","prompt":"Read spec.md","subagent_type":"Explore"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_inner"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientMessageId":"toolu_outer","recipientSessionId":"agent_outer","senderSessionId":"","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientSessionId":"agent_outer","senderMessageId":"toolu_outer","senderSessionId":"","sessionUpdate":"session_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"capabilities":{"cancel":{}},"description":"Read the spec","sessionId":"agent_inner","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Explore"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"sessionUpdate":"state_update","state":"running"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientSessionId":"agent_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientMessageId":"toolu_inner","recipientSessionId":"agent_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientSessionId":"agent_inner","senderMessageId":"toolu_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_grandchild_read"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"locations":[{"line":1,"path":"/spec.md"}],"rawInput":{"file_path":"/spec.md"},"sessionUpdate":"tool_call_update","title":"Read /spec.md","toolCallId":"toolu_grandchild_read"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"content":[{"content":{"text":"```\n1\tspec\n```","type":"text"},"type":"content"}],"rawOutput":"1\tspec","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grandchild_read"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"content":{"text":"The spec says X.","type":"text"},"type":"content"}],"rawOutput":[{"text":"The spec says X.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_inner"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"The spec says X.","type":"text"}],"messageId":"toolu_inner:reply","recipientSessionId":"agent_outer","senderSessionId":"agent_inner","sessionUpdate":"session_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"sessionId":"agent_inner","sessionUpdate":"subagent_update","state":{"state":"idle","stopReason":"end_turn"}}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"content":{"text":"Plan:","type":"text"},"messageId":"msg_outer_text","sessionUpdate":"agent_message_chunk"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"content":{"text":" do X.","type":"text"},"messageId":"msg_outer_text","sessionUpdate":"agent_message_chunk"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"content":{"text":"Plan: do X.","type":"text"},"type":"content"}],"rawOutput":[{"text":"Plan: do X.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_outer"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"Plan: do X.","type":"text"}],"messageId":"toolu_outer:reply","recipientSessionId":"","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionId":"agent_outer","sessionUpdate":"subagent_update","state":{"state":"idle","stopReason":"end_turn"}}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/subagent-task-legacy.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/subagent-task-legacy.jsonl index 5b721243..8a184634 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/subagent-task-legacy.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/subagent-task-legacy.jsonl @@ -4,19 +4,15 @@ {"kind":"response","method":"session/prompt","payload":{"messageId":""}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[],"kind":"think","name":"Task","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_task"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Explore code"},"sessionUpdate":"tool_call_update","title":"Explore code","toolCallId":"toolu_task"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Explore code","prompt":"Find the parser"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"content":{"text":"Find the parser","type":"text"},"type":"content"}],"rawInput":{"description":"Explore code","prompt":"Find the parser","subagent_type":"Explore"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"capabilities":{"cancel":{}},"description":"Explore code","sessionId":"agent_1","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Explore"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"running"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientSessionId":"agent_1","senderSessionId":"","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientMessageId":"toolu_task","recipientSessionId":"agent_1","senderSessionId":"","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientSessionId":"agent_1","senderMessageId":"toolu_task","senderSessionId":"","sessionUpdate":"session_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Looking for","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":" the parser.","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"locations":[{"line":1,"path":"/x.ts"}],"rawInput":{"file_path":"/x.ts"},"sessionUpdate":"tool_call_update","title":"Read /x.ts","toolCallId":"toolu_sub_read"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":3,"subagentType":"Explore"}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_task"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"content":[{"content":{"text":"```\n1\tparser\n```","type":"text"},"type":"content"}],"rawOutput":"1\tparser","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_read"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_sub_bash"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_sub_bash"}}} @@ -48,8 +44,7 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"/x.ts"}],"patch":{"format":"git_patch","text":"diff --git /x.ts /x.ts\n--- /x.ts\n+++ /x.ts\n@@ -1 +1 @@\n-a\n+b\n"},"type":"diff"}],"locations":[{"line":1,"path":"/x.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Foun","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"d it.","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"content":{"text":"The parser is in x.ts.","type":"text"},"type":"content"}],"rawOutput":[{"text":"The parser is in x.ts.\nagentId: a1 (use SendMessage to continue)","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_task"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolResponse":{"status":"completed"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"The parser is in x.ts.","type":"text"}],"messageId":"toolu_task:reply","recipientSessionId":"","senderSessionId":"agent_1","sessionUpdate":"session_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionId":"agent_1","sessionUpdate":"subagent_update","state":{"state":"idle","stopReason":"end_turn"}}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/subagent-transcript-extension.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/subagent-transcript-extension.jsonl index 6f412089..1390e43a 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/subagent-transcript-extension.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/subagent-transcript-extension.jsonl @@ -4,14 +4,11 @@ {"kind":"response","method":"session/prompt","payload":{"messageId":""}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[],"kind":"think","name":"Task","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Task","toolCallId":"toolu_transcript"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Summarize"},"sessionUpdate":"tool_call_update","title":"Summarize","toolCallId":"toolu_transcript"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"rawInput":{"description":"Summarize","prompt":"Summarize the repo"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_transcript"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"content":{"text":"Summarize the repo","type":"text"},"type":"content"}],"rawInput":{"description":"Summarize","prompt":"Summarize the repo","subagent_type":"Explore"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_transcript"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"capabilities":{"cancel":{}},"description":"Summarize","sessionId":"agent_t","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Explore"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"sessionUpdate":"state_update","state":"running"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientSessionId":"agent_t","senderSessionId":"","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientMessageId":"toolu_transcript","recipientSessionId":"agent_t","senderSessionId":"","sessionUpdate":"session_message"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientSessionId":"agent_t","senderMessageId":"toolu_transcript","senderSessionId":"","sessionUpdate":"session_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"Look at t","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"he layout.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"The repo has ","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}} @@ -19,7 +16,7 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":[],"kind":"search","locations":[],"name":"Glob","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Find","toolCallId":"toolu_transcript_glob"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"rawInput":{"pattern":"*"},"sessionUpdate":"tool_call_update","title":"Find `*`","toolCallId":"toolu_transcript_glob"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"content":[{"content":{"text":"a\nb","type":"text"},"type":"content"}],"rawOutput":"a\nb","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_transcript_glob"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"content":{"text":"Two packages.","type":"text"},"type":"content"}],"rawOutput":[{"text":"Two packages.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_transcript"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"content":[{"text":"Two packages.","type":"text"}],"messageId":"toolu_transcript:reply","recipientSessionId":"","senderSessionId":"agent_t","sessionUpdate":"session_message"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionId":"agent_t","sessionUpdate":"subagent_update","state":{"state":"idle","stopReason":"end_turn"}}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}} diff --git a/src/tests/acp-v2.test.ts b/src/tests/acp-v2.test.ts index ead5f08a..e5ec5493 100644 --- a/src/tests/acp-v2.test.ts +++ b/src/tests/acp-v2.test.ts @@ -1927,24 +1927,19 @@ describe("ACP v2 prompts", () => { const { root, updates } = await cancelWhileRunning("toolu_child"); - // The Agent call ends in the session that made it, with Claude Code's - // result and the kind that it stamped. The subagent's command ends in the - // child's session, whose own idle came first. + // The parent does not show the Agent call: the child's idle reports the + // stop. The subagent's command ends in the child's session, before the + // root's idle. const idle = idleAt(root, updates); - const agentEnd = lastStatusAt(updates, "toolu_agent"); - expect(updates[agentEnd]).toMatchObject({ - sessionId: root, - update: { - status: "cancelled", - _meta: { claudeCode: { nonExecutionKind: "user-rejected" } }, - }, + expect(lastStatusAt(updates, "toolu_agent")).toBe(-1); + expect(updates[idleAt("agent-1", updates)]).toMatchObject({ + update: { stopReason: "cancelled" }, }); const childEnd = lastStatusAt(updates, "toolu_child"); expect(updates[childEnd]).toMatchObject({ sessionId: "agent-1", update: { status: "cancelled" }, }); - expect(agentEnd).toBeLessThan(idle); expect(childEnd).toBeLessThan(idle); }); @@ -2584,14 +2579,12 @@ describe("ACP v2 model cycles that Claude Code starts on its own", () => { const { root, updates } = await cancelCycle("toolu_child"); - // Claude Code's result ends the Agent call. The command ends in the - // child's session, after the child's idle and before the cycle's. - expect(updates[lastStatusAt(updates, "toolu_agent")]).toMatchObject({ - sessionId: root, - update: { - status: "cancelled", - _meta: { claudeCode: { nonExecutionKind: "user-rejected" } }, - }, + // The parent does not show the Agent call: the child's idle reports the + // stop. The command ends in the child's session, after the child's idle + // and before the cycle's. + expect(statusesOf(updates, "toolu_agent")).toEqual([]); + expect(updates[lastIdleAt(updates, "agent-1")]).toMatchObject({ + update: { stopReason: "cancelled" }, }); const end = lastStatusAt(updates, "toolu_child"); expect(updates[end]).toMatchObject({ sessionId: "agent-1", update: { status: "cancelled" } }); diff --git a/src/tests/native-subagents.test.ts b/src/tests/native-subagents.test.ts index b9a562d1..0f1b8628 100644 --- a/src/tests/native-subagents.test.ts +++ b/src/tests/native-subagents.test.ts @@ -776,6 +776,27 @@ describe("NativeSubagentRuntime in the RFD form", () => { _meta: { claudeCode: { parentToolUseId } }, }, }) as AcpSessionNotification; + /** The final update of the Agent call `toolCallId`, showing `text` as the + * reporter renders the result, under the SDK's raw output and its trailer. */ + const launchResult = ( + toolCallId: string, + status: "completed" | "failed", + text: string, + parentToolUseId?: string, + ): AcpSessionNotification => + ({ + sessionId: "root", + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status, + content: [{ type: "content", content: { type: "text", text } }], + rawOutput: [{ type: "text", text: `${text}\nagentId: a1 (use SendMessage to continue)` }], + _meta: { + claudeCode: { toolName: "Agent", ...(parentToolUseId ? { parentToolUseId } : {}) }, + }, + }, + }) as AcpSessionNotification; /** A SendMessage call, of the root or of the child that `parentToolUseId` launched. */ const sendMessage = (toolCallId: string, parentToolUseId?: string): AcpSessionNotification => ({ @@ -821,17 +842,17 @@ describe("NativeSubagentRuntime in the RFD form", () => { update.sessionUpdate === "subagent_update" && update.state ? [update.state] : [], ); - it("keeps the Agent tool call as a tool call of the session that made it", async () => { + it("hides the Agent tool call, which the parent's session messages replace", async () => { const { runtime, control } = await runningWorker(); - expect(control).toMatchObject({ sessionId: "root", update: { toolCallId: "launch-1" } }); - // A nested launch is a tool call of the child that made it. + expect(control).toBeNull(); + // So is a nested launch, in the child that made it. await expect( runtime.route(launch("launch-2", { description: "Dig" }, "launch-1"), async () => {}), - ).resolves.toMatchObject({ sessionId: "worker-1", update: { toolCallId: "launch-2" } }); + ).resolves.toBeNull(); }); - it("announces the child with the parent's labels, then sends it the prompt", async () => { + it("announces the child with the parent's labels, then sends the prompt to both transcripts", async () => { const { published } = await runningWorker({ name: "builder" }); expect(published).toEqual([ @@ -846,6 +867,17 @@ describe("NativeSubagentRuntime in the RFD form", () => { state: { state: "running" }, }, }, + { + sessionId: "root", + update: { + sessionUpdate: "session_message", + messageId: "launch-1", + senderSessionId: "root", + recipientSessionId: "worker-1", + recipientMessageId: "launch-1", + content: [{ type: "text", text: "Make it pass" }], + }, + }, { sessionId: "worker-1", update: { @@ -853,12 +885,192 @@ describe("NativeSubagentRuntime in the RFD form", () => { messageId: "launch-1", senderSessionId: "root", recipientSessionId: "worker-1", + senderMessageId: "launch-1", content: [{ type: "text", text: "Make it pass" }], }, }, ]); }); + it("sends a foreground child's answer to its parent as the child's reply", async () => { + const { runtime, published } = await runningWorker(); + + await expect( + runtime.route(launchResult("launch-1", "completed", "The build passes."), async () => {}), + ).resolves.toBeNull(); + + expect(published.at(-1)).toEqual({ + sessionId: "root", + update: { + sessionUpdate: "session_message", + messageId: "launch-1:reply", + senderSessionId: "worker-1", + recipientSessionId: "root", + content: [{ type: "text", text: "The build passes." }], + }, + }); + }); + + describe("a child's SubagentHandback call", () => { + /** An update of the child's hand-back call. Only its first update names the tool. */ + const handback = ( + status: "pending" | "completed" | "failed", + rawInput?: Record, + ): AcpSessionNotification => + ({ + sessionId: "root", + update: { + sessionUpdate: status === "pending" ? "tool_call" : "tool_call_update", + toolCallId: "handback-1", + status, + ...(status === "pending" ? { title: "SubagentHandback" } : {}), + ...(rawInput ? { rawInput } : {}), + _meta: { + claudeCode: { + parentToolUseId: "launch-1", + ...(status === "pending" ? { toolName: "SubagentHandback" } : {}), + }, + }, + }, + }) as AcpSessionNotification; + const pointer = "This agent's report was delivered to you as a message."; + + it("is the child's message to its parent, which replaces the launch's reply", async () => { + const { runtime, published } = await runningWorker(); + const before = published.length; + + // The streamed call starts without input; a refinement brings the report. + const refinement = { + sessionId: "root", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "handback-1", + rawInput: { message: "PONG" }, + _meta: { claudeCode: { parentToolUseId: "launch-1" } }, + }, + } as AcpSessionNotification; + for (const update of [ + handback("pending", {}), + refinement, + handback("completed"), + launchResult("launch-1", "completed", pointer), + ]) { + await expect(runtime.route(update, async () => {})).resolves.toBeNull(); + } + + const message = { + sessionUpdate: "session_message", + messageId: "handback-1", + senderSessionId: "worker-1", + recipientSessionId: "root", + content: [{ type: "text", text: "PONG" }], + }; + expect(published.slice(before)).toEqual([ + { sessionId: "worker-1", update: { ...message, recipientMessageId: "handback-1" } }, + { sessionId: "root", update: { ...message, senderMessageId: "handback-1" } }, + ]); + }); + + it("stays a tool call of the child when it fails, and the launch's result is the reply", async () => { + const { runtime, published } = await runningWorker(); + + await runtime.route(handback("pending", { message: "PONG" }), async () => {}); + await expect(runtime.route(handback("failed"), async () => {})).resolves.toMatchObject({ + sessionId: "worker-1", + update: { sessionUpdate: "tool_call", toolCallId: "handback-1", status: "failed" }, + }); + await runtime.route(launchResult("launch-1", "completed", "PONG"), async () => {}); + + expect(published.at(-1)).toMatchObject({ + sessionId: "root", + update: { messageId: "launch-1:reply", content: [{ type: "text", text: "PONG" }] }, + }); + }); + }); + + it("reports no reply for a failed launch, whose child's idle state reports the failure", async () => { + const { runtime, published } = await runningWorker(); + const before = published.length; + + await expect( + runtime.route(launchResult("launch-1", "failed", "The build still fails."), async () => {}), + ).resolves.toBeNull(); + + expect(published).toHaveLength(before); + }); + + it("reports no reply for a background launch, which completes before its child works", async () => { + const { runtime, published } = await runningWorker({ run_in_background: true }); + const before = published.length; + + await expect( + runtime.route( + launchResult("launch-1", "completed", "Async agent launched successfully."), + async () => {}, + ), + ).resolves.toBeNull(); + + expect(published).toHaveLength(before); + }); + + it("recognizes the adapter's report that closes a hidden launch, which has no tool name", async () => { + const { runtime, published } = await runningWorker(); + // The report that closes a call the cancel left open, for a v2 client. + const close = (toolCallId: string): AcpSessionNotification => ({ + sessionId: "root", + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "cancelled" as "failed", + content: [], + }, + }); + const before = published.length; + + // The child was exposed, so its idle state reports the stop. + await expect(runtime.route(close("launch-1"), async () => {})).resolves.toBeNull(); + expect(published).toHaveLength(before); + // A launch whose child never started is shown after all. + await runtime.route(launch("launch-9", { description: "Dig" }), async () => {}); + await expect(runtime.route(close("launch-9"), async () => {})).resolves.toMatchObject({ + sessionId: "root", + update: { sessionUpdate: "tool_call", toolCallId: "launch-9", status: "cancelled" }, + }); + }); + + it("keeps a launch whose child never started as a tool call with its final status", async () => { + const published: AcpSessionNotification[] = []; + const runtime = new NativeSubagentRuntime( + "rfd", + "root", + {}, + async (notification) => { + published.push(notification); + }, + { log: () => {} }, + ); + for (const [toolCallId, status] of [ + ["launch-ok", "completed"], + ["launch-bad", "failed"], + ] as const) { + await expect( + runtime.route(launch(toolCallId, { description: "Dig" }), async () => {}), + ).resolves.toBeNull(); + await expect( + runtime.route(launchResult(toolCallId, status, "Done"), async () => {}), + ).resolves.toMatchObject({ + sessionId: "root", + update: { sessionUpdate: "tool_call", toolCallId, status }, + }); + } + // A background launch completes before its child starts, so it stays hidden. + await runtime.route(launch("launch-bg", { run_in_background: true }), async () => {}); + await expect( + runtime.route(launchResult("launch-bg", "completed", "Async agent launched"), async () => {}), + ).resolves.toBeNull(); + expect(published).toEqual([]); + }); + it("titles a child without a name by its role", async () => { const { published } = await runningWorker(); @@ -885,7 +1097,10 @@ describe("NativeSubagentRuntime in the RFD form", () => { { state: "running" }, { state: "idle", stopReason: "end_turn" }, ]); + // The SendMessage call stays a tool call of its sender, so its message has + // only the child's view, with no reference. expect(published.filter(({ update }) => update.sessionUpdate === "session_message")).toEqual([ + expect.objectContaining({ sessionId: "root" }), expect.objectContaining({ sessionId: "worker-1" }), { sessionId: "worker-1", @@ -938,8 +1153,10 @@ describe("NativeSubagentRuntime in the RFD form", () => { expect( published .filter(({ update }) => update.sessionUpdate === "session_message") - .map(({ update }) => (update as { messageId: string }).messageId), - ).toEqual(["launch-1", "send-1"]); + .map( + ({ sessionId, update }) => `${sessionId} ${(update as { messageId: string }).messageId}`, + ), + ).toEqual(["root launch-1", "worker-1 launch-1", "worker-1 send-1"]); }); it("ignores a SendMessage result that arrives after the run it started ended", async () => { @@ -985,7 +1202,10 @@ describe("NativeSubagentRuntime in the RFD form", () => { expect(states(published).at(-1)).toEqual({ state: "running" }); expect( - published.filter(({ update }) => update.sessionUpdate === "session_message"), + published.filter( + ({ sessionId, update }) => + sessionId === "worker-1" && update.sessionUpdate === "session_message", + ), ).toHaveLength(1); expect(runtime.isStaleEnd("worker-1", "launch-1")).toBe(false); }); @@ -1245,10 +1465,17 @@ describe("NativeSubagentRuntime in the RFD form", () => { // A resume signal does not give the unexposed child another call. await runtime.taskStarted({ taskId: "worker-2", toolUseId: "send-9" }, async () => {}); await runtime.finishTask("worker-2", "completed", async () => {}); - // The frame arrives late: it is the tool call of worker-1 that made it. + // The frame arrives late, and the launch stays hidden while it runs. Its + // child was never exposed, so its result makes it a tool call of worker-1. await expect( runtime.route(launch("launch-2", { description: "Dig" }, "launch-1"), async () => {}), - ).resolves.toMatchObject({ sessionId: "worker-1" }); + ).resolves.toBeNull(); + await expect( + runtime.route(launchResult("launch-2", "completed", "Dug", "launch-1"), async () => {}), + ).resolves.toMatchObject({ + sessionId: "worker-1", + update: { sessionUpdate: "tool_call", toolCallId: "launch-2", status: "completed" }, + }); expect( published.some(