Skip to content

Commit 07a7bf1

Browse files
timvisher-ddclaude
andcommitted
fix: suppress expected PostToolUse errors for subagent and server tools
The SDK fires PostToolUse hooks for Task (subagent) tools before registerHookCallback() sees the tool_use block, and for server_tool_use tools (WebSearch) which never register a callback. Since this is expected behavior, skip the logger.error() call for these tools as the error output can be confusing for clients. Genuinely unexpected missing callbacks still log an error with the tool name for debugging. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a02ef18 commit 07a7bf1

2 files changed

Lines changed: 182 additions & 2 deletions

File tree

‎src/tests/tools.test.ts‎

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
BetaBashCodeExecutionToolResultBlockParam,
1212
} from "@anthropic-ai/sdk/resources/beta.mjs";
1313
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
14-
import { toolUpdateFromToolResult, createPostToolUseHook } from "../tools.js";
14+
import { toolUpdateFromToolResult, createPostToolUseHook, registerHookCallback } from "../tools.js";
1515

1616
describe("rawOutput in tool call updates", () => {
1717
const mockClient = {} as AgentSideConnection;
@@ -1234,4 +1234,173 @@ describe("Bash terminal output", () => {
12341234
expect(hookMeta.terminal_exit).toBeUndefined();
12351235
});
12361236
});
1237+
1238+
describe("PostToolUse hook for unregistered tool_use IDs", () => {
1239+
// The SDK fires PostToolUse for subagent tools (Task) before
1240+
// registerHookCallback() sees the tool_use block, and for
1241+
// server_tool_use (WebSearch) which never registers a callback.
1242+
// Since this is expected behavior we choose not to log it, as
1243+
// the error output can be confusing for clients.
1244+
1245+
it("should not call logger.error for Task (subagent) tool", async () => {
1246+
const errors: string[] = [];
1247+
const spyLogger: Logger = {
1248+
log: () => {},
1249+
error: (...args: any[]) => {
1250+
errors.push(args.map(String).join(" "));
1251+
},
1252+
};
1253+
1254+
const hook = createPostToolUseHook(spyLogger);
1255+
await hook(
1256+
{
1257+
hook_event_name: "PostToolUse",
1258+
tool_name: "Task",
1259+
tool_input: { prompt: "count exported functions" },
1260+
tool_response: "42 exported functions",
1261+
tool_use_id: "toolu_subagent_never_registered",
1262+
session_id: "test-session",
1263+
transcript_path: "/tmp/test",
1264+
cwd: "/tmp",
1265+
},
1266+
"toolu_subagent_never_registered",
1267+
{ signal: AbortSignal.abort() },
1268+
);
1269+
1270+
expect(errors).toHaveLength(0);
1271+
});
1272+
1273+
it("should not call logger.error for WebSearch (server_tool_use) tool", async () => {
1274+
const errors: string[] = [];
1275+
const spyLogger: Logger = {
1276+
log: () => {},
1277+
error: (...args: any[]) => {
1278+
errors.push(args.map(String).join(" "));
1279+
},
1280+
};
1281+
1282+
const hook = createPostToolUseHook(spyLogger);
1283+
await hook(
1284+
{
1285+
hook_event_name: "PostToolUse",
1286+
tool_name: "WebSearch",
1287+
tool_input: { query: "test" },
1288+
tool_response: "results",
1289+
tool_use_id: "toolu_server_tool_never_registered",
1290+
session_id: "test-session",
1291+
transcript_path: "/tmp/test",
1292+
cwd: "/tmp",
1293+
},
1294+
"toolu_server_tool_never_registered",
1295+
{ signal: AbortSignal.abort() },
1296+
);
1297+
1298+
expect(errors).toHaveLength(0);
1299+
});
1300+
1301+
it("should still call logger.error for genuinely unexpected unregistered tools", async () => {
1302+
const errors: string[] = [];
1303+
const spyLogger: Logger = {
1304+
log: () => {},
1305+
error: (...args: any[]) => {
1306+
errors.push(args.map(String).join(" "));
1307+
},
1308+
};
1309+
1310+
const hook = createPostToolUseHook(spyLogger);
1311+
await hook(
1312+
{
1313+
hook_event_name: "PostToolUse",
1314+
tool_name: "Read",
1315+
tool_input: { file_path: "/tmp/test.txt" },
1316+
tool_response: "file contents",
1317+
tool_use_id: "toolu_unexpected_missing",
1318+
session_id: "test-session",
1319+
transcript_path: "/tmp/test",
1320+
cwd: "/tmp",
1321+
},
1322+
"toolu_unexpected_missing",
1323+
{ signal: AbortSignal.abort() },
1324+
);
1325+
1326+
expect(errors).toHaveLength(1);
1327+
expect(errors[0]).toContain("toolu_unexpected_missing");
1328+
expect(errors[0]).toContain("Read");
1329+
});
1330+
1331+
it("should return { continue: true } for any unregistered tool_use ID", async () => {
1332+
const hook = createPostToolUseHook(mockLogger);
1333+
const result = await hook(
1334+
{
1335+
hook_event_name: "PostToolUse",
1336+
tool_name: "WebSearch",
1337+
tool_input: { query: "test" },
1338+
tool_response: "results",
1339+
tool_use_id: "toolu_server_tool_never_registered_2",
1340+
session_id: "test-session",
1341+
transcript_path: "/tmp/test",
1342+
cwd: "/tmp",
1343+
},
1344+
"toolu_server_tool_never_registered_2",
1345+
{ signal: AbortSignal.abort() },
1346+
);
1347+
1348+
expect(result).toEqual({ continue: true });
1349+
});
1350+
1351+
it("should clean up the toolUseCallbacks entry even when unregistered", async () => {
1352+
// Pre-register a callback for a different ID to verify the
1353+
// unregistered path doesn't interfere with registered ones.
1354+
const hookUpdates: any[] = [];
1355+
const mockClientWithUpdate = {
1356+
sessionUpdate: async (notification: any) => {
1357+
hookUpdates.push(notification);
1358+
},
1359+
} as unknown as AgentSideConnection;
1360+
1361+
registerHookCallback("toolu_registered_2", {
1362+
onPostToolUseHook: async (_id, _input, _response) => {
1363+
await mockClientWithUpdate.sessionUpdate({ update: { registered: true } });
1364+
},
1365+
});
1366+
1367+
const hook = createPostToolUseHook(mockLogger);
1368+
1369+
// Fire for unregistered Task — should silently ignore
1370+
await hook(
1371+
{
1372+
hook_event_name: "PostToolUse",
1373+
tool_name: "Task",
1374+
tool_input: {},
1375+
tool_response: "",
1376+
tool_use_id: "toolu_unregistered_2",
1377+
session_id: "test-session",
1378+
transcript_path: "/tmp/test",
1379+
cwd: "/tmp",
1380+
},
1381+
"toolu_unregistered_2",
1382+
{ signal: AbortSignal.abort() },
1383+
);
1384+
1385+
expect(hookUpdates).toHaveLength(0);
1386+
1387+
// Fire for registered ID — should work normally
1388+
await hook(
1389+
{
1390+
hook_event_name: "PostToolUse",
1391+
tool_name: "Read",
1392+
tool_input: {},
1393+
tool_response: "",
1394+
tool_use_id: "toolu_registered_2",
1395+
session_id: "test-session",
1396+
transcript_path: "/tmp/test",
1397+
cwd: "/tmp",
1398+
},
1399+
"toolu_registered_2",
1400+
{ signal: AbortSignal.abort() },
1401+
);
1402+
1403+
expect(hookUpdates).toHaveLength(1);
1404+
});
1405+
});
12371406
});

‎src/tools.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,18 @@ export const createPostToolUseHook =
766766
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
767767
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
768768
} else {
769-
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
769+
// The SDK fires PostToolUse for subagent tools (Task) before
770+
// registerHookCallback() sees the tool_use block, and for
771+
// server_tool_use (WebSearch etc.) which never registers a
772+
// callback. Since this is expected behavior, we skip logging
773+
// for these tools as the error output can be confusing for
774+
// clients. Only log for genuinely unexpected cases.
775+
const expectedMissing = input.tool_name === "Task" || input.tool_name === "WebSearch";
776+
if (!expectedMissing) {
777+
logger.error(
778+
`No onPostToolUseHook found for tool use ID: ${toolUseID} (tool: ${input.tool_name})`,
779+
);
780+
}
770781
delete toolUseCallbacks[toolUseID];
771782
}
772783
}

0 commit comments

Comments
 (0)