@@ -11,7 +11,7 @@ import {
1111 BetaBashCodeExecutionToolResultBlockParam ,
1212} from "@anthropic-ai/sdk/resources/beta.mjs" ;
1313import { toAcpNotifications , ToolUseCache , Logger } from "../acp-agent.js" ;
14- import { toolUpdateFromToolResult , createPostToolUseHook } from "../tools.js" ;
14+ import { toolUpdateFromToolResult , createPostToolUseHook , registerHookCallback } from "../tools.js" ;
1515
1616describe ( "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} ) ;
0 commit comments