diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 56879a75..893a546c 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -8200,6 +8200,7 @@ export class ClaudeAcpAgent { } let content: typeof message.message.content; + let toolUseResult = message.type === "user" ? message.tool_use_result : undefined; if ( message.type === "assistant" && (message.parent_tool_use_id === null || forwardsSubagentText()) @@ -8220,6 +8221,20 @@ export class ClaudeAcpAgent { (item) => item.type !== "text" && item.type !== "thinking", ); streamedBlocksOf(message.parent_tool_use_id).length = 0; + } else if ( + message.parent_tool_use_id !== null && + Array.isArray(message.message.content) + ) { + // A foreground fork inherits a placeholder result for its own + // launch. Only the launching side can complete that Agent call + // with the fork's actual report. + const launchId = message.parent_tool_use_id; + content = message.message.content.filter( + (block) => block.type !== "tool_result" || block.tool_use_id !== launchId, + ); + // The message-level sidecar cannot describe a remaining result + // after its launch result was removed. + if (content.length !== message.message.content.length) toolUseResult = undefined; } else { content = message.message.content; } @@ -8237,7 +8252,7 @@ export class ClaudeAcpAgent { ); const backgroundBashTask = backgroundBashTaskFromToolResult( content, - message.tool_use_result, + toolUseResult, session.toolUseCache, ); if (backgroundBashTask) await asyncTasks.taskBackgrounded(backgroundBashTask); @@ -8266,7 +8281,7 @@ export class ClaudeAcpAgent { asyncTasks, backgroundBashTask, ); - const resumedAgentId = resumedNativeSubagentId(message.tool_use_result); + const resumedAgentId = resumedNativeSubagentId(toolUseResult); if (resumedAgentId) { const resume = sendMessageResume( session.toolUseCache, @@ -8307,7 +8322,7 @@ export class ClaudeAcpAgent { emittedToolCalls: session.emittedToolCalls, toolCallFields: toolCallFieldsOf(session), messageId: messageIdForGrouping(message), - toolUseResult: message.type === "user" ? message.tool_use_result : undefined, + toolUseResult, // On the wire since CLI 2.1.216 but not in SDKUserMessage's // type, hence the cast. Validated by parseToolResultMeta. toolResultMeta: diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index c21e6753..22b35c78 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -15964,6 +15964,132 @@ describe("usage_update computation", () => { }); }); +describe("foreground fork tool results", () => { + it.each([ + ["plain", {}, null], + ["AIR", AIR_CLIENT_CAPABILITIES, null], + ["plain nested", {}, "toolu_outer"], + ["AIR nested", AIR_CLIENT_CAPABILITIES, "toolu_outer"], + ] as const)( + "waits for the launching side's result for a %s client", + async (_, capabilities, parent) => { + const updates: SessionNotification[] = []; + const logger = { log: vi.fn(), error: vi.fn() }; + const agent = new ClaudeAcpAgent( + { + sessionUpdate: async (notification: SessionNotification) => { + updates.push(notification); + }, + } as unknown as AcpClient, + logger, + ); + await initializeClient(agent, capabilities); + const input = new Pushable(); + const assistant = (content: unknown[], parentToolUseId: string | null) => ({ + type: "assistant", + parent_tool_use_id: parentToolUseId, + uuid: randomUUID(), + session_id: "test-session", + message: { + id: randomUUID(), + role: "assistant", + model: "claude-sonnet-4-6", + content, + usage: successfulResultMessage().usage, + }, + }); + const user = (content: unknown[], parentToolUseId: string | null) => ({ + type: "user", + parent_tool_use_id: parentToolUseId, + uuid: randomUUID(), + session_id: "test-session", + message: { role: "user", content }, + }); + const toolResult = (id: string, text: string) => ({ + type: "tool_result", + tool_use_id: id, + content: [{ type: "text", text }], + }); + async function* messages() { + const { value } = await input[Symbol.asyncIterator]().next(); + yield userEcho(value); + yield assistant( + [ + { + type: "tool_use", + id: "toolu_fork", + name: "Agent", + input: { + subagent_type: "fork", + run_in_background: false, + prompt: "Inspect the parser", + }, + }, + ], + parent, + ); + // The fork inherits a result for its own launch. It cannot complete + // that call: the launching side sends the actual report later. + yield user([toolResult("toolu_fork", "Fork started — that fork is you.")], "toolu_fork"); + yield assistant( + [ + { + type: "tool_use", + id: "toolu_child", + name: "Agent", + input: { subagent_type: "general-purpose", prompt: "Read the parser" }, + }, + ], + "toolu_fork", + ); + // Keep results of tools the fork itself called, even in a message + // that also carries the copied launch result. + yield { + ...user( + [ + toolResult("toolu_fork", "Fork started — processing in background"), + toolResult("toolu_child", "child report"), + ], + "toolu_fork", + ), + // Filtering must not attribute the inherited launch sidecar to + // the one result that remains in this message. + tool_use_result: { + status: "completed", + content: [{ type: "text", text: "inherited launch report" }], + }, + }; + yield user([toolResult("toolu_fork", "fork report")], parent); + yield successfulResultMessage(); + yield { type: "system", subtype: "session_state_changed", state: "idle" }; + } + agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(messages()), input }); + + await expect( + agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "Inspect it" }] }), + ).resolves.toMatchObject({ stopReason: "end_turn" }); + + const completed = updates.flatMap(({ update }) => + update.sessionUpdate === "tool_call_update" && update.status === "completed" + ? [{ id: update.toolCallId, content: update.content, rawOutput: update.rawOutput }] + : [], + ); + expect(completed.map(({ id }) => id)).toEqual(["toolu_child", "toolu_fork"]); + expect(completed.map(({ content }) => content)).toEqual([ + [{ type: "content", content: { type: "text", text: "child report" } }], + [{ type: "content", content: { type: "text", text: "fork report" } }], + ]); + if (!("_meta" in capabilities)) { + expect(completed.map(({ rawOutput }) => rawOutput)).toEqual([ + [{ type: "text", text: "child report" }], + [{ type: "text", text: "fork report" }], + ]); + } + expect(logger.error.mock.calls.flat().join("\n")).not.toContain("wasn't tracked"); + }, + ); +}); + describe("assembled assistant text fallback", () => { const ZERO_USAGE = { input_tokens: 0,