Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts
5241 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';
8
import { URI } from '../../../../../base/common/uri.js';
9
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
10
import { NullLogService } from '../../../../../platform/log/common/log.js';
11
import { HookCommandResultKind, IHookCommandResult } from '../../common/hooks/hooksCommandTypes.js';
12
import { HooksExecutionService, IHooksExecutionProxy } from '../../common/hooks/hooksExecutionService.js';
13
import { HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js';
14
import { IOutputChannel, IOutputService } from '../../../../services/output/common/output.js';
15
16
function cmd(command: string): IHookCommand {
17
return { type: 'command', command, cwd: URI.file('/') };
18
}
19
20
function createMockOutputService(): IOutputService {
21
const mockChannel: Partial<IOutputChannel> = {
22
append: () => { },
23
};
24
return {
25
_serviceBrand: undefined,
26
getChannel: () => mockChannel as IOutputChannel,
27
} as unknown as IOutputService;
28
}
29
30
suite('HooksExecutionService', () => {
31
const store = ensureNoDisposablesAreLeakedInTestSuite();
32
33
let service: HooksExecutionService;
34
const sessionUri = URI.file('/test/session');
35
36
setup(() => {
37
service = store.add(new HooksExecutionService(new NullLogService(), createMockOutputService()));
38
});
39
40
suite('registerHooks', () => {
41
test('registers hooks for a session', () => {
42
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
43
store.add(service.registerHooks(sessionUri, hooks));
44
45
assert.strictEqual(service.getHooksForSession(sessionUri), hooks);
46
});
47
48
test('returns disposable that unregisters hooks', () => {
49
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
50
const disposable = service.registerHooks(sessionUri, hooks);
51
52
assert.strictEqual(service.getHooksForSession(sessionUri), hooks);
53
54
disposable.dispose();
55
56
assert.strictEqual(service.getHooksForSession(sessionUri), undefined);
57
});
58
59
test('different sessions have independent hooks', () => {
60
const session1 = URI.file('/test/session1');
61
const session2 = URI.file('/test/session2');
62
const hooks1 = { [HookType.PreToolUse]: [cmd('echo 1')] };
63
const hooks2 = { [HookType.PostToolUse]: [cmd('echo 2')] };
64
65
store.add(service.registerHooks(session1, hooks1));
66
store.add(service.registerHooks(session2, hooks2));
67
68
assert.strictEqual(service.getHooksForSession(session1), hooks1);
69
assert.strictEqual(service.getHooksForSession(session2), hooks2);
70
});
71
});
72
73
suite('getHooksForSession', () => {
74
test('returns undefined for unregistered session', () => {
75
assert.strictEqual(service.getHooksForSession(sessionUri), undefined);
76
});
77
});
78
79
suite('executeHook', () => {
80
test('returns empty array when no proxy set', async () => {
81
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
82
store.add(service.registerHooks(sessionUri, hooks));
83
84
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
85
assert.deepStrictEqual(results, []);
86
});
87
88
test('returns empty array when no hooks registered for session', async () => {
89
const proxy = createMockProxy();
90
service.setProxy(proxy);
91
92
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
93
assert.deepStrictEqual(results, []);
94
});
95
96
test('returns empty array when no hooks of requested type', async () => {
97
const proxy = createMockProxy();
98
service.setProxy(proxy);
99
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
100
store.add(service.registerHooks(sessionUri, hooks));
101
102
const results = await service.executeHook(HookType.PostToolUse, sessionUri);
103
assert.deepStrictEqual(results, []);
104
});
105
106
test('executes hook commands via proxy and returns semantic results', async () => {
107
const proxy = createMockProxy((cmd) => ({
108
kind: HookCommandResultKind.Success,
109
result: `executed: ${cmd.command}`
110
}));
111
service.setProxy(proxy);
112
113
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
114
store.add(service.registerHooks(sessionUri, hooks));
115
116
const results = await service.executeHook(HookType.PreToolUse, sessionUri, { input: 'test-input' });
117
118
assert.strictEqual(results.length, 1);
119
assert.strictEqual(results[0].resultKind, 'success');
120
assert.strictEqual(results[0].stopReason, undefined);
121
assert.strictEqual(results[0].output, 'executed: echo test');
122
});
123
124
test('executes multiple hook commands in order', async () => {
125
const executedCommands: string[] = [];
126
const proxy = createMockProxy((cmd) => {
127
executedCommands.push(cmd.command ?? '');
128
return { kind: HookCommandResultKind.Success, result: 'ok' };
129
});
130
service.setProxy(proxy);
131
132
const hooks = {
133
[HookType.PreToolUse]: [cmd('cmd1'), cmd('cmd2'), cmd('cmd3')]
134
};
135
store.add(service.registerHooks(sessionUri, hooks));
136
137
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
138
139
assert.strictEqual(results.length, 3);
140
assert.deepStrictEqual(executedCommands, ['cmd1', 'cmd2', 'cmd3']);
141
});
142
143
test('wraps proxy errors in error result', async () => {
144
const proxy = createMockProxy(() => {
145
throw new Error('proxy failed');
146
});
147
service.setProxy(proxy);
148
149
const hooks = { [HookType.PreToolUse]: [cmd('fail')] };
150
store.add(service.registerHooks(sessionUri, hooks));
151
152
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
153
154
assert.strictEqual(results.length, 1);
155
assert.strictEqual(results[0].resultKind, 'error');
156
assert.strictEqual(results[0].output, 'proxy failed');
157
// Error results still have default common fields
158
assert.strictEqual(results[0].stopReason, undefined);
159
});
160
161
test('passes cancellation token to proxy', async () => {
162
let receivedToken: CancellationToken | undefined;
163
const proxy = createMockProxy((_cmd, _input, token) => {
164
receivedToken = token;
165
return { kind: HookCommandResultKind.Success, result: 'ok' };
166
});
167
service.setProxy(proxy);
168
169
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
170
store.add(service.registerHooks(sessionUri, hooks));
171
172
const cts = store.add(new CancellationTokenSource());
173
await service.executeHook(HookType.PreToolUse, sessionUri, { token: cts.token });
174
175
assert.strictEqual(receivedToken, cts.token);
176
});
177
178
test('uses CancellationToken.None when no token provided', async () => {
179
let receivedToken: CancellationToken | undefined;
180
const proxy = createMockProxy((_cmd, _input, token) => {
181
receivedToken = token;
182
return { kind: HookCommandResultKind.Success, result: 'ok' };
183
});
184
service.setProxy(proxy);
185
186
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
187
store.add(service.registerHooks(sessionUri, hooks));
188
189
await service.executeHook(HookType.PreToolUse, sessionUri);
190
191
assert.strictEqual(receivedToken, CancellationToken.None);
192
});
193
194
test('extracts common fields from successful result', async () => {
195
const proxy = createMockProxy(() => ({
196
kind: HookCommandResultKind.Success,
197
result: {
198
stopReason: 'User requested stop',
199
systemMessage: 'Warning: hook triggered',
200
hookSpecificOutput: {
201
permissionDecision: 'allow'
202
}
203
}
204
}));
205
service.setProxy(proxy);
206
207
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
208
store.add(service.registerHooks(sessionUri, hooks));
209
210
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
211
212
assert.strictEqual(results.length, 1);
213
assert.strictEqual(results[0].resultKind, 'success');
214
assert.strictEqual(results[0].stopReason, 'User requested stop');
215
assert.strictEqual(results[0].warningMessage, 'Warning: hook triggered');
216
// Hook-specific fields are in output with wrapper
217
assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } });
218
});
219
220
test('uses defaults when no common fields present', async () => {
221
const proxy = createMockProxy(() => ({
222
kind: HookCommandResultKind.Success,
223
result: {
224
hookSpecificOutput: {
225
permissionDecision: 'allow'
226
}
227
}
228
}));
229
service.setProxy(proxy);
230
231
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
232
store.add(service.registerHooks(sessionUri, hooks));
233
234
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
235
236
assert.strictEqual(results.length, 1);
237
assert.strictEqual(results[0].stopReason, undefined);
238
assert.strictEqual(results[0].warningMessage, undefined);
239
assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } });
240
});
241
242
test('handles error results from command', async () => {
243
const proxy = createMockProxy(() => ({
244
kind: HookCommandResultKind.Error,
245
result: 'command failed with error'
246
}));
247
service.setProxy(proxy);
248
249
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
250
store.add(service.registerHooks(sessionUri, hooks));
251
252
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
253
254
assert.strictEqual(results.length, 1);
255
assert.strictEqual(results[0].resultKind, 'error');
256
assert.strictEqual(results[0].output, 'command failed with error');
257
// Defaults are still applied
258
assert.strictEqual(results[0].stopReason, undefined);
259
});
260
261
test('handles non-blocking error results from command', async () => {
262
const proxy = createMockProxy(() => ({
263
kind: HookCommandResultKind.NonBlockingError,
264
result: 'non-blocking warning message'
265
}));
266
service.setProxy(proxy);
267
268
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
269
store.add(service.registerHooks(sessionUri, hooks));
270
271
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
272
273
assert.strictEqual(results.length, 1);
274
assert.strictEqual(results[0].resultKind, 'warning');
275
assert.strictEqual(results[0].output, undefined);
276
assert.strictEqual(results[0].warningMessage, 'non-blocking warning message');
277
assert.strictEqual(results[0].stopReason, undefined);
278
});
279
280
test('handles non-blocking error with object result', async () => {
281
const proxy = createMockProxy(() => ({
282
kind: HookCommandResultKind.NonBlockingError,
283
result: { code: 'WARN_001', message: 'Something went wrong' }
284
}));
285
service.setProxy(proxy);
286
287
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
288
store.add(service.registerHooks(sessionUri, hooks));
289
290
const results = await service.executeHook(HookType.PreToolUse, sessionUri);
291
292
assert.strictEqual(results.length, 1);
293
assert.strictEqual(results[0].resultKind, 'warning');
294
assert.strictEqual(results[0].output, undefined);
295
assert.strictEqual(results[0].warningMessage, '{"code":"WARN_001","message":"Something went wrong"}');
296
assert.strictEqual(results[0].stopReason, undefined);
297
});
298
299
test('passes through hook-specific output fields for non-preToolUse hooks', async () => {
300
// Stop hooks return different fields (decision, reason) than preToolUse hooks
301
const proxy = createMockProxy(() => ({
302
kind: HookCommandResultKind.Success,
303
result: {
304
decision: 'block',
305
reason: 'Please run the tests'
306
}
307
}));
308
service.setProxy(proxy);
309
310
const hooks = { [HookType.Stop]: [cmd('check-stop')] };
311
store.add(service.registerHooks(sessionUri, hooks));
312
313
const results = await service.executeHook(HookType.Stop, sessionUri);
314
315
assert.strictEqual(results.length, 1);
316
assert.strictEqual(results[0].resultKind, 'success');
317
// Hook-specific fields should be in output, not undefined
318
assert.deepStrictEqual(results[0].output, {
319
decision: 'block',
320
reason: 'Please run the tests'
321
});
322
});
323
324
test('passes input to proxy', async () => {
325
let receivedInput: unknown;
326
const proxy = createMockProxy((_cmd, input) => {
327
receivedInput = input;
328
return { kind: HookCommandResultKind.Success, result: 'ok' };
329
});
330
service.setProxy(proxy);
331
332
const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };
333
store.add(service.registerHooks(sessionUri, hooks));
334
335
const testInput = { foo: 'bar', nested: { value: 123 } };
336
await service.executeHook(HookType.PreToolUse, sessionUri, { input: testInput });
337
338
// Input includes caller properties merged with common hook properties
339
assert.ok(typeof receivedInput === 'object' && receivedInput !== null);
340
const input = receivedInput as Record<string, unknown>;
341
assert.strictEqual(input['foo'], 'bar');
342
assert.deepStrictEqual(input['nested'], { value: 123 });
343
// Common properties are also present
344
assert.strictEqual(typeof input['timestamp'], 'string');
345
assert.strictEqual(input['hookEventName'], HookType.PreToolUse);
346
});
347
});
348
349
suite('executePreToolUseHook', () => {
350
test('returns allow result when hook allows', async () => {
351
const proxy = createMockProxy(() => ({
352
kind: HookCommandResultKind.Success,
353
result: {
354
hookSpecificOutput: {
355
permissionDecision: 'allow',
356
permissionDecisionReason: 'Tool is safe'
357
}
358
}
359
}));
360
service.setProxy(proxy);
361
362
const hooks = { [HookType.PreToolUse]: [cmd('hook')] };
363
store.add(service.registerHooks(sessionUri, hooks));
364
365
const result = await service.executePreToolUseHook(
366
sessionUri,
367
{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }
368
);
369
370
assert.ok(result);
371
assert.strictEqual(result.permissionDecision, 'allow');
372
assert.strictEqual(result.permissionDecisionReason, 'Tool is safe');
373
});
374
375
test('returns ask result when hook requires confirmation', async () => {
376
const proxy = createMockProxy(() => ({
377
kind: HookCommandResultKind.Success,
378
result: {
379
hookSpecificOutput: {
380
permissionDecision: 'ask',
381
permissionDecisionReason: 'Requires user approval'
382
}
383
}
384
}));
385
service.setProxy(proxy);
386
387
const hooks = { [HookType.PreToolUse]: [cmd('hook')] };
388
store.add(service.registerHooks(sessionUri, hooks));
389
390
const result = await service.executePreToolUseHook(
391
sessionUri,
392
{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }
393
);
394
395
assert.ok(result);
396
assert.strictEqual(result.permissionDecision, 'ask');
397
assert.strictEqual(result.permissionDecisionReason, 'Requires user approval');
398
});
399
400
test('deny takes priority over ask and allow', async () => {
401
let callCount = 0;
402
const proxy = createMockProxy(() => {
403
callCount++;
404
// First hook returns allow, second returns ask, third returns deny
405
if (callCount === 1) {
406
return {
407
kind: HookCommandResultKind.Success,
408
result: { hookSpecificOutput: { permissionDecision: 'allow' } }
409
};
410
} else if (callCount === 2) {
411
return {
412
kind: HookCommandResultKind.Success,
413
result: { hookSpecificOutput: { permissionDecision: 'ask' } }
414
};
415
} else {
416
return {
417
kind: HookCommandResultKind.Success,
418
result: { hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'Blocked' } }
419
};
420
}
421
});
422
service.setProxy(proxy);
423
424
const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2'), cmd('hook3')] };
425
store.add(service.registerHooks(sessionUri, hooks));
426
427
const result = await service.executePreToolUseHook(
428
sessionUri,
429
{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }
430
);
431
432
assert.ok(result);
433
assert.strictEqual(result.permissionDecision, 'deny');
434
assert.strictEqual(result.permissionDecisionReason, 'Blocked');
435
});
436
437
test('ask takes priority over allow', async () => {
438
let callCount = 0;
439
const proxy = createMockProxy(() => {
440
callCount++;
441
// First hook returns allow, second returns ask
442
if (callCount === 1) {
443
return {
444
kind: HookCommandResultKind.Success,
445
result: { hookSpecificOutput: { permissionDecision: 'allow' } }
446
};
447
} else {
448
return {
449
kind: HookCommandResultKind.Success,
450
result: { hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'Need confirmation' } }
451
};
452
}
453
});
454
service.setProxy(proxy);
455
456
const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] };
457
store.add(service.registerHooks(sessionUri, hooks));
458
459
const result = await service.executePreToolUseHook(
460
sessionUri,
461
{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }
462
);
463
464
assert.ok(result);
465
assert.strictEqual(result.permissionDecision, 'ask');
466
assert.strictEqual(result.permissionDecisionReason, 'Need confirmation');
467
});
468
469
test('ignores results with wrong hookEventName', async () => {
470
let callCount = 0;
471
const proxy = createMockProxy(() => {
472
callCount++;
473
if (callCount === 1) {
474
// First hook returns allow but with wrong hookEventName
475
return {
476
kind: HookCommandResultKind.Success,
477
result: {
478
hookSpecificOutput: {
479
hookEventName: 'PostToolUse', // Wrong hook type
480
permissionDecision: 'deny'
481
}
482
}
483
};
484
} else {
485
// Second hook returns allow with correct hookEventName
486
return {
487
kind: HookCommandResultKind.Success,
488
result: {
489
hookSpecificOutput: {
490
hookEventName: 'PreToolUse',
491
permissionDecision: 'allow'
492
}
493
}
494
};
495
}
496
});
497
service.setProxy(proxy);
498
499
const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] };
500
store.add(service.registerHooks(sessionUri, hooks));
501
502
const result = await service.executePreToolUseHook(
503
sessionUri,
504
{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }
505
);
506
507
// The deny with wrong hookEventName should be ignored
508
assert.ok(result);
509
assert.strictEqual(result.permissionDecision, 'allow');
510
});
511
512
test('allows results without hookEventName (optional field)', async () => {
513
const proxy = createMockProxy(() => ({
514
kind: HookCommandResultKind.Success,
515
result: {
516
hookSpecificOutput: {
517
// No hookEventName - should be accepted
518
permissionDecision: 'allow'
519
}
520
}
521
}));
522
service.setProxy(proxy);
523
524
const hooks = { [HookType.PreToolUse]: [cmd('hook')] };
525
store.add(service.registerHooks(sessionUri, hooks));
526
527
const result = await service.executePreToolUseHook(
528
sessionUri,
529
{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }
530
);
531
532
assert.ok(result);
533
assert.strictEqual(result.permissionDecision, 'allow');
534
});
535
536
test('returns updatedInput when hook provides it', async () => {
537
const proxy = createMockProxy(() => ({
538
kind: HookCommandResultKind.Success,
539
result: {
540
hookSpecificOutput: {
541
permissionDecision: 'allow',
542
updatedInput: { path: '/safe/path.ts' }
543
}
544
}
545
}));
546
service.setProxy(proxy);
547
548
const hooks = { [HookType.PreToolUse]: [cmd('hook')] };
549
store.add(service.registerHooks(sessionUri, hooks));
550
551
const result = await service.executePreToolUseHook(
552
sessionUri,
553
{ toolName: 'test-tool', toolInput: { path: '/original/path.ts' }, toolCallId: 'call-1' }
554
);
555
556
assert.ok(result);
557
assert.strictEqual(result.permissionDecision, 'allow');
558
assert.deepStrictEqual(result.updatedInput, { path: '/safe/path.ts' });
559
});
560
561
test('later hook updatedInput overrides earlier one', async () => {
562
let callCount = 0;
563
const proxy = createMockProxy(() => {
564
callCount++;
565
if (callCount === 1) {
566
return {
567
kind: HookCommandResultKind.Success,
568
result: { hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'first' } } }
569
};
570
}
571
return {
572
kind: HookCommandResultKind.Success,
573
result: { hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'second' } } }
574
};
575
});
576
service.setProxy(proxy);
577
578
const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] };
579
store.add(service.registerHooks(sessionUri, hooks));
580
581
const result = await service.executePreToolUseHook(
582
sessionUri,
583
{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }
584
);
585
586
assert.ok(result);
587
assert.deepStrictEqual(result.updatedInput, { value: 'second' });
588
});
589
590
test('returns result with updatedInput even without permission decision', async () => {
591
const proxy = createMockProxy(() => ({
592
kind: HookCommandResultKind.Success,
593
result: {
594
hookSpecificOutput: {
595
updatedInput: { modified: true }
596
}
597
}
598
}));
599
service.setProxy(proxy);
600
601
const hooks = { [HookType.PreToolUse]: [cmd('hook')] };
602
store.add(service.registerHooks(sessionUri, hooks));
603
604
const result = await service.executePreToolUseHook(
605
sessionUri,
606
{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }
607
);
608
609
assert.ok(result);
610
assert.deepStrictEqual(result.updatedInput, { modified: true });
611
assert.strictEqual(result.permissionDecision, undefined);
612
});
613
614
test('updatedInput combined with ask shows modified input to user', async () => {
615
const proxy = createMockProxy(() => ({
616
kind: HookCommandResultKind.Success,
617
result: {
618
hookSpecificOutput: {
619
permissionDecision: 'ask',
620
permissionDecisionReason: 'Modified input needs review',
621
updatedInput: { command: 'echo safe' }
622
}
623
}
624
}));
625
service.setProxy(proxy);
626
627
const hooks = { [HookType.PreToolUse]: [cmd('hook')] };
628
store.add(service.registerHooks(sessionUri, hooks));
629
630
const result = await service.executePreToolUseHook(
631
sessionUri,
632
{ toolName: 'test-tool', toolInput: { command: 'rm -rf /' }, toolCallId: 'call-1' }
633
);
634
635
assert.ok(result);
636
assert.strictEqual(result.permissionDecision, 'ask');
637
assert.strictEqual(result.permissionDecisionReason, 'Modified input needs review');
638
assert.deepStrictEqual(result.updatedInput, { command: 'echo safe' });
639
});
640
});
641
642
suite('executePostToolUseHook', () => {
643
test('returns undefined when no hooks configured', async () => {
644
const proxy = createMockProxy();
645
service.setProxy(proxy);
646
647
const hooks = { [HookType.PreToolUse]: [cmd('hook')] };
648
store.add(service.registerHooks(sessionUri, hooks));
649
650
const result = await service.executePostToolUseHook(
651
sessionUri,
652
{ toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' }
653
);
654
655
assert.strictEqual(result, undefined);
656
});
657
658
test('returns block decision when hook blocks', async () => {
659
const proxy = createMockProxy(() => ({
660
kind: HookCommandResultKind.Success,
661
result: {
662
decision: 'block',
663
reason: 'Lint errors found'
664
}
665
}));
666
service.setProxy(proxy);
667
668
const hooks = { [HookType.PostToolUse]: [cmd('hook')] };
669
store.add(service.registerHooks(sessionUri, hooks));
670
671
const result = await service.executePostToolUseHook(
672
sessionUri,
673
{ toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' }
674
);
675
676
assert.ok(result);
677
assert.strictEqual(result.decision, 'block');
678
assert.strictEqual(result.reason, 'Lint errors found');
679
});
680
681
test('returns additionalContext from hookSpecificOutput', async () => {
682
const proxy = createMockProxy(() => ({
683
kind: HookCommandResultKind.Success,
684
result: {
685
hookSpecificOutput: {
686
hookEventName: 'PostToolUse',
687
additionalContext: 'File was modified successfully'
688
}
689
}
690
}));
691
service.setProxy(proxy);
692
693
const hooks = { [HookType.PostToolUse]: [cmd('hook')] };
694
store.add(service.registerHooks(sessionUri, hooks));
695
696
const result = await service.executePostToolUseHook(
697
sessionUri,
698
{ toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' }
699
);
700
701
assert.ok(result);
702
assert.deepStrictEqual(result.additionalContext, ['File was modified successfully']);
703
assert.strictEqual(result.decision, undefined);
704
});
705
706
test('block takes priority and collects all additionalContext', async () => {
707
let callCount = 0;
708
const proxy = createMockProxy(() => {
709
callCount++;
710
if (callCount === 1) {
711
return {
712
kind: HookCommandResultKind.Success,
713
result: {
714
decision: 'block',
715
reason: 'Tests failed'
716
}
717
};
718
} else {
719
return {
720
kind: HookCommandResultKind.Success,
721
result: {
722
hookSpecificOutput: {
723
additionalContext: 'Extra context from second hook'
724
}
725
}
726
};
727
}
728
});
729
service.setProxy(proxy);
730
731
const hooks = { [HookType.PostToolUse]: [cmd('hook1'), cmd('hook2')] };
732
store.add(service.registerHooks(sessionUri, hooks));
733
734
const result = await service.executePostToolUseHook(
735
sessionUri,
736
{ toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' }
737
);
738
739
assert.ok(result);
740
assert.strictEqual(result.decision, 'block');
741
assert.strictEqual(result.reason, 'Tests failed');
742
assert.deepStrictEqual(result.additionalContext, ['Extra context from second hook']);
743
});
744
745
test('ignores results with wrong hookEventName', async () => {
746
let callCount = 0;
747
const proxy = createMockProxy(() => {
748
callCount++;
749
if (callCount === 1) {
750
return {
751
kind: HookCommandResultKind.Success,
752
result: {
753
hookSpecificOutput: {
754
hookEventName: 'PreToolUse',
755
additionalContext: 'Should be ignored'
756
}
757
}
758
};
759
} else {
760
return {
761
kind: HookCommandResultKind.Success,
762
result: {
763
hookSpecificOutput: {
764
hookEventName: 'PostToolUse',
765
additionalContext: 'Correct context'
766
}
767
}
768
};
769
}
770
});
771
service.setProxy(proxy);
772
773
const hooks = { [HookType.PostToolUse]: [cmd('hook1'), cmd('hook2')] };
774
store.add(service.registerHooks(sessionUri, hooks));
775
776
const result = await service.executePostToolUseHook(
777
sessionUri,
778
{ toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' }
779
);
780
781
assert.ok(result);
782
assert.deepStrictEqual(result.additionalContext, ['Correct context']);
783
});
784
785
test('passes tool response text as string to external command', async () => {
786
let receivedInput: unknown;
787
const proxy = createMockProxy((_cmd, input) => {
788
receivedInput = input;
789
return { kind: HookCommandResultKind.Success, result: {} };
790
});
791
service.setProxy(proxy);
792
793
const hooks = { [HookType.PostToolUse]: [cmd('hook')] };
794
store.add(service.registerHooks(sessionUri, hooks));
795
796
await service.executePostToolUseHook(
797
sessionUri,
798
{ toolName: 'my-tool', toolInput: { arg: 'val' }, getToolResponseText: () => 'file contents here', toolCallId: 'call-42' }
799
);
800
801
assert.ok(typeof receivedInput === 'object' && receivedInput !== null);
802
const input = receivedInput as Record<string, unknown>;
803
assert.strictEqual(input['tool_name'], 'my-tool');
804
assert.deepStrictEqual(input['tool_input'], { arg: 'val' });
805
assert.strictEqual(input['tool_response'], 'file contents here');
806
assert.strictEqual(input['tool_use_id'], 'call-42');
807
assert.strictEqual(input['hookEventName'], HookType.PostToolUse);
808
});
809
810
test('does not call getter when no PostToolUse hooks registered', async () => {
811
const proxy = createMockProxy();
812
service.setProxy(proxy);
813
814
// Register hooks only for PreToolUse, not PostToolUse
815
const hooks = { [HookType.PreToolUse]: [cmd('hook')] };
816
store.add(service.registerHooks(sessionUri, hooks));
817
818
let getterCalled = false;
819
const result = await service.executePostToolUseHook(
820
sessionUri,
821
{
822
toolName: 'test-tool',
823
toolInput: {},
824
getToolResponseText: () => { getterCalled = true; return ''; },
825
toolCallId: 'call-1'
826
}
827
);
828
829
assert.strictEqual(result, undefined);
830
assert.strictEqual(getterCalled, false);
831
});
832
});
833
834
suite('preToolUse smoke tests — input → output', () => {
835
test('single hook: allow', async () => {
836
const proxy = createMockProxy(() => ({
837
kind: HookCommandResultKind.Success,
838
result: {
839
hookSpecificOutput: {
840
permissionDecision: 'allow',
841
permissionDecisionReason: 'Trusted tool',
842
}
843
}
844
}));
845
service.setProxy(proxy);
846
847
const hooks = { [HookType.PreToolUse]: [cmd('lint-check')] };
848
store.add(service.registerHooks(sessionUri, hooks));
849
850
const input = { toolName: 'readFile', toolInput: { path: '/src/index.ts' }, toolCallId: 'call-1' };
851
const result = await service.executePreToolUseHook(sessionUri, input);
852
853
assert.deepStrictEqual(
854
JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason, additionalContext: result?.additionalContext }),
855
JSON.stringify({ permissionDecision: 'allow', permissionDecisionReason: 'Trusted tool', additionalContext: undefined })
856
);
857
});
858
859
test('single hook: deny', async () => {
860
const proxy = createMockProxy(() => ({
861
kind: HookCommandResultKind.Success,
862
result: {
863
hookSpecificOutput: {
864
permissionDecision: 'deny',
865
permissionDecisionReason: 'Path is outside workspace',
866
}
867
}
868
}));
869
service.setProxy(proxy);
870
871
const hooks = { [HookType.PreToolUse]: [cmd('path-guard')] };
872
store.add(service.registerHooks(sessionUri, hooks));
873
874
const input = { toolName: 'writeFile', toolInput: { path: '/etc/passwd' }, toolCallId: 'call-2' };
875
const result = await service.executePreToolUseHook(sessionUri, input);
876
877
assert.deepStrictEqual(
878
JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }),
879
JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: 'Path is outside workspace' })
880
);
881
});
882
883
test('multiple hooks: deny wins over allow and ask', async () => {
884
// Three hooks return allow, ask, deny (in that order).
885
// deny must win regardless of ordering.
886
let callCount = 0;
887
const decisions = ['allow', 'ask', 'deny'] as const;
888
const proxy = createMockProxy(() => {
889
const decision = decisions[callCount++];
890
return {
891
kind: HookCommandResultKind.Success,
892
result: { hookSpecificOutput: { permissionDecision: decision, permissionDecisionReason: `hook-${callCount}` } }
893
};
894
});
895
service.setProxy(proxy);
896
897
const hooks = { [HookType.PreToolUse]: [cmd('h1'), cmd('h2'), cmd('h3')] };
898
store.add(service.registerHooks(sessionUri, hooks));
899
900
const result = await service.executePreToolUseHook(
901
sessionUri,
902
{ toolName: 'runCommand', toolInput: { cmd: 'rm -rf /' }, toolCallId: 'call-3' }
903
);
904
905
assert.deepStrictEqual(
906
JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }),
907
JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: 'hook-3' })
908
);
909
});
910
911
test('multiple hooks: ask wins over allow', async () => {
912
let callCount = 0;
913
const decisions = ['allow', 'ask'] as const;
914
const proxy = createMockProxy(() => {
915
const decision = decisions[callCount++];
916
return {
917
kind: HookCommandResultKind.Success,
918
result: { hookSpecificOutput: { permissionDecision: decision, permissionDecisionReason: `reason-${decision}` } }
919
};
920
});
921
service.setProxy(proxy);
922
923
const hooks = { [HookType.PreToolUse]: [cmd('h1'), cmd('h2')] };
924
store.add(service.registerHooks(sessionUri, hooks));
925
926
const result = await service.executePreToolUseHook(
927
sessionUri,
928
{ toolName: 'exec', toolInput: {}, toolCallId: 'call-4' }
929
);
930
931
assert.deepStrictEqual(
932
JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }),
933
JSON.stringify({ permissionDecision: 'ask', permissionDecisionReason: 'reason-ask' })
934
);
935
});
936
});
937
938
suite('postToolUse smoke tests — input → output', () => {
939
test('single hook: block', async () => {
940
const proxy = createMockProxy(() => ({
941
kind: HookCommandResultKind.Success,
942
result: {
943
decision: 'block',
944
reason: 'Lint errors found'
945
}
946
}));
947
service.setProxy(proxy);
948
949
const hooks = { [HookType.PostToolUse]: [cmd('lint')] };
950
store.add(service.registerHooks(sessionUri, hooks));
951
952
const input = { toolName: 'writeFile', toolInput: { path: 'foo.ts' }, getToolResponseText: () => 'wrote 42 bytes', toolCallId: 'call-5' };
953
const result = await service.executePostToolUseHook(sessionUri, input);
954
955
assert.deepStrictEqual(
956
JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }),
957
JSON.stringify({ decision: 'block', reason: 'Lint errors found', additionalContext: undefined })
958
);
959
});
960
961
test('single hook: additionalContext only', async () => {
962
const proxy = createMockProxy(() => ({
963
kind: HookCommandResultKind.Success,
964
result: {
965
hookSpecificOutput: {
966
additionalContext: 'Tests still pass after this edit'
967
}
968
}
969
}));
970
service.setProxy(proxy);
971
972
const hooks = { [HookType.PostToolUse]: [cmd('test-runner')] };
973
store.add(service.registerHooks(sessionUri, hooks));
974
975
const input = { toolName: 'editFile', toolInput: {}, getToolResponseText: () => 'ok', toolCallId: 'call-6' };
976
const result = await service.executePostToolUseHook(sessionUri, input);
977
978
assert.deepStrictEqual(
979
JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }),
980
JSON.stringify({ decision: undefined, reason: undefined, additionalContext: ['Tests still pass after this edit'] })
981
);
982
});
983
984
test('multiple hooks: block wins and all hooks run', async () => {
985
let callCount = 0;
986
const proxy = createMockProxy(() => {
987
callCount++;
988
if (callCount === 1) {
989
return { kind: HookCommandResultKind.Success, result: { decision: 'block', reason: 'Tests failed' } };
990
}
991
return { kind: HookCommandResultKind.Success, result: { hookSpecificOutput: { additionalContext: 'context from second hook' } } };
992
});
993
service.setProxy(proxy);
994
995
const hooks = { [HookType.PostToolUse]: [cmd('test'), cmd('lint')] };
996
store.add(service.registerHooks(sessionUri, hooks));
997
998
const result = await service.executePostToolUseHook(
999
sessionUri,
1000
{ toolName: 'writeFile', toolInput: {}, getToolResponseText: () => 'data', toolCallId: 'call-7' }
1001
);
1002
1003
assert.deepStrictEqual(
1004
JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }),
1005
JSON.stringify({ decision: 'block', reason: 'Tests failed', additionalContext: ['context from second hook'] })
1006
);
1007
});
1008
1009
test('no hooks registered → undefined (getter never called)', async () => {
1010
const proxy = createMockProxy();
1011
service.setProxy(proxy);
1012
1013
// Register PreToolUse only — no PostToolUse
1014
store.add(service.registerHooks(sessionUri, { [HookType.PreToolUse]: [cmd('h')] }));
1015
1016
let getterCalled = false;
1017
const result = await service.executePostToolUseHook(
1018
sessionUri,
1019
{ toolName: 't', toolInput: {}, getToolResponseText: () => { getterCalled = true; return ''; }, toolCallId: 'c' }
1020
);
1021
1022
assert.strictEqual(result, undefined);
1023
assert.strictEqual(getterCalled, false);
1024
});
1025
});
1026
1027
function createMockProxy(handler?: (cmd: IHookCommand, input: unknown, token: CancellationToken) => IHookCommandResult): IHooksExecutionProxy {
1028
return {
1029
runHookCommand: async (hookCommand, input, token) => {
1030
if (handler) {
1031
return handler(hookCommand, input, token);
1032
}
1033
return { kind: HookCommandResultKind.Success, result: 'mock result' };
1034
}
1035
};
1036
}
1037
});
1038
1039