Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/intents/test/node/hookResultProcessor.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, vi } from 'vitest';
7
import type { ChatResponseStream } from 'vscode';
8
import { TestLogService } from '../../../../platform/testing/common/testLogService';
9
import { ChatHookType } from '../../../../vscodeTypes';
10
import { formatHookErrorMessage, HookAbortError, HookResult, isHookAbortError, processHookResults, ProcessHookResultsOptions } from '../../node/hookResultProcessor';
11
12
/**
13
* Mock implementation of ChatResponseStream that tracks hookProgress calls.
14
*/
15
class MockChatResponseStream {
16
readonly hookProgressCalls: Array<{ hookType: ChatHookType; stopReason?: string; systemMessage?: string }> = [];
17
18
hookProgress(hookType: ChatHookType, stopReason?: string, systemMessage?: string): void {
19
this.hookProgressCalls.push({ hookType, stopReason, systemMessage });
20
}
21
}
22
23
describe('hookResultProcessor', () => {
24
let logService: TestLogService;
25
let mockStream: MockChatResponseStream;
26
27
beforeEach(() => {
28
logService = new TestLogService();
29
mockStream = new MockChatResponseStream();
30
});
31
32
describe('HookAbortError', () => {
33
it('should create error with hookType and stopReason', () => {
34
const error = new HookAbortError('UserPromptSubmit', 'Build failed');
35
expect(error.hookType).toBe('UserPromptSubmit');
36
expect(error.stopReason).toBe('Build failed');
37
expect(error.message).toBe('Hook UserPromptSubmit aborted: Build failed');
38
expect(error.name).toBe('HookAbortError');
39
});
40
41
it('should be identifiable via isHookAbortError', () => {
42
const hookError = new HookAbortError('Stop', 'reason');
43
expect(isHookAbortError(hookError)).toBe(true);
44
expect(isHookAbortError(new Error('regular error'))).toBe(false);
45
expect(isHookAbortError(null)).toBe(false);
46
expect(isHookAbortError(undefined)).toBe(false);
47
});
48
});
49
50
describe('stopReason handling for all hook types', () => {
51
const hookTypes: ChatHookType[] = [
52
'UserPromptSubmit',
53
'SessionStart',
54
'Stop',
55
'SubagentStart',
56
'SubagentStop',
57
];
58
59
hookTypes.forEach((hookType) => {
60
describe(`${hookType} hook`, () => {
61
it('should throw HookAbortError when stopReason is present', () => {
62
const results: HookResult[] = [
63
{
64
resultKind: 'success',
65
output: {},
66
stopReason: 'Build failed, fix errors before continuing',
67
},
68
];
69
70
const onSuccess = vi.fn();
71
const options: ProcessHookResultsOptions = {
72
hookType,
73
results,
74
outputStream: mockStream as unknown as ChatResponseStream,
75
logService,
76
onSuccess,
77
};
78
79
expect(() => processHookResults(options)).toThrow(HookAbortError);
80
expect(() => processHookResults(options)).toThrow(
81
`Hook ${hookType} aborted: Build failed, fix errors before continuing`
82
);
83
// Verify hookProgress is called with the stopReason
84
expect(mockStream.hookProgressCalls.length).toBeGreaterThan(0);
85
expect(mockStream.hookProgressCalls[0].hookType).toBe(hookType);
86
expect(mockStream.hookProgressCalls[0].stopReason).toContain('Build failed, fix errors before continuing');
87
});
88
89
it('should not call onSuccess when stopReason is present', () => {
90
const results: HookResult[] = [
91
{
92
resultKind: 'success',
93
output: { someData: 'value' },
94
stopReason: 'Processing blocked',
95
},
96
];
97
98
const onSuccess = vi.fn();
99
const options: ProcessHookResultsOptions = {
100
hookType,
101
results,
102
outputStream: mockStream as unknown as ChatResponseStream,
103
logService,
104
onSuccess,
105
};
106
107
try {
108
processHookResults(options);
109
} catch {
110
// Expected to throw
111
}
112
113
expect(onSuccess).not.toHaveBeenCalled();
114
});
115
116
it('should throw HookAbortError immediately and stop processing remaining results', () => {
117
const results: HookResult[] = [
118
{
119
resultKind: 'success',
120
output: { index: 0 },
121
stopReason: 'First hook aborted',
122
},
123
{
124
resultKind: 'success',
125
output: { index: 1 },
126
},
127
];
128
129
const onSuccess = vi.fn();
130
const options: ProcessHookResultsOptions = {
131
hookType,
132
results,
133
outputStream: mockStream as unknown as ChatResponseStream,
134
logService,
135
onSuccess,
136
};
137
138
expect(() => processHookResults(options)).toThrow('First hook aborted');
139
expect(onSuccess).not.toHaveBeenCalled();
140
// Verify hookProgress is called with the stopReason
141
expect(mockStream.hookProgressCalls).toHaveLength(1);
142
expect(mockStream.hookProgressCalls[0].hookType).toBe(hookType);
143
expect(mockStream.hookProgressCalls[0].stopReason).toContain('First hook aborted');
144
});
145
146
it('should throw HookAbortError when stopReason is empty string (continue: false)', () => {
147
const results: HookResult[] = [
148
{
149
resultKind: 'success',
150
output: {},
151
stopReason: '',
152
},
153
];
154
155
const onSuccess = vi.fn();
156
const options: ProcessHookResultsOptions = {
157
hookType,
158
results,
159
outputStream: mockStream as unknown as ChatResponseStream,
160
logService,
161
onSuccess,
162
};
163
164
expect(() => processHookResults(options)).toThrow(HookAbortError);
165
expect(onSuccess).not.toHaveBeenCalled();
166
});
167
});
168
});
169
});
170
171
describe('UserPromptSubmit exit codes', () => {
172
// Exit code 0 - stdout shown to Claude (onSuccess called)
173
it('should call onSuccess with output on exit code 0 (success)', () => {
174
const results: HookResult[] = [
175
{
176
resultKind: 'success',
177
output: 'Additional context for Claude',
178
},
179
];
180
181
const onSuccess = vi.fn();
182
processHookResults({
183
hookType: 'UserPromptSubmit',
184
results,
185
outputStream: mockStream as unknown as ChatResponseStream,
186
logService,
187
onSuccess,
188
});
189
190
expect(onSuccess).toHaveBeenCalledWith('Additional context for Claude');
191
});
192
193
// Exit code 2 - block processing, erase original prompt, and show stderr to user only
194
it('should throw HookAbortError and push hookProgress on exit code 2 (error)', () => {
195
const results: HookResult[] = [
196
{
197
resultKind: 'error',
198
output: 'Validation failed: missing required field',
199
},
200
];
201
202
const onSuccess = vi.fn();
203
expect(() =>
204
processHookResults({
205
hookType: 'UserPromptSubmit',
206
results,
207
outputStream: mockStream as unknown as ChatResponseStream,
208
logService,
209
onSuccess,
210
})
211
).toThrow(HookAbortError);
212
213
expect(onSuccess).not.toHaveBeenCalled();
214
expect(mockStream.hookProgressCalls).toHaveLength(1);
215
expect(mockStream.hookProgressCalls[0].hookType).toBe('UserPromptSubmit');
216
expect(mockStream.hookProgressCalls[0].stopReason).toContain('Validation failed: missing required field');
217
});
218
219
// Other exit codes - show stderr to user only (warnings flow)
220
it('should show warning to user on other exit codes', () => {
221
const results: HookResult[] = [
222
{
223
resultKind: 'warning',
224
warningMessage: 'Process exited with code 1: Some warning',
225
output: undefined,
226
},
227
];
228
229
const onSuccess = vi.fn();
230
processHookResults({
231
hookType: 'UserPromptSubmit',
232
results,
233
outputStream: mockStream as unknown as ChatResponseStream,
234
logService,
235
onSuccess,
236
});
237
238
expect(onSuccess).not.toHaveBeenCalled();
239
expect(mockStream.hookProgressCalls).toHaveLength(1);
240
expect(mockStream.hookProgressCalls[0].hookType).toBe('UserPromptSubmit');
241
expect(mockStream.hookProgressCalls[0].systemMessage).toBe('Process exited with code 1: Some warning');
242
});
243
244
it('should aggregate multiple warnings', () => {
245
const results: HookResult[] = [
246
{ resultKind: 'warning', warningMessage: 'Warning 1', output: undefined },
247
{ resultKind: 'warning', warningMessage: 'Warning 2', output: undefined },
248
];
249
250
processHookResults({
251
hookType: 'UserPromptSubmit',
252
results,
253
outputStream: mockStream as unknown as ChatResponseStream,
254
logService,
255
onSuccess: () => { },
256
});
257
258
expect(mockStream.hookProgressCalls).toHaveLength(1);
259
expect(mockStream.hookProgressCalls[0].systemMessage).toContain('1. Warning 1');
260
expect(mockStream.hookProgressCalls[0].systemMessage).toContain('2. Warning 2');
261
});
262
});
263
264
describe('SessionStart exit codes', () => {
265
// Exit code 0 - stdout shown to Claude
266
it('should call onSuccess with output on exit code 0 (success)', () => {
267
const results: HookResult[] = [
268
{
269
resultKind: 'success',
270
output: { additionalContext: 'Session context data' },
271
},
272
];
273
274
const onSuccess = vi.fn();
275
processHookResults({
276
hookType: 'SessionStart',
277
results,
278
outputStream: mockStream as unknown as ChatResponseStream,
279
logService,
280
onSuccess,
281
});
282
283
expect(onSuccess).toHaveBeenCalledWith({ additionalContext: 'Session context data' });
284
});
285
286
// Blocking errors are silently ignored (ignoreErrors: true) - no throw, no hookProgress
287
it('should silently ignore errors when ignoreErrors is true (no throw, no hookProgress)', () => {
288
const results: HookResult[] = [
289
{
290
resultKind: 'error',
291
output: 'Session hook error',
292
},
293
];
294
295
const onSuccess = vi.fn();
296
expect(() =>
297
processHookResults({
298
hookType: 'SessionStart',
299
results,
300
outputStream: mockStream as unknown as ChatResponseStream,
301
logService,
302
onSuccess,
303
ignoreErrors: true,
304
})
305
).not.toThrow();
306
307
expect(onSuccess).not.toHaveBeenCalled();
308
// hookProgress should NOT be called when ignoreErrors is true
309
expect(mockStream.hookProgressCalls).toHaveLength(0);
310
});
311
312
// stopReason (continue: false) is silently ignored (ignoreErrors: true)
313
it('should silently ignore stopReason when ignoreErrors is true', () => {
314
const results: HookResult[] = [
315
{
316
resultKind: 'success',
317
output: { additionalContext: 'Some context' },
318
stopReason: 'Build failed, should be ignored',
319
},
320
];
321
322
const onSuccess = vi.fn();
323
expect(() =>
324
processHookResults({
325
hookType: 'SessionStart',
326
results,
327
outputStream: mockStream as unknown as ChatResponseStream,
328
logService,
329
onSuccess,
330
ignoreErrors: true,
331
})
332
).not.toThrow();
333
334
// stopReason means the result is ignored entirely, so onSuccess is NOT called
335
expect(onSuccess).not.toHaveBeenCalled();
336
// hookProgress should NOT be called when ignoreErrors is true
337
expect(mockStream.hookProgressCalls).toHaveLength(0);
338
});
339
340
// Other exit codes - show stderr to user only (warnings)
341
it('should show warning to user on other exit codes', () => {
342
const results: HookResult[] = [
343
{
344
resultKind: 'warning',
345
warningMessage: 'Session start warning',
346
output: undefined,
347
},
348
];
349
350
processHookResults({
351
hookType: 'SessionStart',
352
results,
353
outputStream: mockStream as unknown as ChatResponseStream,
354
logService,
355
onSuccess: () => { },
356
});
357
358
expect(mockStream.hookProgressCalls).toHaveLength(1);
359
expect(mockStream.hookProgressCalls[0].systemMessage).toBe('Session start warning');
360
});
361
});
362
363
describe('Stop exit codes', () => {
364
// Exit code 0 - stdout/stderr not shown (success silently processed)
365
it('should call onSuccess with output on exit code 0 (success)', () => {
366
const results: HookResult[] = [
367
{
368
resultKind: 'success',
369
output: { decision: 'allow' },
370
},
371
];
372
373
const onSuccess = vi.fn();
374
processHookResults({
375
hookType: 'Stop',
376
results,
377
outputStream: mockStream as unknown as ChatResponseStream,
378
logService,
379
onSuccess,
380
});
381
382
expect(onSuccess).toHaveBeenCalledWith({ decision: 'allow' });
383
// No hookProgress for success
384
expect(mockStream.hookProgressCalls).toHaveLength(0);
385
});
386
387
// Exit code 2 - show stderr to model and continue conversation (onError callback)
388
it('should call onError callback on exit code 2 (error) instead of throwing', () => {
389
const results: HookResult[] = [
390
{
391
resultKind: 'error',
392
output: 'Stop hook blocking reason',
393
},
394
];
395
396
const onSuccess = vi.fn();
397
const onError = vi.fn();
398
processHookResults({
399
hookType: 'Stop',
400
results,
401
outputStream: mockStream as unknown as ChatResponseStream,
402
logService,
403
onSuccess,
404
onError,
405
});
406
407
expect(onSuccess).not.toHaveBeenCalled();
408
expect(onError).toHaveBeenCalledWith('Stop hook blocking reason');
409
// hookProgress should NOT be called when onError is provided
410
expect(mockStream.hookProgressCalls).toHaveLength(0);
411
});
412
413
it('should continue processing remaining results after onError', () => {
414
const results: HookResult[] = [
415
{
416
resultKind: 'error',
417
output: 'First error',
418
},
419
{
420
resultKind: 'success',
421
output: { reason: 'keep going' },
422
},
423
{
424
resultKind: 'error',
425
output: 'Second error',
426
},
427
];
428
429
const onSuccess = vi.fn();
430
const onError = vi.fn();
431
processHookResults({
432
hookType: 'Stop',
433
results,
434
outputStream: mockStream as unknown as ChatResponseStream,
435
logService,
436
onSuccess,
437
onError,
438
});
439
440
expect(onError).toHaveBeenCalledTimes(2);
441
expect(onError).toHaveBeenCalledWith('First error');
442
expect(onError).toHaveBeenCalledWith('Second error');
443
expect(onSuccess).toHaveBeenCalledWith({ reason: 'keep going' });
444
});
445
446
// Other exit codes - show stderr to user only (warnings)
447
it('should show warning to user on other exit codes', () => {
448
const results: HookResult[] = [
449
{
450
resultKind: 'warning',
451
warningMessage: 'Stop hook warning',
452
output: undefined,
453
},
454
];
455
456
processHookResults({
457
hookType: 'Stop',
458
results,
459
outputStream: mockStream as unknown as ChatResponseStream,
460
logService,
461
onSuccess: () => { },
462
});
463
464
expect(mockStream.hookProgressCalls).toHaveLength(1);
465
expect(mockStream.hookProgressCalls[0].systemMessage).toBe('Stop hook warning');
466
});
467
});
468
469
describe('SubagentStart exit codes', () => {
470
// Exit code 0 - stdout shown to subagent
471
it('should call onSuccess with output on exit code 0 (success)', () => {
472
const results: HookResult[] = [
473
{
474
resultKind: 'success',
475
output: { additionalContext: 'Subagent context' },
476
},
477
];
478
479
const onSuccess = vi.fn();
480
processHookResults({
481
hookType: 'SubagentStart',
482
results,
483
outputStream: mockStream as unknown as ChatResponseStream,
484
logService,
485
onSuccess,
486
});
487
488
expect(onSuccess).toHaveBeenCalledWith({ additionalContext: 'Subagent context' });
489
});
490
491
// Blocking errors are silently ignored (ignoreErrors: true) - no throw, no hookProgress
492
it('should silently ignore errors when ignoreErrors is true (no throw, no hookProgress)', () => {
493
const results: HookResult[] = [
494
{
495
resultKind: 'error',
496
output: 'Subagent start error',
497
},
498
];
499
500
const onSuccess = vi.fn();
501
expect(() =>
502
processHookResults({
503
hookType: 'SubagentStart',
504
results,
505
outputStream: mockStream as unknown as ChatResponseStream,
506
logService,
507
onSuccess,
508
ignoreErrors: true,
509
})
510
).not.toThrow();
511
512
expect(onSuccess).not.toHaveBeenCalled();
513
// hookProgress should NOT be called when ignoreErrors is true
514
expect(mockStream.hookProgressCalls).toHaveLength(0);
515
});
516
517
// stopReason (continue: false) is silently ignored (ignoreErrors: true)
518
it('should silently ignore stopReason when ignoreErrors is true', () => {
519
const results: HookResult[] = [
520
{
521
resultKind: 'success',
522
output: { additionalContext: 'Subagent context' },
523
stopReason: 'Blocking condition, should be ignored',
524
},
525
];
526
527
const onSuccess = vi.fn();
528
expect(() =>
529
processHookResults({
530
hookType: 'SubagentStart',
531
results,
532
outputStream: mockStream as unknown as ChatResponseStream,
533
logService,
534
onSuccess,
535
ignoreErrors: true,
536
})
537
).not.toThrow();
538
539
// stopReason means the result is ignored entirely, so onSuccess is NOT called
540
expect(onSuccess).not.toHaveBeenCalled();
541
// hookProgress should NOT be called when ignoreErrors is true
542
expect(mockStream.hookProgressCalls).toHaveLength(0);
543
});
544
545
// Other exit codes - show stderr to user only (warnings)
546
it('should show warning to user on other exit codes', () => {
547
const results: HookResult[] = [
548
{
549
resultKind: 'warning',
550
warningMessage: 'Subagent start warning',
551
output: undefined,
552
},
553
];
554
555
processHookResults({
556
hookType: 'SubagentStart',
557
results,
558
outputStream: mockStream as unknown as ChatResponseStream,
559
logService,
560
onSuccess: () => { },
561
});
562
563
expect(mockStream.hookProgressCalls).toHaveLength(1);
564
expect(mockStream.hookProgressCalls[0].systemMessage).toBe('Subagent start warning');
565
});
566
});
567
568
describe('SubagentStop exit codes', () => {
569
// Exit code 0 - stdout/stderr not shown (success silently processed)
570
it('should call onSuccess with output on exit code 0 (success)', () => {
571
const results: HookResult[] = [
572
{
573
resultKind: 'success',
574
output: { decision: 'allow' },
575
},
576
];
577
578
const onSuccess = vi.fn();
579
processHookResults({
580
hookType: 'SubagentStop',
581
results,
582
outputStream: mockStream as unknown as ChatResponseStream,
583
logService,
584
onSuccess,
585
});
586
587
expect(onSuccess).toHaveBeenCalledWith({ decision: 'allow' });
588
expect(mockStream.hookProgressCalls).toHaveLength(0);
589
});
590
591
// Exit code 2 - show stderr to subagent and continue having it run (onError callback)
592
it('should call onError callback on exit code 2 (error) instead of throwing', () => {
593
const results: HookResult[] = [
594
{
595
resultKind: 'error',
596
output: 'Subagent stop blocking reason',
597
},
598
];
599
600
const onSuccess = vi.fn();
601
const onError = vi.fn();
602
processHookResults({
603
hookType: 'SubagentStop',
604
results,
605
outputStream: mockStream as unknown as ChatResponseStream,
606
logService,
607
onSuccess,
608
onError,
609
});
610
611
expect(onSuccess).not.toHaveBeenCalled();
612
expect(onError).toHaveBeenCalledWith('Subagent stop blocking reason');
613
// hookProgress should NOT be called when onError is provided
614
expect(mockStream.hookProgressCalls).toHaveLength(0);
615
});
616
617
// Other exit codes - show stderr to user only (warnings)
618
it('should show warning to user on other exit codes', () => {
619
const results: HookResult[] = [
620
{
621
resultKind: 'warning',
622
warningMessage: 'Subagent stop warning',
623
output: undefined,
624
},
625
];
626
627
processHookResults({
628
hookType: 'SubagentStop',
629
results,
630
outputStream: mockStream as unknown as ChatResponseStream,
631
logService,
632
onSuccess: () => { },
633
});
634
635
expect(mockStream.hookProgressCalls).toHaveLength(1);
636
expect(mockStream.hookProgressCalls[0].systemMessage).toBe('Subagent stop warning');
637
});
638
});
639
640
describe('formatHookErrorMessage', () => {
641
it('should format error message with details', () => {
642
const message = formatHookErrorMessage('Connection failed');
643
expect(message).toContain('Connection failed');
644
expect(message).toContain('A hook prevented chat from continuing');
645
});
646
647
it('should format error message without details', () => {
648
const message = formatHookErrorMessage('');
649
expect(message).toContain('A hook prevented chat from continuing');
650
expect(message).not.toContain('Error message:');
651
});
652
});
653
654
describe('edge cases', () => {
655
it('should handle empty results array', () => {
656
const onSuccess = vi.fn();
657
processHookResults({
658
hookType: 'UserPromptSubmit',
659
results: [],
660
outputStream: mockStream as unknown as ChatResponseStream,
661
logService,
662
onSuccess,
663
});
664
665
expect(onSuccess).not.toHaveBeenCalled();
666
expect(mockStream.hookProgressCalls).toHaveLength(0);
667
});
668
669
it('should handle undefined outputStream', () => {
670
const results: HookResult[] = [
671
{ resultKind: 'warning', warningMessage: 'Warning message', output: undefined },
672
];
673
674
// Should not throw when outputStream is undefined
675
expect(() =>
676
processHookResults({
677
hookType: 'UserPromptSubmit',
678
results,
679
outputStream: undefined,
680
logService,
681
onSuccess: () => { },
682
})
683
).not.toThrow();
684
});
685
686
it('should include warnings from success results', () => {
687
const results: HookResult[] = [
688
{
689
resultKind: 'success',
690
output: 'some output',
691
warningMessage: 'Warning from success result',
692
},
693
];
694
695
const onSuccess = vi.fn();
696
processHookResults({
697
hookType: 'UserPromptSubmit',
698
results,
699
outputStream: mockStream as unknown as ChatResponseStream,
700
logService,
701
onSuccess,
702
});
703
704
expect(onSuccess).toHaveBeenCalledWith('some output');
705
expect(mockStream.hookProgressCalls).toHaveLength(1);
706
expect(mockStream.hookProgressCalls[0].systemMessage).toBe('Warning from success result');
707
});
708
709
it('should handle error result with empty output', () => {
710
const results: HookResult[] = [
711
{
712
resultKind: 'error',
713
output: '',
714
},
715
];
716
717
expect(() =>
718
processHookResults({
719
hookType: 'UserPromptSubmit',
720
results,
721
outputStream: mockStream as unknown as ChatResponseStream,
722
logService,
723
onSuccess: () => { },
724
})
725
).toThrow(HookAbortError);
726
727
expect(mockStream.hookProgressCalls).toHaveLength(1);
728
});
729
730
it('should handle error result with non-string output', () => {
731
const results: HookResult[] = [
732
{
733
resultKind: 'error',
734
output: { complex: 'object' },
735
},
736
];
737
738
expect(() =>
739
processHookResults({
740
hookType: 'UserPromptSubmit',
741
results,
742
outputStream: mockStream as unknown as ChatResponseStream,
743
logService,
744
onSuccess: () => { },
745
})
746
).toThrow(HookAbortError);
747
748
// Empty error message when output is not a string
749
expect(mockStream.hookProgressCalls).toHaveLength(1);
750
});
751
752
it('should process multiple results in order', () => {
753
const results: HookResult[] = [
754
{ resultKind: 'success', output: 'first' },
755
{ resultKind: 'success', output: 'second' },
756
{ resultKind: 'success', output: 'third' },
757
];
758
759
const outputs: unknown[] = [];
760
processHookResults({
761
hookType: 'UserPromptSubmit',
762
results,
763
outputStream: mockStream as unknown as ChatResponseStream,
764
logService,
765
onSuccess: (output) => outputs.push(output),
766
});
767
768
expect(outputs).toEqual(['first', 'second', 'third']);
769
});
770
});
771
});
772
773