diff --git a/README.md b/README.md index f4b0e8dd..0c6c899c 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,24 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https - Following - Edit review - TODO lists +- Nested subagent transcripts - Interactive (and background) terminals - Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands) - Client MCP servers Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/). +### Nested subagent transcripts + +ACP 1.2 has no standard subagent tool kind or nested-message relationship. Clients that can render +nested transcripts can opt in with `clientCapabilities._meta["subagent-transcript"] = true`. +The agent then forwards subagent text, thinking, and tool calls, relating nested updates to the +launching Agent/Task call through `_meta.claudeCode.parentToolUseId`. Agent/Task calls are marked +with `_meta.claudeCode.subagent = true`. + +Clients that do not advertise the capability retain the legacy flattened behavior. In both modes, +the normal Agent/Task tool result is preserved as the protocol-compatible fallback. + ## Contribution Policy This project does not require a Contributor License Agreement (CLA). Instead, contributions are accepted under the following terms: diff --git a/src/acp-agent.ts b/src/acp-agent.ts index c7eba075..f23bb465 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -451,6 +451,9 @@ type Session = { * cancel. */ forceCancelTimer?: ReturnType; emitRawSDKMessages: boolean | SDKMessageFilter[]; + /** Whether nested subagent text/thinking is forwarded to the ACP client. + * Enabled by either the ACP capability or the pre-existing SDK option. */ + forwardSubagentText: boolean; /** Context window size of the session's current model, carried across * prompts so mid-stream usage_update notifications report a correct `size` * before the turn's first result message arrives. Seeded synchronously at @@ -798,6 +801,8 @@ export type ToolUpdateMeta = { claudeCode?: { /* The name of the tool that was used in Claude Code. */ toolName: string; + /* A human-readable title supplied by Claude Code for the tool call. */ + title?: string; /* The structured output provided by Claude Code. */ toolResponse?: unknown; /* For a tool call made inside a subagent: the tool_use id of the @@ -813,6 +818,11 @@ export type ToolUpdateMeta = { /* Free-text the user supplied when rejecting the tool call, when the harness collected any. Only ever present alongside nonExecutionKind. */ userFeedback?: string; + /* Marks Agent/Task tool calls as subagent launches. ACP 1.2 has no + standard subagent ToolKind yet, so clients that support nested + transcripts need a namespaced marker instead of inferring from + `toolName` or the generic `think` kind. */ + subagent?: true; }; /* Terminal metadata for Bash tool execution, matching codex-acp's _meta protocol. */ terminal_info?: { @@ -829,6 +839,28 @@ export type ToolUpdateMeta = { }; }; +const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript"; + +function supportsSubagentTranscript(capabilities?: ClientCapabilities | null): boolean { + return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true; +} + +function parentToolUseIdOf(message: { parent_tool_use_id?: unknown }): string | null { + if (!("parent_tool_use_id" in message)) return null; + return typeof message.parent_tool_use_id === "string" ? message.parent_tool_use_id : null; +} + +function stripSubagentTextAndThinking(content: unknown): unknown { + if (!Array.isArray(content)) return content; + return content.filter( + (item) => + !item || + typeof item !== "object" || + !("type" in item) || + (item.type !== "text" && item.type !== "thinking"), + ); +} + export type ToolUseCache = { [key: string]: { type: "tool_use" | "server_tool_use" | "mcp_tool_use"; @@ -3664,11 +3696,15 @@ export class ClaudeAcpAgent { // Consumed: reset so the next message's blocks accumulate fresh and // the record stays bounded to the in-flight message. streamedBlocks.length = 0; - } else if (message.type === "assistant") { - // Subagent assistant message (`parent_tool_use_id !== null`). It is - // never streamed live and its text/thinking is internal to the tool - // call — keep dropping it so subagent prose doesn't leak into the - // top-level feed. + } else if ( + message.type === "assistant" && + !(session.forwardSubagentText || supportsSubagentTranscript(this.clientCapabilities)) + ) { + // Legacy clients don't understand nested transcripts. Keep the + // historical behavior for them: subagent text/thinking remains + // internal to the tool call instead of leaking into the top-level + // feed. Capable clients opt into the branch above unchanged, with + // `parentToolUseId` stamped by toAcpNotifications. content = message.message.content.filter( (item) => item.type !== "text" && item.type !== "thinking", ); @@ -4270,6 +4306,9 @@ export class ClaudeAcpAgent { private async replaySessionHistory(sessionId: string): Promise { const toolUseCache: ToolUseCache = {}; const messages = await getSessionMessages(sessionId); + const forwardSubagentText = + this.sessions[sessionId]?.forwardSubagentText ?? + supportsSubagentTranscript(this.clientCapabilities); for (const message of messages) { // Backfill the ACP messageId -> SDK uuid mapping for messages we didn't @@ -4291,6 +4330,10 @@ export class ClaudeAcpAgent { // @ts-expect-error - untyped in SDK but we handle all of these let content: unknown = message.message.content; + const parentToolUseId = parentToolUseIdOf(message); + if (message.type === "assistant" && parentToolUseId && !forwardSubagentText) { + content = stripSubagentTextAndThinking(content); + } // @ts-expect-error - untyped in SDK but we handle all of these if (message.message.role === "user") { content = stripLocalCommandMetadata(content); @@ -4312,6 +4355,7 @@ export class ClaudeAcpAgent { cwd: this.sessions[sessionId]?.cwd, taskState: this.sessions[sessionId]?.taskState, messageId: replayMessageId, + parentToolUseId, }, )) { await this.client.sessionUpdate(notification); @@ -5192,6 +5236,9 @@ export class ClaudeAcpAgent { // Extract options from _meta if provided const sessionMeta = params._meta as NewSessionMeta | undefined; const userProvidedOptions = sessionMeta?.claudeCode?.options; + const forwardSubagentText = + supportsSubagentTranscript(this.clientCapabilities) || + userProvidedOptions?.forwardSubagentText === true; // Configure thinking behavior from environment variable const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger); @@ -5270,6 +5317,7 @@ export class ClaudeAcpAgent { // Override certain fields that must be controlled by ACP cwd: params.cwd, includePartialMessages: true, + forwardSubagentText, mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers }, // If we want bypassPermissions to be an option, we have to allow it here. // But it doesn't work in root mode, so we only activate it if it will work. @@ -5614,6 +5662,7 @@ export class ClaudeAcpAgent { fastModeEnabled, abortController, emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false, + forwardSubagentText, contextWindowSize: seededWindow.size, contextWindowAuthoritative: seededWindow.authoritative, providerCacheKey, @@ -6714,6 +6763,29 @@ function shouldEmitToolCall(toolName: string): boolean { return toolName !== "TodoWrite" && !isTaskTool(toolName); } +/** Build the Claude Code-specific metadata for a tool call. Bash descriptions + * are kept out of ACP's standard `title`, which clients may use as the shell + * command preview, while still giving clients access to Claude's concise + * human-readable title. */ +function claudeCodeMetaFromToolUse(toolUse: { + name: string; + input?: unknown; +}): NonNullable { + const description = + toolUse.name === "Bash" && + toolUse.input !== null && + typeof toolUse.input === "object" && + "description" in toolUse.input && + typeof toolUse.input.description === "string" + ? toolUse.input.description + : undefined; + return { + toolName: toolUse.name, + ...(description ? { title: description } : {}), + ...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true as const }), + }; +} + /** Build the `tool_call` (or, with `refine`, the `tool_call_update`) * notification for a tool_use. Shared by every site that surfaces a tool call: * the streamed tool_use path (first encounter → tool_call, later encounter → @@ -6730,7 +6802,7 @@ function toolCallNotification( ): SessionNotification["update"] { if (refine) { return { - _meta: { claudeCode: { toolName: toolUse.name } } satisfies ToolUpdateMeta, + _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) } satisfies ToolUpdateMeta, toolCallId: toolUse.id, sessionUpdate: "tool_call_update", rawInput, @@ -6739,7 +6811,7 @@ function toolCallNotification( } return { _meta: { - claudeCode: { toolName: toolUse.name }, + claudeCode: claudeCodeMetaFromToolUse(toolUse), ...(toolUse.name === "Bash" && supportsTerminalOutput ? { terminal_info: { terminal_id: toolUse.id } } : {}), @@ -6774,7 +6846,9 @@ function streamedInputRefinement( cwd, ); return { - _meta: { claudeCode: { toolName: toolUse.name } } satisfies ToolUpdateMeta, + _meta: { + claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }), + } satisfies ToolUpdateMeta, toolCallId: toolUse.id, sessionUpdate: "tool_call_update", rawInput: input, diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 6c3b9e22..ab27876d 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -156,6 +156,7 @@ function mockSessionState(overrides: Record = {}) { currentAgent: "default", abortController: new AbortController(), emitRawSDKMessages: false, + forwardSubagentText: false, contextWindowSize: 200000, contextWindowAuthoritative: false, providerCacheKey: "default", @@ -530,6 +531,22 @@ describe("tool conversions", () => { }); }); + it("should use the Bash command as the title when no description is provided", () => { + const tool_use = { + type: "tool_use", + id: "toolu_01VtsS2mxUFwpBJZYd7BmbC9", + name: "Bash", + input: { + command: "git diff", + }, + }; + + expect(toolInfoFromToolUse(tool_use)).toMatchObject({ + kind: "execute", + title: "git diff", + }); + }); + it("should handle Glob nicely", () => { const tool_use = { type: "tool_use", @@ -576,6 +593,58 @@ describe("tool conversions", () => { }); }); + it("marks Agent and legacy Task tool calls as subagent launches", () => { + for (const name of ["Agent", "Task"]) { + const notifications = toAcpNotifications( + [ + { + type: "tool_use", + id: `toolu_${name}`, + name, + input: { description: "Explore", prompt: "Inspect the project" }, + }, + ] as any, + "assistant", + "test-session", + {}, + {} as AcpClient, + console, + ); + + expect(notifications[0]?.update).toMatchObject({ + sessionUpdate: "tool_call", + _meta: { claudeCode: { toolName: name, subagent: true } }, + }); + } + + const nestedAgent = toAcpNotifications( + [ + { + type: "tool_use", + id: "nested-agent", + name: "Agent", + input: { description: "Review tests", prompt: "Inspect tests" }, + }, + ] as any, + "assistant", + "test-session", + {}, + {} as AcpClient, + console, + { parentToolUseId: "outer-agent" }, + ); + expect(nestedAgent[0]?.update).toMatchObject({ + sessionUpdate: "tool_call", + _meta: { + claudeCode: { + toolName: "Agent", + subagent: true, + parentToolUseId: "outer-agent", + }, + }, + }); + }); + it("should handle Grep tool calls", () => { const tool_use = { type: "tool_use", @@ -1568,6 +1637,90 @@ describe("synthetic login message (issue #863)", () => { }); }); +describe("subagent transcript replay", () => { + const replayHistory = [ + { + type: "assistant", + uuid: "subagent-message", + session_id: "s1", + parent_tool_use_id: "parent-agent-call", + parent_agent_id: "agent-1", + message: { + id: "api-subagent-message", + model: "claude-sonnet-4-5", + role: "assistant", + type: "message", + stop_reason: "tool_use", + content: [ + { type: "text", text: "nested report" }, + { type: "tool_use", id: "child-tool", name: "Bash", input: { command: "pwd" } }, + ], + }, + }, + ] as Awaited>; + + async function replay(capable: boolean): Promise { + const updates: SessionNotification[] = []; + const client = { + sessionUpdate: async (update: SessionNotification) => updates.push(update), + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }); + (agent as any).clientCapabilities = capable ? { _meta: { "subagent-transcript": true } } : {}; + vi.mocked(getSessionMessages).mockResolvedValueOnce(replayHistory); + + await ( + agent as unknown as { replaySessionHistory(sessionId: string): Promise } + ).replaySessionHistory("s1"); + return updates; + } + + it("preserves nested text and child tool attribution for capable clients", async () => { + const updates = await replay(true); + expect(updates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "nested report" }, + _meta: expect.objectContaining({ + claudeCode: expect.objectContaining({ parentToolUseId: "parent-agent-call" }), + }), + }), + }), + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: "tool_call", + toolCallId: "child-tool", + _meta: expect.objectContaining({ + claudeCode: expect.objectContaining({ parentToolUseId: "parent-agent-call" }), + }), + }), + }), + ]), + ); + }); + + it("keeps legacy text filtering without losing child tool attribution", async () => { + const updates = await replay(false); + expect(updates.some(({ update }) => update.sessionUpdate === "agent_message_chunk")).toBe( + false, + ); + expect(updates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: "tool_call", + toolCallId: "child-tool", + _meta: expect.objectContaining({ + claudeCode: expect.objectContaining({ parentToolUseId: "parent-agent-call" }), + }), + }), + }), + ]), + ); + }); +}); + describe("escape markdown", () => { it("should escape markdown characters", () => { let text = "Hello *world*!"; @@ -1948,6 +2101,7 @@ describe("permission request cancellation", () => { fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, + forwardSubagentText: false, contextWindowSize: 200000, contextWindowAuthoritative: false, providerCacheKey: "default", @@ -2046,11 +2200,15 @@ describe("tool_call emitted before permission request", () => { it("emits the tool_call (then asks permission) when the stream hasn't yet", async () => { const { agent, events, updates, session } = setup(); - const result = await agent.canUseTool("session-1")("Bash", { command: "ls" }, { - signal: new AbortController().signal, - suggestions: [], - toolUseID: "tool-1", - } as any); + const result = await agent.canUseTool("session-1")( + "Bash", + { command: "git diff", description: "Show current diff" }, + { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "tool-1", + } as any, + ); // tool_call is sent before the permission request is raised. expect(events).toEqual(["update:tool_call", "permission"]); @@ -2058,6 +2216,10 @@ describe("tool_call emitted before permission request", () => { sessionUpdate: "tool_call", toolCallId: "tool-1", status: "pending", + title: "git diff", + _meta: { + claudeCode: { toolName: "Bash", title: "Show current diff" }, + }, }); expect(session.emittedToolCalls.has("tool-1")).toBe(true); expect(result).toMatchObject({ behavior: "allow" }); @@ -3790,6 +3952,7 @@ describe("session/close", () => { fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, + forwardSubagentText: false, contextWindowSize: 200000, contextWindowAuthoritative: false, providerCacheKey: "default", @@ -3882,6 +4045,7 @@ describe("session/delete", () => { fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, + forwardSubagentText: false, contextWindowSize: 200000, contextWindowAuthoritative: false, providerCacheKey: "default", @@ -3991,6 +4155,7 @@ describe("getOrCreateSession param change detection", () => { fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, + forwardSubagentText: false, contextWindowSize: 200000, contextWindowAuthoritative: false, providerCacheKey: "default", @@ -5908,6 +6073,39 @@ describe("assembled assistant text fallback", () => { expect(thoughtChunkTexts(updates)).toEqual([]); }); + it("forwards subagent text and thinking as nested chunks for capable clients", async () => { + const { agent, updates } = createMockAgentWithCapture(); + (agent as any).clientCapabilities = { _meta: { "subagent-transcript": true } }; + injectSession(agent, [ + assistantMessage( + "msg-subagent", + [ + { type: "thinking", thinking: "checking" }, + { type: "text", text: "nested report" }, + ], + "tool_use_1", + ), + result(), + idle, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] }); + + const nestedUpdates = updates.filter( + ({ update }) => + update.sessionUpdate === "agent_message_chunk" || + update.sessionUpdate === "agent_thought_chunk", + ); + expect(nestedUpdates).toHaveLength(2); + for (const { update } of nestedUpdates) { + expect(update._meta).toMatchObject({ + claudeCode: { parentToolUseId: "tool_use_1" }, + }); + } + expect(messageChunkTexts(updates)).toContain("nested report"); + expect(thoughtChunkTexts(updates)).toContain("checking"); + }); + it("forwards distinct blocks that a gateway splits across same-id messages", async () => { const { agent, updates } = createMockAgentWithCapture(); // Observed with OpenAI-compatible gateways: one response id split into an @@ -6815,6 +7013,7 @@ describe("post-error recovery", () => { fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, + forwardSubagentText: false, contextWindowSize: 200000, contextWindowAuthoritative: false, providerCacheKey: "default", @@ -9726,6 +9925,7 @@ describe("session/cancel wedge recovery (issue #680)", () => { fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, + forwardSubagentText: false, contextWindowSize: 200000, contextWindowAuthoritative: false, providerCacheKey: "default", @@ -10307,6 +10507,14 @@ describe("streamEventToAcpNotifications", () => { title: "Research dependencies", rawInput: { description: "Research dependencies" }, }, + { + case: "Bash description", + name: "Bash", + partialJson: '{"command":"git diff","description":"Show current diff","timeout":', + title: "git diff", + metaTitle: "Show current diff", + rawInput: { command: "git diff", description: "Show current diff" }, + }, { case: "Bash command with comma and escaped quotes", name: "Bash", @@ -10424,6 +10632,11 @@ describe("streamEventToAcpNotifications", () => { title: testCase.title, rawInput: testCase.rawInput, }); + if ("metaTitle" in testCase) { + expect(refined[0].update._meta).toMatchObject({ + claudeCode: { title: testCase.metaTitle }, + }); + } // Refinements never carry `content`: content built from partial input is // misleading (an Edit missing new_string renders as a deletion) or // invalid (a Write diff without content lacks the required newText). @@ -10997,6 +11210,7 @@ describe("agent selection config option", () => { fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, + forwardSubagentText: false, contextWindowSize: 200000, contextWindowAuthoritative: false, providerCacheKey: "default", diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index 12a7d23e..bad683c6 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -263,6 +263,38 @@ describe("createSession options merging", () => { expect(capturedOptions!.tools).toEqual([]); }); + describe("subagent transcript forwarding", () => { + it("keeps the legacy default when neither the client nor caller opts in", async () => { + await agent.newSession({ cwd: process.cwd(), mcpServers: [] }); + + expect(capturedOptions!.forwardSubagentText).toBe(false); + }); + + it("preserves the pre-existing caller-provided SDK option", async () => { + await agent.newSession({ + cwd: process.cwd(), + mcpServers: [], + _meta: { claudeCode: { options: { forwardSubagentText: true } } }, + }); + + expect(capturedOptions!.forwardSubagentText).toBe(true); + }); + + it("enables SDK forwarding when the ACP client advertises support", async () => { + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { _meta: { "subagent-transcript": true } }, + }); + await agent.newSession({ + cwd: process.cwd(), + mcpServers: [], + _meta: { claudeCode: { options: { forwardSubagentText: false } } }, + }); + + expect(capturedOptions!.forwardSubagentText).toBe(true); + }); + }); + describe("systemPrompt via _meta", () => { it("defaults to the claude_code preset when not provided", async () => { await agent.newSession({ cwd: process.cwd(), mcpServers: [] });