Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chat/test/vscode-node/chatHookService.spec.ts
13405 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 { beforeEach, describe, expect, it } from 'vitest';
7
import type { ChatHookCommand, ChatHookResult, ChatHookResultKind, ChatRequestHooks, Uri } from 'vscode';
8
import { IPostToolUseHookResult, IPreToolUseHookResult } from '../../../../platform/chat/common/chatHookService';
9
import { HookCommandResultKind, IHookCommandResult } from '../../../../platform/chat/common/hookExecutor';
10
import { IToolValidationResult } from '../../../tools/common/toolsService';
11
import { isCompatibleHookEventName } from '../../vscode-node/chatHookService';
12
13
function cmd(command: string, cwd?: Uri): ChatHookCommand {
14
return { command, cwd } as ChatHookCommand;
15
}
16
17
/**
18
* A testable version of ChatHookService.executeHook logic,
19
* reimplemented here to stay within the layering constraints.
20
* This mirrors the real implementation's result conversion and iteration logic.
21
*/
22
class TestableExecuteHookService {
23
public executorCalls: Array<{ hookCommand: ChatHookCommand; input: unknown }> = [];
24
public executorHandler: (hookCommand: ChatHookCommand, input: unknown) => IHookCommandResult = () => ({ kind: HookCommandResultKind.Success, result: '' });
25
public transcriptPath: Uri | undefined;
26
public flushedSessionIds: string[] = [];
27
28
async executeHook(hookType: string, hooks: ChatRequestHooks | undefined, input: unknown, sessionId?: string): Promise<ChatHookResult[]> {
29
if (!hooks) {
30
return [];
31
}
32
33
const hookCommands = hooks[hookType];
34
if (!hookCommands || hookCommands.length === 0) {
35
return [];
36
}
37
38
if (sessionId) {
39
this.flushedSessionIds.push(sessionId);
40
}
41
42
const commonInput = {
43
timestamp: new Date().toISOString(),
44
hook_event_name: hookType,
45
...(sessionId ? { session_id: sessionId } : undefined),
46
...(this.transcriptPath ? { transcript_path: this.transcriptPath } : undefined),
47
};
48
const fullInput = (typeof input === 'object' && input !== null)
49
? { ...commonInput, ...input }
50
: commonInput;
51
52
const results: ChatHookResult[] = [];
53
54
for (const hookCommand of hookCommands) {
55
try {
56
const commandInput = hookCommand.cwd
57
? { ...fullInput, cwd: hookCommand.cwd }
58
: fullInput;
59
this.executorCalls.push({ hookCommand, input: commandInput });
60
const commandResult = this.executorHandler(hookCommand, commandInput);
61
const result = this._toHookResult(hookType, commandResult);
62
results.push(result);
63
64
if (result.stopReason !== undefined) {
65
break;
66
}
67
} catch (err) {
68
results.push({
69
resultKind: 'warning',
70
output: undefined,
71
warningMessage: err instanceof Error ? err.message : String(err),
72
});
73
}
74
}
75
76
return results;
77
}
78
79
private _toHookResult(hookType: string, commandResult: IHookCommandResult): ChatHookResult {
80
switch (commandResult.kind) {
81
case HookCommandResultKind.Error: {
82
const message = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);
83
return { resultKind: 'error', output: message };
84
}
85
case HookCommandResultKind.NonBlockingError: {
86
const errorMessage = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);
87
return { resultKind: 'warning', output: undefined, warningMessage: errorMessage };
88
}
89
case HookCommandResultKind.Success: {
90
if (typeof commandResult.result !== 'object') {
91
return { resultKind: 'success', output: commandResult.result };
92
}
93
94
const resultObj = commandResult.result as Record<string, unknown>;
95
const stopReason = typeof resultObj['stopReason'] === 'string' ? resultObj['stopReason'] : undefined;
96
const continueFlag = resultObj['continue'];
97
const systemMessage = typeof resultObj['systemMessage'] === 'string' ? resultObj['systemMessage'] : undefined;
98
99
let effectiveStopReason = stopReason;
100
if (continueFlag === false && !effectiveStopReason) {
101
effectiveStopReason = '';
102
}
103
104
// Check hookEventName at top level — if present and mismatched, skip this result
105
const topLevelHookEventName = resultObj['hookEventName'];
106
if (typeof topLevelHookEventName === 'string' && !isCompatibleHookEventName(topLevelHookEventName, hookType)) {
107
return { resultKind: 'success', output: undefined };
108
}
109
110
// Check hookEventName inside hookSpecificOutput — if mismatched, strip hookSpecificOutput but keep the rest
111
let stripHookSpecificOutput = false;
112
const hookSpecificOutput = resultObj['hookSpecificOutput'];
113
if (typeof hookSpecificOutput === 'object' && hookSpecificOutput !== null) {
114
const nestedHookEventName = (hookSpecificOutput as Record<string, unknown>)['hookEventName'];
115
if (typeof nestedHookEventName === 'string' && !isCompatibleHookEventName(nestedHookEventName, hookType)) {
116
stripHookSpecificOutput = true;
117
}
118
}
119
120
const commonFields = new Set(['continue', 'stopReason', 'systemMessage']);
121
if (stripHookSpecificOutput) {
122
commonFields.add('hookSpecificOutput');
123
}
124
const hookOutput: Record<string, unknown> = {};
125
for (const [key, value] of Object.entries(resultObj)) {
126
if (value !== undefined && !commonFields.has(key)) {
127
hookOutput[key] = value;
128
}
129
}
130
131
return {
132
resultKind: 'success',
133
stopReason: effectiveStopReason,
134
warningMessage: systemMessage,
135
output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined,
136
};
137
}
138
default:
139
return { resultKind: 'warning', warningMessage: `Unexpected hook command result kind: ${(commandResult as IHookCommandResult).kind}`, output: undefined };
140
}
141
}
142
}
143
144
describe('ChatHookService.executeHook', () => {
145
let service: TestableExecuteHookService;
146
147
beforeEach(() => {
148
service = new TestableExecuteHookService();
149
});
150
151
it('returns empty array when hooks is undefined', async () => {
152
const results = await service.executeHook('Stop', undefined, {});
153
expect(results).toEqual([]);
154
});
155
156
it('returns empty array when no commands for hook type', async () => {
157
const results = await service.executeHook('Stop', { PreToolUse: [cmd('echo test')] }, {});
158
expect(results).toEqual([]);
159
});
160
161
it('executes hook and returns success result', async () => {
162
service.executorHandler = () => ({ kind: HookCommandResultKind.Success, result: { decision: 'block', reason: 'test' } });
163
const results = await service.executeHook('Stop', { Stop: [cmd('echo test')] }, {});
164
165
expect(results).toHaveLength(1);
166
expect(results[0].resultKind).toBe('success');
167
expect(results[0].output).toEqual({ decision: 'block', reason: 'test' });
168
});
169
170
it('converts exit code 2 to error result with message in output', async () => {
171
service.executorHandler = () => ({ kind: HookCommandResultKind.Error, result: 'fatal error' });
172
const results = await service.executeHook('Stop', { Stop: [cmd('fail')] }, {});
173
174
expect(results).toHaveLength(1);
175
expect(results[0].resultKind).toBe('error');
176
expect(results[0].output).toBe('fatal error');
177
expect(results[0].stopReason).toBeUndefined();
178
});
179
180
it('does not stop processing on error results (callers decide)', async () => {
181
let callCount = 0;
182
service.executorHandler = () => {
183
callCount++;
184
if (callCount === 1) {
185
return { kind: HookCommandResultKind.Error, result: 'error from first' };
186
}
187
return { kind: HookCommandResultKind.Success, result: 'second ok' };
188
};
189
const results = await service.executeHook('Stop', { Stop: [cmd('first'), cmd('second')] }, {});
190
191
expect(results).toHaveLength(2);
192
expect(callCount).toBe(2);
193
expect(results[0].resultKind).toBe('error');
194
expect(results[1].resultKind).toBe('success');
195
});
196
197
it('converts non-blocking error to warning', async () => {
198
service.executorHandler = () => ({ kind: HookCommandResultKind.NonBlockingError, result: 'warning msg' });
199
const results = await service.executeHook('Stop', { Stop: [cmd('warn')] }, {});
200
201
expect(results).toHaveLength(1);
202
expect(results[0].resultKind).toBe('warning');
203
expect(results[0].warningMessage).toBe('warning msg');
204
expect(results[0].stopReason).toBeUndefined();
205
});
206
207
it('stops processing after first hook with stopReason', async () => {
208
let callCount = 0;
209
service.executorHandler = () => {
210
callCount++;
211
if (callCount === 1) {
212
return { kind: HookCommandResultKind.Success, result: { stopReason: 'stop here' } };
213
}
214
return { kind: HookCommandResultKind.Success, result: 'second' };
215
};
216
const results = await service.executeHook('Stop', { Stop: [cmd('first'), cmd('second')] }, {});
217
218
expect(results).toHaveLength(1);
219
expect(callCount).toBe(1);
220
expect(results[0].stopReason).toBe('stop here');
221
});
222
223
it('stops processing on empty string stopReason (continue: false)', async () => {
224
let callCount = 0;
225
service.executorHandler = () => {
226
callCount++;
227
return { kind: HookCommandResultKind.Success, result: { continue: false } };
228
};
229
const results = await service.executeHook('Stop', { Stop: [cmd('first'), cmd('second')] }, {});
230
231
expect(results).toHaveLength(1);
232
expect(callCount).toBe(1);
233
expect(results[0].stopReason).toBe('');
234
});
235
236
it('catches executor errors and returns warning', async () => {
237
service.executorHandler = () => { throw new Error('spawn failed'); };
238
const results = await service.executeHook('Stop', { Stop: [cmd('fail')] }, {});
239
240
expect(results).toHaveLength(1);
241
expect(results[0].resultKind).toBe('warning');
242
expect(results[0].warningMessage).toBe('spawn failed');
243
});
244
245
it('includes sessionId in common input', async () => {
246
service.executorHandler = () => ({ kind: HookCommandResultKind.Success, result: '' });
247
await service.executeHook('Stop', { Stop: [cmd('test')] }, {}, 'session-123');
248
249
expect(service.executorCalls[0].input).toMatchObject({ session_id: 'session-123', hook_event_name: 'Stop' });
250
});
251
252
it('includes cwd from hook command in input', async () => {
253
const cwdUri = { scheme: 'file', path: '/my/project' } as Uri;
254
service.executorHandler = () => ({ kind: HookCommandResultKind.Success, result: '' });
255
await service.executeHook('Stop', { Stop: [cmd('test', cwdUri)] }, {});
256
257
expect(service.executorCalls[0].input).toMatchObject({ cwd: cwdUri });
258
});
259
260
it('merges caller input with common input', async () => {
261
service.executorHandler = () => ({ kind: HookCommandResultKind.Success, result: '' });
262
await service.executeHook('PreToolUse', { PreToolUse: [cmd('test')] }, { tool_name: 'myTool', tool_input: { x: 1 } });
263
264
const input = service.executorCalls[0].input as Record<string, unknown>;
265
expect(input['tool_name']).toBe('myTool');
266
expect(input['tool_input']).toEqual({ x: 1 });
267
expect(input['hook_event_name']).toBe('PreToolUse');
268
expect(typeof input['timestamp']).toBe('string');
269
});
270
271
it('includes transcript_path when configured', async () => {
272
const transcriptUri = { scheme: 'file', path: '/tmp/transcript.jsonl' } as Uri;
273
service.transcriptPath = transcriptUri;
274
service.executorHandler = () => ({ kind: HookCommandResultKind.Success, result: '' });
275
await service.executeHook('Stop', { Stop: [cmd('test')] }, {}, 'session-1');
276
277
expect(service.flushedSessionIds).toContain('session-1');
278
expect(service.executorCalls[0].input).toMatchObject({ transcript_path: transcriptUri });
279
});
280
281
it('extracts systemMessage as warningMessage', async () => {
282
service.executorHandler = () => ({
283
kind: HookCommandResultKind.Success,
284
result: { systemMessage: 'be careful' },
285
});
286
const results = await service.executeHook('Stop', { Stop: [cmd('test')] }, {});
287
288
expect(results[0].warningMessage).toBe('be careful');
289
});
290
291
it('separates common fields from hook-specific output', async () => {
292
service.executorHandler = () => ({
293
kind: HookCommandResultKind.Success,
294
result: { continue: true, systemMessage: 'msg', decision: 'block', reason: 'test' },
295
});
296
const results = await service.executeHook('Stop', { Stop: [cmd('test')] }, {});
297
298
expect(results[0].output).toEqual({ decision: 'block', reason: 'test' });
299
expect(results[0].warningMessage).toBe('msg');
300
expect(results[0].stopReason).toBeUndefined();
301
});
302
303
it('executes multiple hooks in sequence', async () => {
304
const commands: string[] = [];
305
service.executorHandler = (hookCmd) => {
306
commands.push(hookCmd.command);
307
return { kind: HookCommandResultKind.Success, result: '' };
308
};
309
const results = await service.executeHook('Stop', { Stop: [cmd('a'), cmd('b'), cmd('c')] }, {});
310
311
expect(results).toHaveLength(3);
312
expect(commands).toEqual(['a', 'b', 'c']);
313
});
314
315
it('filters out results with mismatched top-level hookEventName', async () => {
316
service.executorHandler = () => ({
317
kind: HookCommandResultKind.Success,
318
result: { hookEventName: 'PreToolUse', decision: 'block', reason: 'wrong event' },
319
});
320
const results = await service.executeHook('Stop', { Stop: [cmd('test')] }, {});
321
322
expect(results).toHaveLength(1);
323
expect(results[0].resultKind).toBe('success');
324
expect(results[0].output).toBeUndefined();
325
});
326
327
it('strips hookSpecificOutput with mismatched nested hookEventName but keeps other fields', async () => {
328
service.executorHandler = () => ({
329
kind: HookCommandResultKind.Success,
330
result: { hookSpecificOutput: { hookEventName: 'PostToolUse', permissionDecision: 'deny' }, decision: 'block', reason: 'kept' },
331
});
332
const results = await service.executeHook('PreToolUse', { PreToolUse: [cmd('test')] }, {});
333
334
expect(results).toHaveLength(1);
335
expect(results[0].resultKind).toBe('success');
336
expect(results[0].output).toEqual({ decision: 'block', reason: 'kept' });
337
});
338
339
it('discards entire output when hookSpecificOutput is the only non-common field and hookEventName mismatches', async () => {
340
service.executorHandler = () => ({
341
kind: HookCommandResultKind.Success,
342
result: { hookSpecificOutput: { hookEventName: 'PostToolUse', permissionDecision: 'deny' } },
343
});
344
const results = await service.executeHook('PreToolUse', { PreToolUse: [cmd('test')] }, {});
345
346
expect(results).toHaveLength(1);
347
expect(results[0].resultKind).toBe('success');
348
expect(results[0].output).toBeUndefined();
349
});
350
351
it('allows results with matching hookEventName', async () => {
352
service.executorHandler = () => ({
353
kind: HookCommandResultKind.Success,
354
result: { hookEventName: 'Stop', decision: 'block', reason: 'correct event' },
355
});
356
const results = await service.executeHook('Stop', { Stop: [cmd('test')] }, {});
357
358
expect(results).toHaveLength(1);
359
expect(results[0].output).toEqual({ hookEventName: 'Stop', decision: 'block', reason: 'correct event' });
360
});
361
362
it('allows results without hookEventName', async () => {
363
service.executorHandler = () => ({
364
kind: HookCommandResultKind.Success,
365
result: { decision: 'block', reason: 'no event name' },
366
});
367
const results = await service.executeHook('Stop', { Stop: [cmd('test')] }, {});
368
369
expect(results).toHaveLength(1);
370
expect(results[0].output).toEqual({ decision: 'block', reason: 'no event name' });
371
});
372
373
it('treats Stop hookEventName as compatible with SubagentStop hook type', async () => {
374
service.executorHandler = () => ({
375
kind: HookCommandResultKind.Success,
376
result: { hookSpecificOutput: { hookEventName: 'Stop', decision: 'block', reason: 'tests failing' } },
377
});
378
const results = await service.executeHook('SubagentStop', { SubagentStop: [cmd('test')] }, {});
379
380
expect(results).toHaveLength(1);
381
expect(results[0].resultKind).toBe('success');
382
expect(results[0].output).toEqual({ hookSpecificOutput: { hookEventName: 'Stop', decision: 'block', reason: 'tests failing' } });
383
});
384
385
it('does NOT treat SubagentStop hookEventName as compatible with Stop hook type', async () => {
386
service.executorHandler = () => ({
387
kind: HookCommandResultKind.Success,
388
result: { hookSpecificOutput: { hookEventName: 'SubagentStop', decision: 'block', reason: 'not done' } },
389
});
390
const results = await service.executeHook('Stop', { Stop: [cmd('test')] }, {});
391
392
expect(results).toHaveLength(1);
393
expect(results[0].resultKind).toBe('success');
394
// hookSpecificOutput should be stripped because SubagentStop -> Stop is not compatible
395
expect(results[0].output).toBeUndefined();
396
});
397
398
it('treats SessionStart hookEventName as compatible with SubagentStart hook type', async () => {
399
service.executorHandler = () => ({
400
kind: HookCommandResultKind.Success,
401
result: { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'context' } },
402
});
403
const results = await service.executeHook('SubagentStart', { SubagentStart: [cmd('test')] }, {});
404
405
expect(results).toHaveLength(1);
406
expect(results[0].resultKind).toBe('success');
407
expect(results[0].output).toEqual({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'context' } });
408
});
409
410
it('does NOT treat SubagentStart hookEventName as compatible with SessionStart hook type', async () => {
411
service.executorHandler = () => ({
412
kind: HookCommandResultKind.Success,
413
result: { hookSpecificOutput: { hookEventName: 'SubagentStart', additionalContext: 'context' } },
414
});
415
const results = await service.executeHook('SessionStart', { SessionStart: [cmd('test')] }, {});
416
417
expect(results).toHaveLength(1);
418
expect(results[0].resultKind).toBe('success');
419
// hookSpecificOutput should be stripped because SubagentStart -> SessionStart is not compatible
420
expect(results[0].output).toBeUndefined();
421
});
422
423
it('treats top-level Stop hookEventName as compatible with SubagentStop', async () => {
424
service.executorHandler = () => ({
425
kind: HookCommandResultKind.Success,
426
result: { hookEventName: 'Stop', decision: 'block', reason: 'from stop hook' },
427
});
428
const results = await service.executeHook('SubagentStop', { SubagentStop: [cmd('test')] }, {});
429
430
expect(results).toHaveLength(1);
431
expect(results[0].resultKind).toBe('success');
432
expect(results[0].output).toEqual({ hookEventName: 'Stop', decision: 'block', reason: 'from stop hook' });
433
});
434
435
it('still strips hookSpecificOutput when hookEventName is truly incompatible', async () => {
436
service.executorHandler = () => ({
437
kind: HookCommandResultKind.Success,
438
result: { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } },
439
});
440
const results = await service.executeHook('SubagentStop', { SubagentStop: [cmd('test')] }, {});
441
442
expect(results).toHaveLength(1);
443
expect(results[0].resultKind).toBe('success');
444
expect(results[0].output).toBeUndefined();
445
});
446
});
447
448
/**
449
* Minimal mock of ChatHookService that exposes executePreToolUseHook
450
* without requiring the real vscode API.
451
*
452
* We replicate the collapsing logic from ChatHookService.executePreToolUseHook
453
* by subclassing and overriding executeHook to return configurable results.
454
*/
455
456
interface IPreToolUseHookSpecificOutput {
457
hookEventName?: string;
458
permissionDecision?: 'allow' | 'deny' | 'ask';
459
permissionDecisionReason?: string;
460
updatedInput?: object;
461
additionalContext?: string;
462
}
463
464
const permissionPriority: Record<string, number> = { 'deny': 2, 'ask': 1, 'allow': 0 };
465
466
/**
467
* A testable version of the executePreToolUseHook collapsing logic,
468
* decoupled from the vscode API. Takes raw ChatHookResult[] and returns
469
* the collapsed IPreToolUseHookResult.
470
*/
471
function collapsePreToolUseHookResults(results: ChatHookResult[]): IPreToolUseHookResult | undefined {
472
if (results.length === 0) {
473
return undefined;
474
}
475
476
let mostRestrictiveDecision: 'allow' | 'deny' | 'ask' | undefined;
477
let winningReason: string | undefined;
478
let lastUpdatedInput: object | undefined;
479
const allAdditionalContext: string[] = [];
480
481
for (const result of results) {
482
// Exit code 2 (error) means deny the tool
483
if (result.resultKind === 'error') {
484
const reason = typeof result.output === 'string' ? result.output : undefined;
485
mostRestrictiveDecision = 'deny';
486
winningReason = reason ?? winningReason;
487
break;
488
}
489
490
if (result.resultKind !== 'success' || typeof result.output !== 'object' || result.output === null) {
491
continue;
492
}
493
494
const output = result.output as { hookSpecificOutput?: IPreToolUseHookSpecificOutput };
495
const hookSpecificOutput = output.hookSpecificOutput;
496
if (!hookSpecificOutput) {
497
continue;
498
}
499
500
if (hookSpecificOutput.hookEventName !== undefined && hookSpecificOutput.hookEventName !== 'PreToolUse') {
501
continue;
502
}
503
504
if (hookSpecificOutput.additionalContext) {
505
allAdditionalContext.push(hookSpecificOutput.additionalContext);
506
}
507
508
if (hookSpecificOutput.updatedInput) {
509
lastUpdatedInput = hookSpecificOutput.updatedInput;
510
}
511
512
const decision = hookSpecificOutput.permissionDecision;
513
if (decision && (mostRestrictiveDecision === undefined || (permissionPriority[decision] ?? 0) > (permissionPriority[mostRestrictiveDecision] ?? 0))) {
514
mostRestrictiveDecision = decision;
515
winningReason = hookSpecificOutput.permissionDecisionReason;
516
}
517
}
518
519
if (!mostRestrictiveDecision && !lastUpdatedInput && allAdditionalContext.length === 0) {
520
return undefined;
521
}
522
523
return {
524
permissionDecision: mostRestrictiveDecision,
525
permissionDecisionReason: winningReason,
526
updatedInput: lastUpdatedInput,
527
additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,
528
};
529
}
530
531
function hookResult(output: unknown, kind: ChatHookResultKind = 'success'): ChatHookResult {
532
return { resultKind: kind, output } as ChatHookResult;
533
}
534
535
/**
536
* A testable ChatHookService that stubs executeHook to return configurable results,
537
* so we can test executePreToolUseHook's collapsing logic without the real vscode API.
538
*/
539
class TestableChatHookService {
540
public hookResults: ChatHookResult[] = [];
541
public validateToolInputFn: ((name: string, input: string) => IToolValidationResult) | undefined;
542
543
async executeHook(): Promise<ChatHookResult[]> {
544
return this.hookResults;
545
}
546
547
async executePreToolUseHook(
548
toolName: string,
549
toolInput: unknown,
550
toolCallId: string,
551
toolInvocationToken: unknown,
552
sessionId?: string,
553
): Promise<IPreToolUseHookResult | undefined> {
554
const results = await this.executeHook();
555
const collapsed = collapsePreToolUseHookResults(results);
556
if (!collapsed) {
557
return undefined;
558
}
559
560
// Validate updatedInput against the tool's input schema, mirroring the real ChatHookService
561
if (collapsed.updatedInput && this.validateToolInputFn) {
562
const validationResult = this.validateToolInputFn(toolName, JSON.stringify(collapsed.updatedInput));
563
if ('error' in validationResult) {
564
collapsed.updatedInput = undefined;
565
}
566
}
567
568
if (!collapsed.permissionDecision && !collapsed.updatedInput && !collapsed.additionalContext?.length) {
569
return undefined;
570
}
571
572
return collapsed;
573
}
574
}
575
576
describe('ChatHookService.executePreToolUseHook', () => {
577
let service: TestableChatHookService;
578
579
beforeEach(() => {
580
service = new TestableChatHookService();
581
});
582
583
it('returns undefined when no hooks return results', async () => {
584
service.hookResults = [];
585
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
586
expect(result).toBeUndefined();
587
});
588
589
it('returns allow when single hook allows', async () => {
590
service.hookResults = [
591
hookResult({ hookSpecificOutput: { permissionDecision: 'allow', permissionDecisionReason: 'Tool is safe' } }),
592
];
593
594
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
595
expect(result).toEqual({
596
permissionDecision: 'allow',
597
permissionDecisionReason: 'Tool is safe',
598
updatedInput: undefined,
599
additionalContext: undefined,
600
});
601
});
602
603
it('returns deny when single hook denies', async () => {
604
service.hookResults = [
605
hookResult({ hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'Blocked' } }),
606
];
607
608
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
609
expect(result).toEqual({
610
permissionDecision: 'deny',
611
permissionDecisionReason: 'Blocked',
612
updatedInput: undefined,
613
additionalContext: undefined,
614
});
615
});
616
617
it('returns ask when single hook asks', async () => {
618
service.hookResults = [
619
hookResult({ hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'Needs review' } }),
620
];
621
622
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
623
expect(result).toEqual({
624
permissionDecision: 'ask',
625
permissionDecisionReason: 'Needs review',
626
updatedInput: undefined,
627
additionalContext: undefined,
628
});
629
});
630
631
it('deny wins over allow and ask', async () => {
632
service.hookResults = [
633
hookResult({ hookSpecificOutput: { permissionDecision: 'allow', permissionDecisionReason: 'ok' } }),
634
hookResult({ hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'maybe' } }),
635
hookResult({ hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'nope' } }),
636
];
637
638
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
639
expect(result?.permissionDecision).toBe('deny');
640
expect(result?.permissionDecisionReason).toBe('nope');
641
});
642
643
it('ask wins over allow', async () => {
644
service.hookResults = [
645
hookResult({ hookSpecificOutput: { permissionDecision: 'allow', permissionDecisionReason: 'ok' } }),
646
hookResult({ hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'confirm please' } }),
647
];
648
649
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
650
expect(result?.permissionDecision).toBe('ask');
651
expect(result?.permissionDecisionReason).toBe('confirm please');
652
});
653
654
it('ignores results with wrong hookEventName', async () => {
655
service.hookResults = [
656
hookResult({ hookSpecificOutput: { hookEventName: 'PostToolUse', permissionDecision: 'deny' } }),
657
hookResult({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' } }),
658
];
659
660
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
661
expect(result?.permissionDecision).toBe('allow');
662
});
663
664
it('accepts results without hookEventName', async () => {
665
service.hookResults = [
666
hookResult({ hookSpecificOutput: { permissionDecision: 'allow' } }),
667
];
668
669
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
670
expect(result?.permissionDecision).toBe('allow');
671
});
672
673
it('returns updatedInput from hook', async () => {
674
service.hookResults = [
675
hookResult({ hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { path: '/safe/path.ts' } } }),
676
];
677
678
const result = await service.executePreToolUseHook('tool', { path: '/original' }, 'call-1', undefined);
679
expect(result?.updatedInput).toEqual({ path: '/safe/path.ts' });
680
});
681
682
it('later hook updatedInput overrides earlier one', async () => {
683
service.hookResults = [
684
hookResult({ hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'first' } } }),
685
hookResult({ hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'second' } } }),
686
];
687
688
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
689
expect(result?.updatedInput).toEqual({ value: 'second' });
690
});
691
692
it('returns updatedInput even without permission decision', async () => {
693
service.hookResults = [
694
hookResult({ hookSpecificOutput: { updatedInput: { modified: true } } }),
695
];
696
697
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
698
expect(result?.updatedInput).toEqual({ modified: true });
699
expect(result?.permissionDecision).toBeUndefined();
700
});
701
702
it('discards updatedInput when schema validation fails', async () => {
703
service.validateToolInputFn = () => ({ error: 'Missing required property "command"' });
704
service.hookResults = [
705
hookResult({ hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { invalidField: 'wrong' } } }),
706
];
707
708
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
709
expect(result?.permissionDecision).toBe('allow');
710
expect(result?.updatedInput).toBeUndefined();
711
});
712
713
it('keeps updatedInput when schema validation passes', async () => {
714
service.validateToolInputFn = (_name, input) => ({ inputObj: JSON.parse(input) });
715
service.hookResults = [
716
hookResult({ hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { command: 'safe' } } }),
717
];
718
719
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
720
expect(result?.permissionDecision).toBe('allow');
721
expect(result?.updatedInput).toEqual({ command: 'safe' });
722
});
723
724
it('returns undefined when only updatedInput is present but fails validation', async () => {
725
service.validateToolInputFn = () => ({ error: 'invalid' });
726
service.hookResults = [
727
hookResult({ hookSpecificOutput: { updatedInput: { bad: true } } }),
728
];
729
730
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
731
expect(result).toBeUndefined();
732
});
733
734
it('collects additionalContext from all hooks', async () => {
735
service.hookResults = [
736
hookResult({ hookSpecificOutput: { permissionDecision: 'allow', additionalContext: 'context from hook 1' } }),
737
hookResult({ hookSpecificOutput: { permissionDecision: 'allow', additionalContext: 'context from hook 2' } }),
738
];
739
740
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
741
expect(result?.additionalContext).toEqual(['context from hook 1', 'context from hook 2']);
742
});
743
744
it('returns undefined additionalContext when no hooks provide it', async () => {
745
service.hookResults = [
746
hookResult({ hookSpecificOutput: { permissionDecision: 'allow' } }),
747
];
748
749
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
750
expect(result?.additionalContext).toBeUndefined();
751
});
752
753
it('combines updatedInput, additionalContext, and permission decision', async () => {
754
service.hookResults = [
755
hookResult({ hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'Modified input needs review', updatedInput: { command: 'echo safe' }, additionalContext: 'audit log enabled' } }),
756
];
757
758
const result = await service.executePreToolUseHook('tool', { command: 'rm -rf /' }, 'call-1', undefined);
759
expect(result).toEqual({
760
permissionDecision: 'ask',
761
permissionDecisionReason: 'Modified input needs review',
762
updatedInput: { command: 'echo safe' },
763
additionalContext: ['audit log enabled'],
764
});
765
});
766
767
it('treats error results (exit code 2) as deny', async () => {
768
service.hookResults = [
769
hookResult('hook blocked this tool', 'error'),
770
hookResult({ hookSpecificOutput: { permissionDecision: 'allow' } }),
771
];
772
773
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
774
expect(result?.permissionDecision).toBe('deny');
775
expect(result?.permissionDecisionReason).toBe('hook blocked this tool');
776
});
777
778
it('preserves context from prior hooks when error denies', async () => {
779
service.hookResults = [
780
hookResult({ hookSpecificOutput: { permissionDecision: 'allow', additionalContext: 'context from first hook' } }),
781
hookResult('second hook errored', 'error'),
782
];
783
784
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
785
expect(result?.permissionDecision).toBe('deny');
786
expect(result?.additionalContext).toEqual(['context from first hook']);
787
});
788
789
it('skips warning results', async () => {
790
service.hookResults = [
791
hookResult({ hookSpecificOutput: { permissionDecision: 'deny' } }, 'warning'),
792
hookResult({ hookSpecificOutput: { permissionDecision: 'allow' } }),
793
];
794
795
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
796
expect(result?.permissionDecision).toBe('allow');
797
});
798
799
it('skips results with non-object output', async () => {
800
service.hookResults = [
801
hookResult('string output'),
802
hookResult({ hookSpecificOutput: { permissionDecision: 'allow' } }),
803
];
804
805
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
806
expect(result?.permissionDecision).toBe('allow');
807
});
808
809
it('skips results without hookSpecificOutput', async () => {
810
service.hookResults = [
811
hookResult({ someOtherField: 'value' }),
812
hookResult({ hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'blocked' } }),
813
];
814
815
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
816
expect(result?.permissionDecision).toBe('deny');
817
});
818
819
it('returns undefined when all results are warnings', async () => {
820
service.hookResults = [
821
hookResult({ hookSpecificOutput: { permissionDecision: 'deny' } }, 'warning'),
822
hookResult({ hookSpecificOutput: { permissionDecision: 'allow' } }, 'warning'),
823
];
824
825
const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);
826
expect(result).toBeUndefined();
827
});
828
});
829
830
interface IPostToolUseHookSpecificOutput {
831
hookEventName?: string;
832
additionalContext?: string;
833
}
834
835
function collapsePostToolUseHookResults(results: ChatHookResult[]): IPostToolUseHookResult | undefined {
836
if (results.length === 0) {
837
return undefined;
838
}
839
840
let hasBlock = false;
841
let blockReason: string | undefined;
842
const allAdditionalContext: string[] = [];
843
844
for (const result of results) {
845
// Exit code 2 (error) means block the tool result
846
if (result.resultKind === 'error') {
847
const reason = typeof result.output === 'string' ? result.output : undefined;
848
if (!hasBlock) {
849
hasBlock = true;
850
blockReason = reason;
851
}
852
break;
853
}
854
855
if (result.resultKind !== 'success' || typeof result.output !== 'object' || result.output === null) {
856
continue;
857
}
858
859
const output = result.output as {
860
decision?: string;
861
reason?: string;
862
hookSpecificOutput?: IPostToolUseHookSpecificOutput;
863
};
864
865
if (output.hookSpecificOutput?.hookEventName !== undefined && output.hookSpecificOutput.hookEventName !== 'PostToolUse') {
866
continue;
867
}
868
869
if (output.hookSpecificOutput?.additionalContext) {
870
allAdditionalContext.push(output.hookSpecificOutput.additionalContext);
871
}
872
873
if (output.decision === 'block' && !hasBlock) {
874
hasBlock = true;
875
blockReason = output.reason;
876
}
877
}
878
879
if (!hasBlock && allAdditionalContext.length === 0) {
880
return undefined;
881
}
882
883
return {
884
decision: hasBlock ? 'block' : undefined,
885
reason: blockReason,
886
additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,
887
};
888
}
889
890
class TestablePostToolUseChatHookService {
891
public hookResults: ChatHookResult[] = [];
892
893
async executeHook(): Promise<ChatHookResult[]> {
894
return this.hookResults;
895
}
896
897
async executePostToolUseHook(
898
toolName: string,
899
toolInput: unknown,
900
toolResponseText: string,
901
toolCallId: string,
902
toolInvocationToken: unknown,
903
sessionId?: string,
904
): Promise<IPostToolUseHookResult | undefined> {
905
const results = await this.executeHook();
906
return collapsePostToolUseHookResults(results);
907
}
908
}
909
910
describe('ChatHookService.executePostToolUseHook', () => {
911
let service: TestablePostToolUseChatHookService;
912
913
beforeEach(() => {
914
service = new TestablePostToolUseChatHookService();
915
});
916
917
it('returns undefined when no hooks return results', async () => {
918
service.hookResults = [];
919
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
920
expect(result).toBeUndefined();
921
});
922
923
it('returns block decision when hook blocks', async () => {
924
service.hookResults = [
925
hookResult({ decision: 'block', reason: 'Lint errors found' }),
926
];
927
928
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
929
expect(result).toEqual({
930
decision: 'block',
931
reason: 'Lint errors found',
932
additionalContext: undefined,
933
});
934
});
935
936
it('returns additionalContext from hookSpecificOutput', async () => {
937
service.hookResults = [
938
hookResult({ hookSpecificOutput: { additionalContext: 'Tests still pass' } }),
939
];
940
941
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
942
expect(result).toEqual({
943
decision: undefined,
944
reason: undefined,
945
additionalContext: ['Tests still pass'],
946
});
947
});
948
949
it('collects additionalContext from all hooks', async () => {
950
service.hookResults = [
951
hookResult({ hookSpecificOutput: { additionalContext: 'context from hook 1' } }),
952
hookResult({ hookSpecificOutput: { additionalContext: 'context from hook 2' } }),
953
];
954
955
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
956
expect(result?.additionalContext).toEqual(['context from hook 1', 'context from hook 2']);
957
});
958
959
it('first block decision wins', async () => {
960
service.hookResults = [
961
hookResult({ decision: 'block', reason: 'First block' }),
962
hookResult({ decision: 'block', reason: 'Second block' }),
963
];
964
965
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
966
expect(result?.decision).toBe('block');
967
expect(result?.reason).toBe('First block');
968
});
969
970
it('block decision with additionalContext from different hooks', async () => {
971
service.hookResults = [
972
hookResult({ decision: 'block', reason: 'Tests failed' }),
973
hookResult({ hookSpecificOutput: { additionalContext: 'Extra context from linter' } }),
974
];
975
976
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
977
expect(result).toEqual({
978
decision: 'block',
979
reason: 'Tests failed',
980
additionalContext: ['Extra context from linter'],
981
});
982
});
983
984
it('ignores results with wrong hookEventName', async () => {
985
service.hookResults = [
986
hookResult({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Should be ignored' } }),
987
hookResult({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'Correct context' } }),
988
];
989
990
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
991
expect(result?.additionalContext).toEqual(['Correct context']);
992
});
993
994
it('accepts results without hookEventName', async () => {
995
service.hookResults = [
996
hookResult({ hookSpecificOutput: { additionalContext: 'No event name' } }),
997
];
998
999
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
1000
expect(result?.additionalContext).toEqual(['No event name']);
1001
});
1002
1003
it('treats error results (exit code 2) as block', async () => {
1004
service.hookResults = [
1005
hookResult('hook errored', 'error'),
1006
hookResult({ hookSpecificOutput: { additionalContext: 'Valid context' } }),
1007
];
1008
1009
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
1010
expect(result?.decision).toBe('block');
1011
expect(result?.reason).toBe('hook errored');
1012
});
1013
1014
it('preserves context from prior hooks when error blocks', async () => {
1015
service.hookResults = [
1016
hookResult({ hookSpecificOutput: { additionalContext: 'context from first' } }),
1017
hookResult('second errored', 'error'),
1018
];
1019
1020
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
1021
expect(result?.decision).toBe('block');
1022
expect(result?.additionalContext).toEqual(['context from first']);
1023
});
1024
1025
it('skips warning results', async () => {
1026
service.hookResults = [
1027
hookResult({ decision: 'block', reason: 'Should be ignored' }, 'warning'),
1028
hookResult({ hookSpecificOutput: { additionalContext: 'Valid context' } }),
1029
];
1030
1031
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
1032
expect(result?.decision).toBeUndefined();
1033
expect(result?.additionalContext).toEqual(['Valid context']);
1034
});
1035
1036
it('skips results with non-object output', async () => {
1037
service.hookResults = [
1038
hookResult('string output'),
1039
hookResult({ decision: 'block', reason: 'Valid block' }),
1040
];
1041
1042
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
1043
expect(result?.decision).toBe('block');
1044
});
1045
1046
it('returns undefined when all results are warnings', async () => {
1047
service.hookResults = [
1048
hookResult({ decision: 'block' }, 'warning'),
1049
hookResult({ hookSpecificOutput: { additionalContext: 'ctx' } }, 'warning'),
1050
];
1051
1052
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
1053
expect(result).toBeUndefined();
1054
});
1055
1056
it('returns undefined when no hook provides block or additionalContext', async () => {
1057
service.hookResults = [
1058
hookResult({ hookSpecificOutput: {} }),
1059
];
1060
1061
const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);
1062
expect(result).toBeUndefined();
1063
});
1064
});
1065
1066