From 2835f030cf42f23f58b22f8202076f751ddc98da Mon Sep 17 00:00:00 2001 From: Guillaume Sabran Date: Thu, 13 Nov 2025 01:42:21 -0800 Subject: [PATCH] Add metadata to tool calls, including structured tool output (#128) Use the `_meta` field for tool calls to forward: - the original tool name (ie `Bash` etc) - the structured output of the tool call The output is received by using the `PostToolUse` hook from the SDK. The `tool_call_update` for the tool result is now only sent when both the hook callback and the original tool result (plain text) have been received. Claude Code describes [in its documentation ](https://docs.claude.com/en/api/agent-sdk/typescript#tool-output-types)the expected structure of the output for each tools. Unfortunately those are not part of the SDK, and [empirically I've found that there was some differences](https://github.com/getcmd-dev/cmd/blob/58c3100a36f7e8ec92e1fd04b3af3091867d6cfc/local-server/src/server/endpoints/sendMessage/acp/clients/claudeCode/types.ts#L265). Still I've found that having this structured output was useful for my client to provide a richer UX. Maybe Zed will too. I was not sure how to add tests for this change, given the current setup --- src/acp-agent.ts | 62 +++++++++++++++++++++++++++++++++++++ src/lib.ts | 1 + src/tests/acp-agent.test.ts | 10 +++++- src/tools.ts | 48 ++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 0a2c4db7..e9ed570b 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -47,6 +47,8 @@ import { planEntries, toolUpdateFromToolResult, ClaudePlanEntry, + registerHookCallback, + postToolUseHook, } from "./tools.js"; import { ContentBlockParam } from "@anthropic-ai/sdk/resources"; import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs"; @@ -70,6 +72,16 @@ type BackgroundTerminal = pendingOutput: TerminalOutputResponse; }; +/** + * Extra metadata that the agent provides for each tool_call / tool_update update, under the `_meta.claudeCode` key. + */ +export type ToolUpdateMeta = { + /* The name of the tool that was used in Claude Code. */ + toolName: string; + /* The structured output provided by Claude Code. */ + toolResponse?: unknown; +}; + type ToolUseCache = { [key: string]: { type: "tool_use" | "server_tool_use" | "mcp_tool_use"; @@ -223,6 +235,13 @@ export class ClaudeAcpAgent implements Agent { ...(process.env.CLAUDE_CODE_EXECUTABLE && { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE, }), + hooks: { + PostToolUse: [ + { + hooks: [postToolUseHook], + }, + ], + }, }; const allowedTools = []; @@ -371,6 +390,7 @@ export class ClaudeAcpAgent implements Agent { params.sessionId, this.toolUseCache, this.fileContentCache, + this.client, )) { await this.client.sessionUpdate(notification); } @@ -427,6 +447,7 @@ export class ClaudeAcpAgent implements Agent { params.sessionId, this.toolUseCache, this.fileContentCache, + this.client, )) { await this.client.sessionUpdate(notification); } @@ -772,6 +793,7 @@ export function toAcpNotifications( sessionId: string, toolUseCache: ToolUseCache, fileContentCache: { [key: string]: string }, + client: AgentSideConnection, ): SessionNotification[] { if (typeof content === "string") { return [ @@ -837,6 +859,33 @@ export function toAcpNotifications( }; } } else { + // Register hook callback to receive the structured output from the hook + registerHookCallback(chunk.id, { + onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => { + const toolUse = toolUseCache[toolUseId]; + if (toolUse) { + const update: SessionNotification["update"] = { + _meta: { + claudeCode: { + toolResponse, + toolName: toolUse.name, + } satisfies ToolUpdateMeta, + }, + toolCallId: toolUseId, + sessionUpdate: "tool_call_update", + }; + await client.sessionUpdate({ + sessionId, + update, + }); + } else { + console.error( + `[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`, + ); + } + }, + }); + let rawInput; try { rawInput = JSON.parse(JSON.stringify(chunk.input)); @@ -844,6 +893,11 @@ export function toAcpNotifications( // ignore if we can't turn it to JSON } update = { + _meta: { + claudeCode: { + toolName: chunk.name, + } satisfies ToolUpdateMeta, + }, toolCallId: chunk.id, sessionUpdate: "tool_call", rawInput, @@ -871,6 +925,11 @@ export function toAcpNotifications( if (toolUse.name !== "TodoWrite") { update = { + _meta: { + claudeCode: { + toolName: toolUse.name, + } satisfies ToolUpdateMeta, + }, toolCallId: chunk.tool_use_id, sessionUpdate: "tool_call_update", status: "is_error" in chunk && chunk.is_error ? "failed" : "completed", @@ -905,6 +964,7 @@ export function streamEventToAcpNotifications( sessionId: string, toolUseCache: ToolUseCache, fileContentCache: { [key: string]: string }, + client: AgentSideConnection, ): SessionNotification[] { const event = message.event; switch (event.type) { @@ -915,6 +975,7 @@ export function streamEventToAcpNotifications( sessionId, toolUseCache, fileContentCache, + client, ); case "content_block_delta": return toAcpNotifications( @@ -923,6 +984,7 @@ export function streamEventToAcpNotifications( sessionId, toolUseCache, fileContentCache, + client, ); // No content case "message_start": diff --git a/src/lib.ts b/src/lib.ts index eb73e779..40b50dbf 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -4,6 +4,7 @@ export { runAcp, toAcpNotifications, streamEventToAcpNotifications, + type ToolUpdateMeta, } from "./acp-agent.js"; export { loadManagedSettings, diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 5f0cd917..f91785ff 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { spawn, spawnSync } from "child_process"; import { Agent, + AgentSideConnection, AvailableCommand, Client, ClientSideConnection, @@ -650,7 +651,14 @@ describe("tool conversions", () => { uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5", }; expect( - toAcpNotifications(received.message.content, received.message.role, "test", {}, {}), + toAcpNotifications( + received.message.content, + received.message.role, + "test", + {}, + {}, + {} as AgentSideConnection, + ), ).toStrictEqual([ { sessionId: "test", diff --git a/src/tools.ts b/src/tools.ts index ed971261..0777c0c7 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -9,6 +9,7 @@ import { BetaWebFetchToolResultBlockParam, BetaWebSearchToolResultBlockParam, } from "@anthropic-ai/sdk/resources/beta.mjs"; +import { HookCallback } from "@anthropic-ai/claude-agent-sdk"; interface ToolInfo { title: string; @@ -538,3 +539,50 @@ export function markdownEscape(text: string): string { } return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape; } + +/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */ +const toolUseCallbacks: { + [toolUseId: string]: { + onPostToolUseHook?: ( + toolUseID: string, + toolInput: unknown, + toolResponse: unknown, + ) => Promise; + }; +} = {}; + +/* Setup callbacks that will be called when receiving hooks from Claude Code */ +export const registerHookCallback = ( + toolUseID: string, + { + onPostToolUseHook, + }: { + onPostToolUseHook?: ( + toolUseID: string, + toolInput: unknown, + toolResponse: unknown, + ) => Promise; + }, +) => { + toolUseCallbacks[toolUseID] = { + onPostToolUseHook, + }; +}; + +/* A callback for Claude Code that is called when receiving a PostToolUse hook */ +export const postToolUseHook: HookCallback = async ( + input: any, + toolUseID: string | undefined, +): Promise<{ continue: boolean }> => { + if (input.hook_event_name === "PostToolUse" && toolUseID) { + const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook; + if (onPostToolUseHook) { + await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response); + delete toolUseCallbacks[toolUseID]; // Cleanup after execution + } else { + console.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`); + delete toolUseCallbacks[toolUseID]; + } + } + return { continue: true }; +};