Skip to content

Commit 5ed2d93

Browse files
timvisher-ddclaude
andcommitted
fix: defer PostToolUse hook execution when callback not yet registered
The Claude Agent SDK fires PostToolUse hooks and delivers streaming content blocks concurrently. When a tool completes quickly, the hook fires before our streaming handler has processed the tool_use block and called registerHookCallback(), causing "No onPostToolUseHook found" errors and silently dropping the session/update notification. Instead of logging an error and giving up, createPostToolUseHook now creates a deferred promise and waits (up to 5s) for registerHookCallback() to provide the callback. registerHookCallback() checks for pending deferreds and resolves them immediately, so the hook proceeds as soon as the streaming handler catches up. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a02ef18 commit 5ed2d93

2 files changed

Lines changed: 219 additions & 4 deletions

File tree

src/tests/tools.test.ts

Lines changed: 167 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,170 @@ describe("Bash terminal output", () => {
12341234
expect(hookMeta.terminal_exit).toBeUndefined();
12351235
});
12361236
});
1237+
1238+
describe("PostToolUse callback execution contract", () => {
1239+
// These tests verify the observable contract between PostToolUse
1240+
// hooks and registerHookCallback, regardless of implementation:
1241+
//
1242+
// 1. Callback registered THEN hook fires → callback executes
1243+
// 2. Hook fires THEN callback registered → callback still executes
1244+
// 3. No errors logged in either ordering
1245+
// 4. Callback receives correct toolInput and toolResponse
1246+
// 5. Multiple hooks with mixed ordering don't interfere
1247+
//
1248+
// A helper that builds the hook input object for a given tool call.
1249+
function postToolUseInput(toolUseId: string, toolName: string, toolInput: unknown = {}, toolResponse: unknown = "") {
1250+
return {
1251+
hook_event_name: "PostToolUse",
1252+
tool_name: toolName,
1253+
tool_input: toolInput,
1254+
tool_response: toolResponse,
1255+
tool_use_id: toolUseId,
1256+
session_id: "test-session",
1257+
transcript_path: "/tmp/test",
1258+
cwd: "/tmp",
1259+
};
1260+
}
1261+
1262+
it("executes callback when registered before hook fires", async () => {
1263+
const received: { id: string; input: unknown; response: unknown }[] = [];
1264+
1265+
registerHookCallback("toolu_before_1", {
1266+
onPostToolUseHook: async (id, input, response) => {
1267+
received.push({ id, input, response });
1268+
},
1269+
});
1270+
1271+
const hook = createPostToolUseHook(mockLogger);
1272+
const result = await hook(
1273+
postToolUseInput("toolu_before_1", "Bash", { command: "ls" }, "file.txt"),
1274+
"toolu_before_1",
1275+
{ signal: AbortSignal.abort() },
1276+
);
1277+
1278+
expect(result).toEqual({ continue: true });
1279+
expect(received).toHaveLength(1);
1280+
expect(received[0]).toEqual({
1281+
id: "toolu_before_1",
1282+
input: { command: "ls" },
1283+
response: "file.txt",
1284+
});
1285+
});
1286+
1287+
it("executes callback when registered after hook fires", async () => {
1288+
const received: { id: string; input: unknown; response: unknown }[] = [];
1289+
const hook = createPostToolUseHook(mockLogger);
1290+
1291+
// Hook fires first — no callback registered yet.
1292+
const hookPromise = hook(
1293+
postToolUseInput("toolu_after_1", "Read", { file_path: "/tmp/f" }, "contents"),
1294+
"toolu_after_1",
1295+
{ signal: AbortSignal.abort() },
1296+
);
1297+
1298+
// Registration arrives on the next tick (simulates streaming lag).
1299+
await new Promise((r) => setTimeout(r, 5));
1300+
registerHookCallback("toolu_after_1", {
1301+
onPostToolUseHook: async (id, input, response) => {
1302+
received.push({ id, input, response });
1303+
},
1304+
});
1305+
1306+
const result = await hookPromise;
1307+
1308+
expect(result).toEqual({ continue: true });
1309+
expect(received).toHaveLength(1);
1310+
expect(received[0]).toEqual({
1311+
id: "toolu_after_1",
1312+
input: { file_path: "/tmp/f" },
1313+
response: "contents",
1314+
});
1315+
});
1316+
1317+
it("does not log errors regardless of registration ordering", async () => {
1318+
const errors: string[] = [];
1319+
const spyLogger: Logger = {
1320+
log: () => {},
1321+
error: (...args: any[]) => {
1322+
errors.push(args.map(String).join(" "));
1323+
},
1324+
};
1325+
1326+
const hook = createPostToolUseHook(spyLogger);
1327+
1328+
// Case A: register-then-fire
1329+
registerHookCallback("toolu_order_a", {
1330+
onPostToolUseHook: async () => {},
1331+
});
1332+
await hook(
1333+
postToolUseInput("toolu_order_a", "Bash"),
1334+
"toolu_order_a",
1335+
{ signal: AbortSignal.abort() },
1336+
);
1337+
1338+
// Case B: fire-then-register
1339+
const hookPromise = hook(
1340+
postToolUseInput("toolu_order_b", "Grep"),
1341+
"toolu_order_b",
1342+
{ signal: AbortSignal.abort() },
1343+
);
1344+
await new Promise((r) => setTimeout(r, 5));
1345+
registerHookCallback("toolu_order_b", {
1346+
onPostToolUseHook: async () => {},
1347+
});
1348+
await hookPromise;
1349+
1350+
expect(errors).toHaveLength(0);
1351+
});
1352+
1353+
it("keeps hooks independent when some are pre-registered and some are late", async () => {
1354+
const callOrder: string[] = [];
1355+
const hook = createPostToolUseHook(mockLogger);
1356+
1357+
// Register callback A upfront.
1358+
registerHookCallback("toolu_mix_a", {
1359+
onPostToolUseHook: async (id) => { callOrder.push(id); },
1360+
});
1361+
1362+
// Fire hook B first (no registration yet), then hook A.
1363+
const hookBPromise = hook(
1364+
postToolUseInput("toolu_mix_b", "Read"),
1365+
"toolu_mix_b",
1366+
{ signal: AbortSignal.abort() },
1367+
);
1368+
1369+
await hook(
1370+
postToolUseInput("toolu_mix_a", "Bash"),
1371+
"toolu_mix_a",
1372+
{ signal: AbortSignal.abort() },
1373+
);
1374+
1375+
// A should have executed already.
1376+
expect(callOrder).toEqual(["toolu_mix_a"]);
1377+
1378+
// Now register B — its hook should complete.
1379+
registerHookCallback("toolu_mix_b", {
1380+
onPostToolUseHook: async (id) => { callOrder.push(id); },
1381+
});
1382+
1383+
await hookBPromise;
1384+
expect(callOrder).toEqual(["toolu_mix_a", "toolu_mix_b"]);
1385+
});
1386+
1387+
it("always returns { continue: true } even in the race case", async () => {
1388+
const hook = createPostToolUseHook(mockLogger);
1389+
1390+
const hookPromise = hook(
1391+
postToolUseInput("toolu_continue_1", "Agent"),
1392+
"toolu_continue_1",
1393+
{ signal: AbortSignal.abort() },
1394+
);
1395+
1396+
registerHookCallback("toolu_continue_1", {
1397+
onPostToolUseHook: async () => {},
1398+
});
1399+
1400+
expect(await hookPromise).toEqual({ continue: true });
1401+
});
1402+
});
12371403
});

src/tools.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,25 @@ const toolUseCallbacks: {
727727
};
728728
} = {};
729729

730+
/*
731+
* When the SDK fires PostToolUse before the streaming handler has called
732+
* registerHookCallback(), we store a deferred here. registerHookCallback()
733+
* checks this map and resolves the deferred so the waiting hook can proceed.
734+
*/
735+
const pendingHooks: {
736+
[toolUseId: string]: {
737+
resolve: (
738+
cb: (toolUseID: string, toolInput: unknown, toolResponse: unknown) => Promise<void>,
739+
) => void;
740+
promise: Promise<
741+
(toolUseID: string, toolInput: unknown, toolResponse: unknown) => Promise<void>
742+
>;
743+
};
744+
} = {};
745+
746+
/** Maximum time (ms) to wait for a callback registration before giving up. */
747+
const HOOK_WAIT_TIMEOUT_MS = 5_000;
748+
730749
/* Setup callbacks that will be called when receiving hooks from Claude Code */
731750
export const registerHookCallback = (
732751
toolUseID: string,
@@ -743,6 +762,12 @@ export const registerHookCallback = (
743762
toolUseCallbacks[toolUseID] = {
744763
onPostToolUseHook,
745764
};
765+
766+
// If a PostToolUse hook is already waiting for this ID, hand it the callback.
767+
if (onPostToolUseHook && pendingHooks[toolUseID]) {
768+
pendingHooks[toolUseID].resolve(onPostToolUseHook);
769+
delete pendingHooks[toolUseID];
770+
}
746771
};
747772

748773
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
@@ -761,13 +786,37 @@ export const createPostToolUseHook =
761786
}
762787

763788
if (toolUseID) {
764-
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
789+
let onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
765790
if (onPostToolUseHook) {
766791
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
767792
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
768793
} else {
769-
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
770-
delete toolUseCallbacks[toolUseID];
794+
// The SDK fired PostToolUse before the streaming handler called
795+
// registerHookCallback(). Wait for registration (with timeout).
796+
let resolve: (typeof pendingHooks)[string]["resolve"];
797+
const promise = new Promise<
798+
(toolUseID: string, toolInput: unknown, toolResponse: unknown) => Promise<void>
799+
>((r) => {
800+
resolve = r;
801+
});
802+
pendingHooks[toolUseID] = { resolve: resolve!, promise };
803+
804+
const cb = await Promise.race([
805+
promise,
806+
new Promise<null>((r) => setTimeout(() => r(null), HOOK_WAIT_TIMEOUT_MS)),
807+
]);
808+
809+
delete pendingHooks[toolUseID];
810+
811+
if (cb) {
812+
await cb(toolUseID, input.tool_input, input.tool_response);
813+
delete toolUseCallbacks[toolUseID];
814+
} else {
815+
logger.error(
816+
`PostToolUse hook timed out waiting for callback registration: ${toolUseID}`,
817+
);
818+
delete toolUseCallbacks[toolUseID];
819+
}
771820
}
772821
}
773822
}

0 commit comments

Comments
 (0)