@librechat/agents 3.1.67 → 3.1.68-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +23 -3
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +16 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +91 -0
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/graphs/MultiAgentGraph.cjs +36 -0
  8. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  9. package/dist/cjs/hooks/HookRegistry.cjs +162 -0
  10. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -0
  11. package/dist/cjs/hooks/executeHooks.cjs +276 -0
  12. package/dist/cjs/hooks/executeHooks.cjs.map +1 -0
  13. package/dist/cjs/hooks/matchers.cjs +256 -0
  14. package/dist/cjs/hooks/matchers.cjs.map +1 -0
  15. package/dist/cjs/hooks/types.cjs +27 -0
  16. package/dist/cjs/hooks/types.cjs.map +1 -0
  17. package/dist/cjs/main.cjs +54 -0
  18. package/dist/cjs/main.cjs.map +1 -1
  19. package/dist/cjs/messages/format.cjs +74 -12
  20. package/dist/cjs/messages/format.cjs.map +1 -1
  21. package/dist/cjs/run.cjs +111 -0
  22. package/dist/cjs/run.cjs.map +1 -1
  23. package/dist/cjs/summarization/index.cjs +41 -0
  24. package/dist/cjs/summarization/index.cjs.map +1 -1
  25. package/dist/cjs/summarization/node.cjs +165 -19
  26. package/dist/cjs/summarization/node.cjs.map +1 -1
  27. package/dist/cjs/tools/BashExecutor.cjs +175 -0
  28. package/dist/cjs/tools/BashExecutor.cjs.map +1 -0
  29. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +296 -0
  30. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -0
  31. package/dist/cjs/tools/ReadFile.cjs +43 -0
  32. package/dist/cjs/tools/ReadFile.cjs.map +1 -0
  33. package/dist/cjs/tools/SkillTool.cjs +50 -0
  34. package/dist/cjs/tools/SkillTool.cjs.map +1 -0
  35. package/dist/cjs/tools/SubagentTool.cjs +92 -0
  36. package/dist/cjs/tools/SubagentTool.cjs.map +1 -0
  37. package/dist/cjs/tools/ToolNode.cjs +304 -140
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/skillCatalog.cjs +84 -0
  40. package/dist/cjs/tools/skillCatalog.cjs.map +1 -0
  41. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +511 -0
  42. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -0
  43. package/dist/esm/agents/AgentContext.mjs +23 -3
  44. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  45. package/dist/esm/common/enum.mjs +15 -1
  46. package/dist/esm/common/enum.mjs.map +1 -1
  47. package/dist/esm/graphs/Graph.mjs +91 -0
  48. package/dist/esm/graphs/Graph.mjs.map +1 -1
  49. package/dist/esm/graphs/MultiAgentGraph.mjs +36 -0
  50. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  51. package/dist/esm/hooks/HookRegistry.mjs +160 -0
  52. package/dist/esm/hooks/HookRegistry.mjs.map +1 -0
  53. package/dist/esm/hooks/executeHooks.mjs +273 -0
  54. package/dist/esm/hooks/executeHooks.mjs.map +1 -0
  55. package/dist/esm/hooks/matchers.mjs +251 -0
  56. package/dist/esm/hooks/matchers.mjs.map +1 -0
  57. package/dist/esm/hooks/types.mjs +25 -0
  58. package/dist/esm/hooks/types.mjs.map +1 -0
  59. package/dist/esm/main.mjs +13 -2
  60. package/dist/esm/main.mjs.map +1 -1
  61. package/dist/esm/messages/format.mjs +66 -4
  62. package/dist/esm/messages/format.mjs.map +1 -1
  63. package/dist/esm/run.mjs +111 -0
  64. package/dist/esm/run.mjs.map +1 -1
  65. package/dist/esm/summarization/index.mjs +41 -1
  66. package/dist/esm/summarization/index.mjs.map +1 -1
  67. package/dist/esm/summarization/node.mjs +165 -19
  68. package/dist/esm/summarization/node.mjs.map +1 -1
  69. package/dist/esm/tools/BashExecutor.mjs +169 -0
  70. package/dist/esm/tools/BashExecutor.mjs.map +1 -0
  71. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +287 -0
  72. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -0
  73. package/dist/esm/tools/ReadFile.mjs +38 -0
  74. package/dist/esm/tools/ReadFile.mjs.map +1 -0
  75. package/dist/esm/tools/SkillTool.mjs +45 -0
  76. package/dist/esm/tools/SkillTool.mjs.map +1 -0
  77. package/dist/esm/tools/SubagentTool.mjs +85 -0
  78. package/dist/esm/tools/SubagentTool.mjs.map +1 -0
  79. package/dist/esm/tools/ToolNode.mjs +306 -142
  80. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  81. package/dist/esm/tools/skillCatalog.mjs +82 -0
  82. package/dist/esm/tools/skillCatalog.mjs.map +1 -0
  83. package/dist/esm/tools/subagent/SubagentExecutor.mjs +505 -0
  84. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -0
  85. package/dist/types/agents/AgentContext.d.ts +6 -0
  86. package/dist/types/common/enum.d.ts +10 -1
  87. package/dist/types/graphs/Graph.d.ts +2 -0
  88. package/dist/types/graphs/MultiAgentGraph.d.ts +12 -0
  89. package/dist/types/hooks/HookRegistry.d.ts +56 -0
  90. package/dist/types/hooks/executeHooks.d.ts +79 -0
  91. package/dist/types/hooks/index.d.ts +6 -0
  92. package/dist/types/hooks/matchers.d.ts +95 -0
  93. package/dist/types/hooks/types.d.ts +320 -0
  94. package/dist/types/index.d.ts +8 -0
  95. package/dist/types/messages/format.d.ts +2 -1
  96. package/dist/types/run.d.ts +1 -0
  97. package/dist/types/summarization/index.d.ts +2 -0
  98. package/dist/types/summarization/node.d.ts +2 -0
  99. package/dist/types/tools/BashExecutor.d.ts +45 -0
  100. package/dist/types/tools/BashProgrammaticToolCalling.d.ts +72 -0
  101. package/dist/types/tools/ReadFile.d.ts +28 -0
  102. package/dist/types/tools/SkillTool.d.ts +40 -0
  103. package/dist/types/tools/SubagentTool.d.ts +36 -0
  104. package/dist/types/tools/ToolNode.d.ts +24 -2
  105. package/dist/types/tools/skillCatalog.d.ts +19 -0
  106. package/dist/types/tools/subagent/SubagentExecutor.d.ts +137 -0
  107. package/dist/types/tools/subagent/index.d.ts +2 -0
  108. package/dist/types/types/graph.d.ts +61 -2
  109. package/dist/types/types/index.d.ts +1 -0
  110. package/dist/types/types/run.d.ts +20 -0
  111. package/dist/types/types/skill.d.ts +9 -0
  112. package/dist/types/types/tools.d.ts +38 -1
  113. package/package.json +5 -1
  114. package/src/agents/AgentContext.ts +26 -2
  115. package/src/common/enum.ts +15 -0
  116. package/src/graphs/Graph.ts +113 -0
  117. package/src/graphs/MultiAgentGraph.ts +39 -0
  118. package/src/graphs/__tests__/MultiAgentGraph.test.ts +91 -0
  119. package/src/hooks/HookRegistry.ts +208 -0
  120. package/src/hooks/__tests__/HookRegistry.test.ts +190 -0
  121. package/src/hooks/__tests__/compactHooks.test.ts +214 -0
  122. package/src/hooks/__tests__/executeHooks.test.ts +1013 -0
  123. package/src/hooks/__tests__/integration.test.ts +337 -0
  124. package/src/hooks/__tests__/matchers.test.ts +238 -0
  125. package/src/hooks/__tests__/toolHooks.test.ts +669 -0
  126. package/src/hooks/executeHooks.ts +375 -0
  127. package/src/hooks/index.ts +57 -0
  128. package/src/hooks/matchers.ts +280 -0
  129. package/src/hooks/types.ts +404 -0
  130. package/src/index.ts +10 -0
  131. package/src/messages/format.ts +74 -4
  132. package/src/messages/formatAgentMessages.skills.test.ts +334 -0
  133. package/src/run.ts +126 -0
  134. package/src/scripts/multi-agent-subagent.ts +246 -0
  135. package/src/scripts/subagent-event-driven-debug.ts +190 -0
  136. package/src/scripts/subagent-tools-debug.ts +160 -0
  137. package/src/specs/subagent.test.ts +305 -0
  138. package/src/summarization/__tests__/node.test.ts +42 -0
  139. package/src/summarization/__tests__/trigger.test.ts +100 -1
  140. package/src/summarization/index.ts +47 -0
  141. package/src/summarization/node.ts +202 -24
  142. package/src/tools/BashExecutor.ts +205 -0
  143. package/src/tools/BashProgrammaticToolCalling.ts +397 -0
  144. package/src/tools/ReadFile.ts +39 -0
  145. package/src/tools/SkillTool.ts +46 -0
  146. package/src/tools/SubagentTool.ts +100 -0
  147. package/src/tools/ToolNode.ts +391 -169
  148. package/src/tools/__tests__/ReadFile.test.ts +44 -0
  149. package/src/tools/__tests__/SkillTool.test.ts +442 -0
  150. package/src/tools/__tests__/SubagentExecutor.test.ts +1148 -0
  151. package/src/tools/__tests__/SubagentTool.test.ts +149 -0
  152. package/src/tools/__tests__/ToolNode.session.test.ts +12 -12
  153. package/src/tools/__tests__/skillCatalog.test.ts +161 -0
  154. package/src/tools/__tests__/subagentHooks.test.ts +215 -0
  155. package/src/tools/skillCatalog.ts +126 -0
  156. package/src/tools/subagent/SubagentExecutor.ts +676 -0
  157. package/src/tools/subagent/index.ts +13 -0
  158. package/src/types/graph.ts +80 -1
  159. package/src/types/index.ts +1 -0
  160. package/src/types/run.ts +20 -0
  161. package/src/types/skill.ts +11 -0
  162. package/src/types/tools.ts +41 -1
@@ -0,0 +1,1013 @@
1
+ // src/hooks/__tests__/executeHooks.test.ts
2
+ import { HookRegistry } from '../HookRegistry';
3
+ import { executeHooks } from '../executeHooks';
4
+ import { clearMatcherCache } from '../matchers';
5
+ import type {
6
+ HookCallback,
7
+ HookMatcher,
8
+ RunStartHookInput,
9
+ RunStartHookOutput,
10
+ StopHookInput,
11
+ StopHookOutput,
12
+ PreToolUseHookInput,
13
+ PreToolUseHookOutput,
14
+ PostToolUseHookInput,
15
+ PostToolUseHookOutput,
16
+ } from '../types';
17
+
18
+ function preToolUseInput(
19
+ toolName: string,
20
+ overrides: Partial<PreToolUseHookInput> = {}
21
+ ): PreToolUseHookInput {
22
+ return {
23
+ hook_event_name: 'PreToolUse',
24
+ runId: 'run-1',
25
+ threadId: 'thread-1',
26
+ toolName,
27
+ toolInput: { cmd: 'ls' },
28
+ toolUseId: 'tool-call-1',
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ function postToolUseInput(
34
+ toolName: string,
35
+ overrides: Partial<PostToolUseHookInput> = {}
36
+ ): PostToolUseHookInput {
37
+ return {
38
+ hook_event_name: 'PostToolUse',
39
+ runId: 'run-1',
40
+ toolName,
41
+ toolInput: {},
42
+ toolOutput: null,
43
+ toolUseId: 'tc-1',
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ function stopInput(overrides: Partial<StopHookInput> = {}): StopHookInput {
49
+ return {
50
+ hook_event_name: 'Stop',
51
+ runId: 'run-1',
52
+ messages: [],
53
+ stopHookActive: false,
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ function runStartInput(): RunStartHookInput {
59
+ return {
60
+ hook_event_name: 'RunStart',
61
+ runId: 'run-1',
62
+ messages: [],
63
+ };
64
+ }
65
+
66
+ function preToolHook(
67
+ fn: HookCallback<'PreToolUse'>
68
+ ): HookCallback<'PreToolUse'> {
69
+ return fn;
70
+ }
71
+
72
+ function postToolHook(
73
+ fn: HookCallback<'PostToolUse'>
74
+ ): HookCallback<'PostToolUse'> {
75
+ return fn;
76
+ }
77
+
78
+ function runStartHook(fn: HookCallback<'RunStart'>): HookCallback<'RunStart'> {
79
+ return fn;
80
+ }
81
+
82
+ function stopHook(fn: HookCallback<'Stop'>): HookCallback<'Stop'> {
83
+ return fn;
84
+ }
85
+
86
+ const emptyPreOutput: PreToolUseHookOutput = {};
87
+ const emptyRunStartOutput: RunStartHookOutput = {};
88
+
89
+ const noopPreHook = preToolHook(
90
+ async (): Promise<PreToolUseHookOutput> => emptyPreOutput
91
+ );
92
+ const noopRunStartHook = runStartHook(
93
+ async (): Promise<RunStartHookOutput> => emptyRunStartOutput
94
+ );
95
+
96
+ describe('executeHooks', () => {
97
+ let consoleWarnSpy: jest.SpyInstance;
98
+
99
+ beforeEach(() => {
100
+ clearMatcherCache();
101
+ consoleWarnSpy = jest
102
+ .spyOn(console, 'warn')
103
+ .mockImplementation((): void => {
104
+ /* silence expected warnings */
105
+ });
106
+ });
107
+
108
+ afterEach(() => {
109
+ consoleWarnSpy.mockRestore();
110
+ });
111
+
112
+ describe('empty matcher set', () => {
113
+ it('returns an empty aggregated result when no matchers are registered', async () => {
114
+ const registry = new HookRegistry();
115
+ const result = await executeHooks({
116
+ registry,
117
+ input: preToolUseInput('Bash'),
118
+ matchQuery: 'Bash',
119
+ });
120
+ expect(result).toEqual({ additionalContexts: [], errors: [] });
121
+ });
122
+
123
+ it('returns an empty result when no matcher pattern matches the query', async () => {
124
+ const registry = new HookRegistry();
125
+ let called = false;
126
+ registry.register('PreToolUse', {
127
+ pattern: '^Edit$',
128
+ hooks: [
129
+ preToolHook(async (): Promise<PreToolUseHookOutput> => {
130
+ called = true;
131
+ return emptyPreOutput;
132
+ }),
133
+ ],
134
+ });
135
+ const result = await executeHooks({
136
+ registry,
137
+ input: preToolUseInput('Bash'),
138
+ matchQuery: 'Bash',
139
+ });
140
+ expect(called).toBe(false);
141
+ expect(result).toEqual({ additionalContexts: [], errors: [] });
142
+ });
143
+ });
144
+
145
+ describe('matcher regex filtering', () => {
146
+ it('fires hooks whose matcher regex matches the query', async () => {
147
+ const registry = new HookRegistry();
148
+ const calls: string[] = [];
149
+ registry.register('PreToolUse', {
150
+ pattern: '^Bash$',
151
+ hooks: [
152
+ preToolHook(async (): Promise<PreToolUseHookOutput> => {
153
+ calls.push('bash-only');
154
+ return emptyPreOutput;
155
+ }),
156
+ ],
157
+ });
158
+ registry.register('PreToolUse', {
159
+ pattern: 'Bash|Edit',
160
+ hooks: [
161
+ preToolHook(async (): Promise<PreToolUseHookOutput> => {
162
+ calls.push('bash-or-edit');
163
+ return emptyPreOutput;
164
+ }),
165
+ ],
166
+ });
167
+ registry.register('PreToolUse', {
168
+ pattern: '^Edit$',
169
+ hooks: [
170
+ preToolHook(async (): Promise<PreToolUseHookOutput> => {
171
+ calls.push('edit-only');
172
+ return emptyPreOutput;
173
+ }),
174
+ ],
175
+ });
176
+
177
+ await executeHooks({
178
+ registry,
179
+ input: preToolUseInput('Bash'),
180
+ matchQuery: 'Bash',
181
+ });
182
+ expect(calls.sort()).toEqual(['bash-only', 'bash-or-edit']);
183
+ });
184
+
185
+ it('fires matchers with no pattern regardless of query', async () => {
186
+ const registry = new HookRegistry();
187
+ let fired = false;
188
+ registry.register('PreToolUse', {
189
+ hooks: [
190
+ preToolHook(async (): Promise<PreToolUseHookOutput> => {
191
+ fired = true;
192
+ return emptyPreOutput;
193
+ }),
194
+ ],
195
+ });
196
+ await executeHooks({
197
+ registry,
198
+ input: preToolUseInput('Bash'),
199
+ matchQuery: 'Bash',
200
+ });
201
+ expect(fired).toBe(true);
202
+ });
203
+ });
204
+
205
+ describe('decision precedence (deny > ask > allow)', () => {
206
+ it('deny beats allow', async () => {
207
+ const registry = new HookRegistry();
208
+ registry.register('PreToolUse', {
209
+ hooks: [
210
+ preToolHook(
211
+ async (): Promise<PreToolUseHookOutput> => ({
212
+ decision: 'allow',
213
+ reason: 'all good',
214
+ })
215
+ ),
216
+ ],
217
+ });
218
+ registry.register('PreToolUse', {
219
+ hooks: [
220
+ preToolHook(
221
+ async (): Promise<PreToolUseHookOutput> => ({
222
+ decision: 'deny',
223
+ reason: 'forbidden',
224
+ })
225
+ ),
226
+ ],
227
+ });
228
+ const result = await executeHooks({
229
+ registry,
230
+ input: preToolUseInput('Bash'),
231
+ matchQuery: 'Bash',
232
+ });
233
+ expect(result.decision).toBe('deny');
234
+ expect(result.reason).toBe('forbidden');
235
+ });
236
+
237
+ it('deny beats ask', async () => {
238
+ const registry = new HookRegistry();
239
+ registry.register('PreToolUse', {
240
+ hooks: [
241
+ preToolHook(
242
+ async (): Promise<PreToolUseHookOutput> => ({
243
+ decision: 'ask',
244
+ reason: 'needs prompt',
245
+ })
246
+ ),
247
+ ],
248
+ });
249
+ registry.register('PreToolUse', {
250
+ hooks: [
251
+ preToolHook(
252
+ async (): Promise<PreToolUseHookOutput> => ({
253
+ decision: 'deny',
254
+ reason: 'forbidden',
255
+ })
256
+ ),
257
+ ],
258
+ });
259
+ const result = await executeHooks({
260
+ registry,
261
+ input: preToolUseInput('Bash'),
262
+ matchQuery: 'Bash',
263
+ });
264
+ expect(result.decision).toBe('deny');
265
+ expect(result.reason).toBe('forbidden');
266
+ });
267
+
268
+ it('ask beats allow but not deny', async () => {
269
+ const registry = new HookRegistry();
270
+ registry.register('PreToolUse', {
271
+ hooks: [
272
+ preToolHook(
273
+ async (): Promise<PreToolUseHookOutput> => ({ decision: 'allow' })
274
+ ),
275
+ ],
276
+ });
277
+ registry.register('PreToolUse', {
278
+ hooks: [
279
+ preToolHook(
280
+ async (): Promise<PreToolUseHookOutput> => ({
281
+ decision: 'ask',
282
+ reason: 'please confirm',
283
+ })
284
+ ),
285
+ ],
286
+ });
287
+ const result = await executeHooks({
288
+ registry,
289
+ input: preToolUseInput('Bash'),
290
+ matchQuery: 'Bash',
291
+ });
292
+ expect(result.decision).toBe('ask');
293
+ expect(result.reason).toBe('please confirm');
294
+ });
295
+
296
+ it('allow is the default when any hook returned allow and none denied or asked', async () => {
297
+ const registry = new HookRegistry();
298
+ registry.register('PreToolUse', {
299
+ hooks: [
300
+ preToolHook(
301
+ async (): Promise<PreToolUseHookOutput> => ({
302
+ decision: 'allow',
303
+ reason: 'ok',
304
+ })
305
+ ),
306
+ ],
307
+ });
308
+ const result = await executeHooks({
309
+ registry,
310
+ input: preToolUseInput('Bash'),
311
+ matchQuery: 'Bash',
312
+ });
313
+ expect(result.decision).toBe('allow');
314
+ expect(result.reason).toBe('ok');
315
+ });
316
+
317
+ it('no decision is set when no hook returns one', async () => {
318
+ const registry = new HookRegistry();
319
+ registry.register('PreToolUse', {
320
+ hooks: [noopPreHook],
321
+ });
322
+ const result = await executeHooks({
323
+ registry,
324
+ input: preToolUseInput('Bash'),
325
+ matchQuery: 'Bash',
326
+ });
327
+ expect(result.decision).toBeUndefined();
328
+ });
329
+ });
330
+
331
+ describe('stop decision folding', () => {
332
+ it('any block wins over continue', async () => {
333
+ const registry = new HookRegistry();
334
+ registry.register('Stop', {
335
+ hooks: [
336
+ stopHook(
337
+ async (): Promise<StopHookOutput> => ({ decision: 'continue' })
338
+ ),
339
+ ],
340
+ });
341
+ registry.register('Stop', {
342
+ hooks: [
343
+ stopHook(
344
+ async (): Promise<StopHookOutput> => ({
345
+ decision: 'block',
346
+ reason: 'more work to do',
347
+ })
348
+ ),
349
+ ],
350
+ });
351
+ const result = await executeHooks({ registry, input: stopInput() });
352
+ expect(result.stopDecision).toBe('block');
353
+ expect(result.reason).toBe('more work to do');
354
+ });
355
+
356
+ it('continue is the aggregated result when no hook blocks', async () => {
357
+ const registry = new HookRegistry();
358
+ registry.register('Stop', {
359
+ hooks: [
360
+ stopHook(
361
+ async (): Promise<StopHookOutput> => ({ decision: 'continue' })
362
+ ),
363
+ ],
364
+ });
365
+ const result = await executeHooks({ registry, input: stopInput() });
366
+ expect(result.stopDecision).toBe('continue');
367
+ });
368
+ });
369
+
370
+ describe('additionalContext accumulation', () => {
371
+ it('accumulates non-empty additionalContext from every hook', async () => {
372
+ const registry = new HookRegistry();
373
+ registry.register('PreToolUse', {
374
+ hooks: [
375
+ preToolHook(
376
+ async (): Promise<PreToolUseHookOutput> => ({
377
+ additionalContext: 'context one',
378
+ })
379
+ ),
380
+ preToolHook(
381
+ async (): Promise<PreToolUseHookOutput> => ({
382
+ additionalContext: '',
383
+ })
384
+ ),
385
+ preToolHook(
386
+ async (): Promise<PreToolUseHookOutput> => ({
387
+ additionalContext: 'context two',
388
+ })
389
+ ),
390
+ ],
391
+ });
392
+ const result = await executeHooks({
393
+ registry,
394
+ input: preToolUseInput('Bash'),
395
+ matchQuery: 'Bash',
396
+ });
397
+ expect(result.additionalContexts.sort()).toEqual([
398
+ 'context one',
399
+ 'context two',
400
+ ]);
401
+ });
402
+ });
403
+
404
+ describe('updatedInput handling', () => {
405
+ it('last-writer-wins on updatedInput follows registration order', async () => {
406
+ const registry = new HookRegistry();
407
+ registry.register('PreToolUse', {
408
+ hooks: [
409
+ preToolHook(
410
+ async (): Promise<PreToolUseHookOutput> => ({
411
+ updatedInput: { cmd: 'first' },
412
+ })
413
+ ),
414
+ ],
415
+ });
416
+ registry.register('PreToolUse', {
417
+ hooks: [
418
+ preToolHook(
419
+ async (): Promise<PreToolUseHookOutput> => ({
420
+ updatedInput: { cmd: 'second' },
421
+ })
422
+ ),
423
+ ],
424
+ });
425
+ registry.register('PreToolUse', {
426
+ hooks: [
427
+ preToolHook(
428
+ async (): Promise<PreToolUseHookOutput> => ({
429
+ updatedInput: { cmd: 'third' },
430
+ })
431
+ ),
432
+ ],
433
+ });
434
+ const result = await executeHooks({
435
+ registry,
436
+ input: preToolUseInput('Bash'),
437
+ matchQuery: 'Bash',
438
+ });
439
+ expect(result.updatedInput).toEqual({ cmd: 'third' });
440
+ });
441
+
442
+ it('last-writer-wins within a single matcher follows hook array order', async () => {
443
+ const registry = new HookRegistry();
444
+ registry.register('PreToolUse', {
445
+ hooks: [
446
+ preToolHook(
447
+ async (): Promise<PreToolUseHookOutput> => ({
448
+ updatedInput: { cmd: 'inner-first' },
449
+ })
450
+ ),
451
+ preToolHook(
452
+ async (): Promise<PreToolUseHookOutput> => ({
453
+ updatedInput: { cmd: 'inner-second' },
454
+ })
455
+ ),
456
+ ],
457
+ });
458
+ const result = await executeHooks({
459
+ registry,
460
+ input: preToolUseInput('Bash'),
461
+ matchQuery: 'Bash',
462
+ });
463
+ expect(result.updatedInput).toEqual({ cmd: 'inner-second' });
464
+ });
465
+ });
466
+
467
+ describe('updatedOutput handling', () => {
468
+ it('flows updatedOutput through the aggregated result', async () => {
469
+ const registry = new HookRegistry();
470
+ registry.register('PostToolUse', {
471
+ hooks: [
472
+ postToolHook(
473
+ async (): Promise<PostToolUseHookOutput> => ({
474
+ updatedOutput: 'redacted',
475
+ })
476
+ ),
477
+ ],
478
+ });
479
+ const result = await executeHooks({
480
+ registry,
481
+ input: postToolUseInput('Bash'),
482
+ matchQuery: 'Bash',
483
+ });
484
+ expect(result.updatedOutput).toBe('redacted');
485
+ });
486
+
487
+ it('last-writer-wins on updatedOutput follows registration order', async () => {
488
+ const registry = new HookRegistry();
489
+ registry.register('PostToolUse', {
490
+ hooks: [
491
+ postToolHook(
492
+ async (): Promise<PostToolUseHookOutput> => ({
493
+ updatedOutput: { tag: 'first' },
494
+ })
495
+ ),
496
+ ],
497
+ });
498
+ registry.register('PostToolUse', {
499
+ hooks: [
500
+ postToolHook(
501
+ async (): Promise<PostToolUseHookOutput> => ({
502
+ updatedOutput: { tag: 'second' },
503
+ })
504
+ ),
505
+ ],
506
+ });
507
+ const result = await executeHooks({
508
+ registry,
509
+ input: postToolUseInput('Bash'),
510
+ matchQuery: 'Bash',
511
+ });
512
+ expect(result.updatedOutput).toEqual({ tag: 'second' });
513
+ });
514
+
515
+ it('leaves updatedOutput undefined when no hook sets it', async () => {
516
+ const registry = new HookRegistry();
517
+ registry.register('PostToolUse', {
518
+ hooks: [postToolHook(async (): Promise<PostToolUseHookOutput> => ({}))],
519
+ });
520
+ const result = await executeHooks({
521
+ registry,
522
+ input: postToolUseInput('Bash'),
523
+ matchQuery: 'Bash',
524
+ });
525
+ expect(result.updatedOutput).toBeUndefined();
526
+ });
527
+ });
528
+
529
+ describe('preventContinuation', () => {
530
+ it('propagates preventContinuation and stopReason', async () => {
531
+ const registry = new HookRegistry();
532
+ registry.register('PostToolUse', {
533
+ hooks: [
534
+ postToolHook(
535
+ async (): Promise<PostToolUseHookOutput> => ({
536
+ preventContinuation: true,
537
+ stopReason: 'budget exhausted',
538
+ })
539
+ ),
540
+ ],
541
+ });
542
+ const result = await executeHooks({
543
+ registry,
544
+ input: postToolUseInput('Bash'),
545
+ matchQuery: 'Bash',
546
+ });
547
+ expect(result.preventContinuation).toBe(true);
548
+ expect(result.stopReason).toBe('budget exhausted');
549
+ });
550
+
551
+ it('keeps the first stopReason when multiple hooks set preventContinuation', async () => {
552
+ const registry = new HookRegistry();
553
+ registry.register('PostToolUse', {
554
+ hooks: [
555
+ postToolHook(
556
+ async (): Promise<PostToolUseHookOutput> => ({
557
+ preventContinuation: true,
558
+ stopReason: 'first writer',
559
+ })
560
+ ),
561
+ ],
562
+ });
563
+ registry.register('PostToolUse', {
564
+ hooks: [
565
+ postToolHook(
566
+ async (): Promise<PostToolUseHookOutput> => ({
567
+ preventContinuation: true,
568
+ stopReason: 'second writer',
569
+ })
570
+ ),
571
+ ],
572
+ });
573
+ const result = await executeHooks({
574
+ registry,
575
+ input: postToolUseInput('Bash'),
576
+ matchQuery: 'Bash',
577
+ });
578
+ expect(result.preventContinuation).toBe(true);
579
+ expect(result.stopReason).toBe('first writer');
580
+ });
581
+
582
+ it('sets preventContinuation even if only the flag, no reason, is present', async () => {
583
+ const registry = new HookRegistry();
584
+ registry.register('PostToolUse', {
585
+ hooks: [
586
+ postToolHook(
587
+ async (): Promise<PostToolUseHookOutput> => ({
588
+ preventContinuation: true,
589
+ })
590
+ ),
591
+ ],
592
+ });
593
+ const result = await executeHooks({
594
+ registry,
595
+ input: postToolUseInput('Bash'),
596
+ matchQuery: 'Bash',
597
+ });
598
+ expect(result.preventContinuation).toBe(true);
599
+ expect(result.stopReason).toBeUndefined();
600
+ });
601
+ });
602
+
603
+ describe('session scoping', () => {
604
+ it('runs session matchers only when sessionId is supplied', async () => {
605
+ const registry = new HookRegistry();
606
+ let globalFired = false;
607
+ let sessionFired = false;
608
+ registry.register('PreToolUse', {
609
+ hooks: [
610
+ preToolHook(async (): Promise<PreToolUseHookOutput> => {
611
+ globalFired = true;
612
+ return emptyPreOutput;
613
+ }),
614
+ ],
615
+ });
616
+ registry.registerSession('run-1', 'PreToolUse', {
617
+ hooks: [
618
+ preToolHook(async (): Promise<PreToolUseHookOutput> => {
619
+ sessionFired = true;
620
+ return emptyPreOutput;
621
+ }),
622
+ ],
623
+ });
624
+
625
+ await executeHooks({
626
+ registry,
627
+ input: preToolUseInput('Bash'),
628
+ matchQuery: 'Bash',
629
+ });
630
+ expect(globalFired).toBe(true);
631
+ expect(sessionFired).toBe(false);
632
+
633
+ globalFired = false;
634
+ await executeHooks({
635
+ registry,
636
+ input: preToolUseInput('Bash'),
637
+ matchQuery: 'Bash',
638
+ sessionId: 'run-1',
639
+ });
640
+ expect(globalFired).toBe(true);
641
+ expect(sessionFired).toBe(true);
642
+ });
643
+ });
644
+
645
+ describe('once: true self-removal', () => {
646
+ it('removes the matcher after a successful fire', async () => {
647
+ const registry = new HookRegistry();
648
+ let calls = 0;
649
+ const matcher: HookMatcher<'RunStart'> = {
650
+ once: true,
651
+ hooks: [
652
+ runStartHook(async (): Promise<RunStartHookOutput> => {
653
+ calls++;
654
+ return emptyRunStartOutput;
655
+ }),
656
+ ],
657
+ };
658
+ registry.register('RunStart', matcher);
659
+
660
+ await executeHooks({ registry, input: runStartInput() });
661
+ expect(calls).toBe(1);
662
+ expect(registry.getMatchers('RunStart')).toHaveLength(0);
663
+
664
+ await executeHooks({ registry, input: runStartInput() });
665
+ expect(calls).toBe(1);
666
+ });
667
+
668
+ it('removes the matcher even when every hook in it throws (at-most-once dispatch)', async () => {
669
+ const registry = new HookRegistry();
670
+ const matcher: HookMatcher<'RunStart'> = {
671
+ once: true,
672
+ hooks: [
673
+ runStartHook(async (): Promise<RunStartHookOutput> => {
674
+ throw new Error('hook failed');
675
+ }),
676
+ runStartHook(async (): Promise<RunStartHookOutput> => {
677
+ throw new Error('hook also failed');
678
+ }),
679
+ ],
680
+ };
681
+ registry.register('RunStart', matcher);
682
+
683
+ const result = await executeHooks({ registry, input: runStartInput() });
684
+ expect(result.errors).toHaveLength(2);
685
+ expect(registry.getMatchers('RunStart')).toHaveLength(0);
686
+ });
687
+
688
+ it('removes the matcher when at least one hook succeeds', async () => {
689
+ const registry = new HookRegistry();
690
+ const matcher: HookMatcher<'RunStart'> = {
691
+ once: true,
692
+ hooks: [
693
+ runStartHook(async (): Promise<RunStartHookOutput> => {
694
+ throw new Error('boom');
695
+ }),
696
+ noopRunStartHook,
697
+ ],
698
+ };
699
+ registry.register('RunStart', matcher);
700
+
701
+ const result = await executeHooks({ registry, input: runStartInput() });
702
+ expect(result.errors).toHaveLength(1);
703
+ expect(registry.getMatchers('RunStart')).toHaveLength(0);
704
+ });
705
+
706
+ it('removes once-matchers registered for a session from the session scope', async () => {
707
+ const registry = new HookRegistry();
708
+ const matcher: HookMatcher<'PreToolUse'> = {
709
+ once: true,
710
+ hooks: [noopPreHook],
711
+ };
712
+ registry.registerSession('run-1', 'PreToolUse', matcher);
713
+ await executeHooks({
714
+ registry,
715
+ input: preToolUseInput('Bash'),
716
+ matchQuery: 'Bash',
717
+ sessionId: 'run-1',
718
+ });
719
+ expect(registry.getMatchers('PreToolUse', 'run-1')).toHaveLength(0);
720
+ });
721
+
722
+ it('fires exactly once across concurrent executeHooks calls (atomic claim)', async () => {
723
+ const registry = new HookRegistry();
724
+ let calls = 0;
725
+ const matcher: HookMatcher<'RunStart'> = {
726
+ once: true,
727
+ hooks: [
728
+ runStartHook(async (): Promise<RunStartHookOutput> => {
729
+ calls++;
730
+ return emptyRunStartOutput;
731
+ }),
732
+ ],
733
+ };
734
+ registry.register('RunStart', matcher);
735
+
736
+ await Promise.all([
737
+ executeHooks({ registry, input: runStartInput() }),
738
+ executeHooks({ registry, input: runStartInput() }),
739
+ executeHooks({ registry, input: runStartInput() }),
740
+ ]);
741
+
742
+ expect(calls).toBe(1);
743
+ expect(registry.getMatchers('RunStart')).toHaveLength(0);
744
+ });
745
+
746
+ it('fires exactly once across concurrent dispatch even when hooks are slow', async () => {
747
+ const registry = new HookRegistry();
748
+ let calls = 0;
749
+ const matcher: HookMatcher<'RunStart'> = {
750
+ once: true,
751
+ hooks: [
752
+ runStartHook(async (): Promise<RunStartHookOutput> => {
753
+ calls++;
754
+ await new Promise<void>((resolve): void => {
755
+ setTimeout(resolve, 10);
756
+ });
757
+ return emptyRunStartOutput;
758
+ }),
759
+ ],
760
+ };
761
+ registry.register('RunStart', matcher);
762
+
763
+ await Promise.all(
764
+ Array.from({ length: 8 }, () =>
765
+ executeHooks({ registry, input: runStartInput() })
766
+ )
767
+ );
768
+
769
+ expect(calls).toBe(1);
770
+ expect(registry.getMatchers('RunStart')).toHaveLength(0);
771
+ });
772
+ });
773
+
774
+ describe('timeout enforcement', () => {
775
+ it('aborts hooks that exceed the matcher timeout', async () => {
776
+ const registry = new HookRegistry();
777
+ registry.register('RunStart', {
778
+ timeout: 20,
779
+ hooks: [
780
+ runStartHook(
781
+ (_input, signal): Promise<RunStartHookOutput> =>
782
+ new Promise<RunStartHookOutput>((_resolve, reject) => {
783
+ const id = setTimeout((): void => {
784
+ reject(new Error('hook should have been aborted'));
785
+ }, 500);
786
+ signal.addEventListener('abort', (): void => {
787
+ clearTimeout(id);
788
+ reject(new Error('aborted'));
789
+ });
790
+ })
791
+ ),
792
+ ],
793
+ });
794
+
795
+ const start = Date.now();
796
+ const result = await executeHooks({ registry, input: runStartInput() });
797
+ const elapsed = Date.now() - start;
798
+
799
+ expect(result.errors).toHaveLength(1);
800
+ expect(elapsed).toBeLessThan(400);
801
+ });
802
+
803
+ it('times out hooks that ignore the signal and surfaces an abort-shaped error', async () => {
804
+ const registry = new HookRegistry();
805
+ const pendingTimers: NodeJS.Timeout[] = [];
806
+ registry.register('RunStart', {
807
+ timeout: 15,
808
+ hooks: [
809
+ runStartHook(
810
+ (): Promise<RunStartHookOutput> =>
811
+ new Promise<RunStartHookOutput>((resolve): void => {
812
+ const id = setTimeout(
813
+ (): void => resolve(emptyRunStartOutput),
814
+ 500
815
+ );
816
+ pendingTimers.push(id);
817
+ })
818
+ ),
819
+ ],
820
+ });
821
+
822
+ const start = Date.now();
823
+ const result = await executeHooks({ registry, input: runStartInput() });
824
+ const elapsed = Date.now() - start;
825
+
826
+ for (const id of pendingTimers) {
827
+ clearTimeout(id);
828
+ }
829
+ expect(result.errors).toHaveLength(1);
830
+ expect(result.errors[0]?.toLowerCase()).toMatch(
831
+ /timeout|timed out|abort/
832
+ );
833
+ expect(elapsed).toBeLessThan(400);
834
+ });
835
+
836
+ it('honours the batch timeoutMs default when the matcher does not set its own', async () => {
837
+ const registry = new HookRegistry();
838
+ registry.register('RunStart', {
839
+ hooks: [
840
+ runStartHook(
841
+ (_input, signal): Promise<RunStartHookOutput> =>
842
+ new Promise<RunStartHookOutput>((_resolve, reject) => {
843
+ signal.addEventListener('abort', (): void =>
844
+ reject(new Error('aborted'))
845
+ );
846
+ })
847
+ ),
848
+ ],
849
+ });
850
+
851
+ const start = Date.now();
852
+ const result = await executeHooks({
853
+ registry,
854
+ input: runStartInput(),
855
+ timeoutMs: 25,
856
+ });
857
+ const elapsed = Date.now() - start;
858
+
859
+ expect(result.errors).toHaveLength(1);
860
+ expect(elapsed).toBeLessThan(400);
861
+ });
862
+ });
863
+
864
+ describe('error non-fatality', () => {
865
+ it('swallows synchronous throws into the errors array and keeps going', async () => {
866
+ const registry = new HookRegistry();
867
+ let otherRan = false;
868
+ registry.register('PreToolUse', {
869
+ hooks: [
870
+ preToolHook((): Promise<PreToolUseHookOutput> => {
871
+ throw new Error('sync boom');
872
+ }),
873
+ ],
874
+ });
875
+ registry.register('PreToolUse', {
876
+ hooks: [
877
+ preToolHook(async (): Promise<PreToolUseHookOutput> => {
878
+ otherRan = true;
879
+ return { additionalContext: 'still ran' };
880
+ }),
881
+ ],
882
+ });
883
+
884
+ const result = await executeHooks({
885
+ registry,
886
+ input: preToolUseInput('Bash'),
887
+ matchQuery: 'Bash',
888
+ });
889
+ expect(otherRan).toBe(true);
890
+ expect(result.additionalContexts).toEqual(['still ran']);
891
+ expect(result.errors).toHaveLength(1);
892
+ expect(result.errors[0]).toContain('sync boom');
893
+ });
894
+
895
+ it('swallows async rejections', async () => {
896
+ const registry = new HookRegistry();
897
+ registry.register('PreToolUse', {
898
+ hooks: [
899
+ preToolHook(
900
+ async (): Promise<PreToolUseHookOutput> =>
901
+ Promise.reject(new Error('async boom'))
902
+ ),
903
+ ],
904
+ });
905
+ const result = await executeHooks({
906
+ registry,
907
+ input: preToolUseInput('Bash'),
908
+ matchQuery: 'Bash',
909
+ });
910
+ expect(result.errors).toHaveLength(1);
911
+ expect(result.errors[0]).toContain('async boom');
912
+ });
913
+
914
+ it('excludes internal matcher errors from the errors array', async () => {
915
+ const registry = new HookRegistry();
916
+ registry.register('PreToolUse', {
917
+ internal: true,
918
+ hooks: [
919
+ preToolHook(
920
+ async (): Promise<PreToolUseHookOutput> =>
921
+ Promise.reject(new Error('internal failure'))
922
+ ),
923
+ ],
924
+ });
925
+ const result = await executeHooks({
926
+ registry,
927
+ input: preToolUseInput('Bash'),
928
+ matchQuery: 'Bash',
929
+ });
930
+ expect(result.errors).toHaveLength(0);
931
+ });
932
+
933
+ it('routes non-internal errors through an optional logger instead of console', async () => {
934
+ const registry = new HookRegistry();
935
+ registry.register('PreToolUse', {
936
+ hooks: [
937
+ preToolHook(
938
+ async (): Promise<PreToolUseHookOutput> =>
939
+ Promise.reject(new Error('oops'))
940
+ ),
941
+ ],
942
+ });
943
+ const warnings: string[] = [];
944
+ const fakeLogger = {
945
+ warn: (msg: string): void => {
946
+ warnings.push(msg);
947
+ },
948
+ } as unknown as import('winston').Logger;
949
+ await executeHooks({
950
+ registry,
951
+ input: preToolUseInput('Bash'),
952
+ matchQuery: 'Bash',
953
+ logger: fakeLogger,
954
+ });
955
+ expect(warnings).toHaveLength(1);
956
+ expect(warnings[0]).toContain('oops');
957
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
958
+ });
959
+
960
+ it('falls back to console.warn when no logger is supplied', async () => {
961
+ const registry = new HookRegistry();
962
+ registry.register('PreToolUse', {
963
+ hooks: [
964
+ preToolHook(
965
+ async (): Promise<PreToolUseHookOutput> =>
966
+ Promise.reject(new Error('fallback'))
967
+ ),
968
+ ],
969
+ });
970
+ await executeHooks({
971
+ registry,
972
+ input: preToolUseInput('Bash'),
973
+ matchQuery: 'Bash',
974
+ });
975
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
976
+ const firstCall = consoleWarnSpy.mock.calls[0] as unknown[];
977
+ expect(String(firstCall[0])).toContain('fallback');
978
+ });
979
+ });
980
+
981
+ describe('parent AbortSignal combination', () => {
982
+ it('aborts hooks when the caller signal fires', async () => {
983
+ const registry = new HookRegistry();
984
+ registry.register('RunStart', {
985
+ hooks: [
986
+ runStartHook(
987
+ (_input, signal): Promise<RunStartHookOutput> =>
988
+ new Promise<RunStartHookOutput>((_resolve, reject) => {
989
+ signal.addEventListener('abort', (): void =>
990
+ reject(new Error('aborted'))
991
+ );
992
+ })
993
+ ),
994
+ ],
995
+ });
996
+
997
+ const controller = new AbortController();
998
+ setTimeout((): void => controller.abort(), 20);
999
+
1000
+ const start = Date.now();
1001
+ const result = await executeHooks({
1002
+ registry,
1003
+ input: runStartInput(),
1004
+ signal: controller.signal,
1005
+ timeoutMs: 5_000,
1006
+ });
1007
+ const elapsed = Date.now() - start;
1008
+
1009
+ expect(result.errors).toHaveLength(1);
1010
+ expect(elapsed).toBeLessThan(400);
1011
+ });
1012
+ });
1013
+ });