Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/intents/test/node/toolCallingLoopHooks.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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
import type { CancellationToken, ChatHookResult, ChatHookType, ChatRequest, LanguageModelToolInformation } from 'vscode';
8
import { IChatHookService, SessionStartHookInput, StopHookInput, SubagentStartHookInput, SubagentStopHookInput } from '../../../../platform/chat/common/chatHookService';
9
import { NoopOTelService } from '../../../../platform/otel/common/noopOtelService';
10
import { resolveOTelConfig } from '../../../../platform/otel/common/otelConfig';
11
import { IOTelService } from '../../../../platform/otel/common/otelService';
12
import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';
13
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
14
import { generateUuid } from '../../../../util/vs/base/common/uuid';
15
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
16
import { Conversation, Turn } from '../../../prompt/common/conversation';
17
import { IBuildPromptContext } from '../../../prompt/common/intents';
18
import { IBuildPromptResult, nullRenderPromptResult } from '../../../prompt/node/intents';
19
import { createExtensionUnitTestingServices } from '../../../test/node/services';
20
import { IToolCallingLoopOptions, ToolCallingLoop } from '../../node/toolCallingLoop';
21
22
/**
23
* Configurable mock implementation of IChatHookService for testing.
24
*
25
* Allows tests to configure:
26
* - Hook results to return for specific hook types
27
* - Error behavior to simulate hook failures
28
* - Call tracking to verify hook invocations
29
*/
30
export class MockChatHookService implements IChatHookService {
31
declare readonly _serviceBrand: undefined;
32
33
/** Configured results to return per hook type */
34
private readonly hookResults = new Map<ChatHookType, ChatHookResult[]>();
35
36
/** Configured errors to throw per hook type */
37
private readonly hookErrors = new Map<ChatHookType, Error>();
38
39
/** Tracks all hook calls for verification */
40
readonly hookCalls: Array<{ hookType: ChatHookType; input: unknown }> = [];
41
42
logConfiguredHooks(): void { }
43
44
/**
45
* Configure the results that should be returned when a specific hook type is executed.
46
*/
47
setHookResults(hookType: ChatHookType, results: ChatHookResult[]): void {
48
this.hookResults.set(hookType, results);
49
}
50
51
/**
52
* Configure an error to throw when a specific hook type is executed.
53
*/
54
setHookError(hookType: ChatHookType, error: Error): void {
55
this.hookErrors.set(hookType, error);
56
}
57
58
/**
59
* Clear all hook calls for test isolation.
60
*/
61
clearCalls(): void {
62
this.hookCalls.length = 0;
63
}
64
65
/**
66
* Get all calls for a specific hook type.
67
*/
68
getCallsForHook(hookType: ChatHookType): Array<{ hookType: ChatHookType; input: unknown }> {
69
return this.hookCalls.filter(call => call.hookType === hookType);
70
}
71
72
async executeHook(hookType: ChatHookType, _hooks: unknown, input: unknown, _sessionId?: string, _token?: CancellationToken): Promise<ChatHookResult[]> {
73
// Track the call
74
this.hookCalls.push({ hookType, input });
75
76
// Check if we should throw an error
77
const error = this.hookErrors.get(hookType);
78
if (error) {
79
throw error;
80
}
81
82
// Return configured results or empty array
83
return this.hookResults.get(hookType) || [];
84
}
85
86
async executePreToolUseHook(): Promise<undefined> {
87
return undefined;
88
}
89
90
async executePostToolUseHook(): Promise<undefined> {
91
return undefined;
92
}
93
}
94
95
/**
96
* Minimal concrete implementation of ToolCallingLoop for testing.
97
* Exposes the abstract base class methods for testing while providing
98
* simple implementations for the abstract methods.
99
*/
100
class TestToolCallingLoop extends ToolCallingLoop<IToolCallingLoopOptions> {
101
public lastBuildPromptContext: IBuildPromptContext | undefined;
102
public additionalContextValue: string | undefined;
103
104
protected override async buildPrompt(buildPromptContext: IBuildPromptContext): Promise<IBuildPromptResult> {
105
this.lastBuildPromptContext = buildPromptContext;
106
return nullRenderPromptResult();
107
}
108
109
protected override async getAvailableTools(): Promise<LanguageModelToolInformation[]> {
110
return [];
111
}
112
113
protected override async fetch(): Promise<never> {
114
throw new Error('fetch should not be called in these tests');
115
}
116
117
// Expose the protected method for testing
118
public async testRunStartHooks(token: CancellationToken): Promise<void> {
119
await this.runStartHooks(undefined, token);
120
}
121
122
// Expose the protected stop hook methods for testing
123
public async testExecuteStopHook(input: StopHookInput, sessionId: string, token: CancellationToken) {
124
return this.executeStopHook(input, sessionId, undefined, token);
125
}
126
127
public async testExecuteSubagentStopHook(input: SubagentStopHookInput, sessionId: string, token: CancellationToken) {
128
return this.executeSubagentStopHook(input, sessionId, undefined, token);
129
}
130
131
// Expose additionalHookContext for verification
132
public getAdditionalHookContext(): string | undefined {
133
// Access via createPromptContext which uses this.additionalHookContext
134
const context = this.createPromptContext([], undefined);
135
return context.additionalHookContext;
136
}
137
}
138
139
function createMockChatRequest(overrides: Partial<ChatRequest> = {}): ChatRequest {
140
return {
141
prompt: 'test prompt',
142
command: undefined,
143
references: [],
144
location: 1, // ChatLocation.Panel
145
location2: undefined,
146
attempt: 0,
147
enableCommandDetection: false,
148
isParticipantDetected: false,
149
toolReferences: [],
150
toolInvocationToken: {} as ChatRequest['toolInvocationToken'],
151
model: null!,
152
tools: new Map(),
153
id: generateUuid(),
154
sessionId: generateUuid(),
155
...overrides,
156
} as ChatRequest;
157
}
158
159
function createTestConversation(turnCount: number = 1): Conversation {
160
const turns: Turn[] = [];
161
for (let i = 0; i < turnCount; i++) {
162
turns.push(new Turn(
163
generateUuid(),
164
{ message: `test message ${i}`, type: 'user' }
165
));
166
}
167
return new Conversation(generateUuid(), turns);
168
}
169
170
describe('ToolCallingLoop SessionStart hook', () => {
171
let disposables: DisposableStore;
172
let instantiationService: IInstantiationService;
173
let mockChatHookService: MockChatHookService;
174
let tokenSource: CancellationTokenSource;
175
176
beforeEach(() => {
177
disposables = new DisposableStore();
178
mockChatHookService = new MockChatHookService();
179
180
const serviceCollection = disposables.add(createExtensionUnitTestingServices());
181
// Must define the mock service BEFORE creating the accessor
182
serviceCollection.define(IChatHookService, mockChatHookService);
183
serviceCollection.define(IOTelService, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })));
184
185
const accessor = serviceCollection.createTestingAccessor();
186
instantiationService = accessor.get(IInstantiationService);
187
188
tokenSource = new CancellationTokenSource();
189
disposables.add(tokenSource);
190
});
191
192
afterEach(() => {
193
disposables.dispose();
194
vi.restoreAllMocks();
195
});
196
197
describe('SessionStart hook execution conditions', () => {
198
it('should execute SessionStart hook on the first turn of regular sessions', async () => {
199
const conversation = createTestConversation(1); // First turn
200
const request = createMockChatRequest({
201
model: { id: 'test-model-id' } as ChatRequest['model'],
202
participant: 'test-agent',
203
} as unknown as Partial<ChatRequest>);
204
205
const loop = instantiationService.createInstance(
206
TestToolCallingLoop,
207
{
208
conversation,
209
toolCallLimit: 10,
210
request,
211
}
212
);
213
disposables.add(loop);
214
215
// Spy on the hook service
216
vi.spyOn(mockChatHookService, 'executeHook');
217
218
await loop.testRunStartHooks(tokenSource.token);
219
220
const sessionStartCalls = mockChatHookService.getCallsForHook('SessionStart');
221
expect(sessionStartCalls).toHaveLength(1);
222
const input = sessionStartCalls[0].input as SessionStartHookInput;
223
expect(input).toMatchObject({
224
source: 'new',
225
model: 'test-model-id',
226
agent_type: 'test-agent',
227
});
228
});
229
230
it('should NOT execute SessionStart hook on subsequent turns', async () => {
231
const conversation = createTestConversation(3); // Third turn
232
const request = createMockChatRequest();
233
234
const loop = instantiationService.createInstance(
235
TestToolCallingLoop,
236
{
237
conversation,
238
toolCallLimit: 10,
239
request,
240
}
241
);
242
disposables.add(loop);
243
244
await loop.testRunStartHooks(tokenSource.token);
245
246
const sessionStartCalls = mockChatHookService.getCallsForHook('SessionStart');
247
expect(sessionStartCalls).toHaveLength(0);
248
});
249
250
it('should NOT execute SessionStart hook for subagent requests', async () => {
251
const conversation = createTestConversation(1); // First turn
252
const request = createMockChatRequest({
253
subAgentInvocationId: 'subagent-123',
254
subAgentName: 'TestSubagent',
255
} as Partial<ChatRequest>);
256
257
const loop = instantiationService.createInstance(
258
TestToolCallingLoop,
259
{
260
conversation,
261
toolCallLimit: 10,
262
request,
263
}
264
);
265
disposables.add(loop);
266
267
await loop.testRunStartHooks(tokenSource.token);
268
269
// SessionStart should NOT be called for subagents
270
const sessionStartCalls = mockChatHookService.getCallsForHook('SessionStart');
271
expect(sessionStartCalls).toHaveLength(0);
272
273
// SubagentStart should be called instead
274
const subagentStartCalls = mockChatHookService.getCallsForHook('SubagentStart');
275
expect(subagentStartCalls).toHaveLength(1);
276
});
277
});
278
279
describe('SessionStart hook result collection', () => {
280
it('should collect additionalContext from single hook result', async () => {
281
const conversation = createTestConversation(1);
282
const request = createMockChatRequest();
283
284
mockChatHookService.setHookResults('SessionStart', [
285
{
286
resultKind: 'success',
287
output: { hookSpecificOutput: { additionalContext: 'Context from hook 1' } },
288
},
289
]);
290
291
const loop = instantiationService.createInstance(
292
TestToolCallingLoop,
293
{
294
conversation,
295
toolCallLimit: 10,
296
request,
297
}
298
);
299
disposables.add(loop);
300
301
await loop.testRunStartHooks(tokenSource.token);
302
303
const additionalContext = loop.getAdditionalHookContext();
304
expect(additionalContext).toBe('Context from hook 1');
305
});
306
307
it('should concatenate additionalContext from multiple hook results', async () => {
308
const conversation = createTestConversation(1);
309
const request = createMockChatRequest();
310
311
mockChatHookService.setHookResults('SessionStart', [
312
{
313
resultKind: 'success',
314
output: { hookSpecificOutput: { additionalContext: 'Context from hook 1' } },
315
},
316
{
317
resultKind: 'success',
318
output: { hookSpecificOutput: { additionalContext: 'Context from hook 2' } },
319
},
320
{
321
resultKind: 'success',
322
output: { hookSpecificOutput: { additionalContext: 'Context from hook 3' } },
323
},
324
]);
325
326
const loop = instantiationService.createInstance(
327
TestToolCallingLoop,
328
{
329
conversation,
330
toolCallLimit: 10,
331
request,
332
}
333
);
334
disposables.add(loop);
335
336
await loop.testRunStartHooks(tokenSource.token);
337
338
const additionalContext = loop.getAdditionalHookContext();
339
expect(additionalContext).toBe('Context from hook 1\nContext from hook 2\nContext from hook 3');
340
});
341
342
it('should ignore hook results with no additionalContext', async () => {
343
const conversation = createTestConversation(1);
344
const request = createMockChatRequest();
345
346
mockChatHookService.setHookResults('SessionStart', [
347
{
348
resultKind: 'success',
349
output: { hookSpecificOutput: { additionalContext: 'Context from hook 1' } },
350
},
351
{
352
resultKind: 'success',
353
output: {}, // No additionalContext
354
},
355
{
356
resultKind: 'success',
357
output: { hookSpecificOutput: { additionalContext: 'Context from hook 3' } },
358
},
359
]);
360
361
const loop = instantiationService.createInstance(
362
TestToolCallingLoop,
363
{
364
conversation,
365
toolCallLimit: 10,
366
request,
367
}
368
);
369
disposables.add(loop);
370
371
await loop.testRunStartHooks(tokenSource.token);
372
373
const additionalContext = loop.getAdditionalHookContext();
374
expect(additionalContext).toBe('Context from hook 1\nContext from hook 3');
375
});
376
377
it('should silently ignore failed hook results (blocking errors are ignored)', async () => {
378
const conversation = createTestConversation(1);
379
const request = createMockChatRequest();
380
381
mockChatHookService.setHookResults('SessionStart', [
382
{
383
resultKind: 'success',
384
output: { hookSpecificOutput: { additionalContext: 'Context from hook 1' } },
385
},
386
{
387
resultKind: 'error',
388
output: 'Hook error message',
389
},
390
{
391
resultKind: 'success',
392
output: { hookSpecificOutput: { additionalContext: 'Context from hook 3' } },
393
},
394
]);
395
396
const loop = instantiationService.createInstance(
397
TestToolCallingLoop,
398
{
399
conversation,
400
toolCallLimit: 10,
401
request,
402
}
403
);
404
disposables.add(loop);
405
406
// Should NOT throw - blocking errors are silently ignored for SessionStart
407
await expect(loop.testRunStartHooks(tokenSource.token)).resolves.not.toThrow();
408
409
// Only non-error results should be processed
410
const additionalContext = loop.getAdditionalHookContext();
411
expect(additionalContext).toBe('Context from hook 1\nContext from hook 3');
412
});
413
414
it('should silently ignore stopReason (continue: false) from hook results', async () => {
415
const conversation = createTestConversation(1);
416
const request = createMockChatRequest();
417
418
mockChatHookService.setHookResults('SessionStart', [
419
{
420
resultKind: 'success',
421
output: { hookSpecificOutput: { additionalContext: 'Context from hook 1' } },
422
},
423
{
424
resultKind: 'success',
425
output: { hookSpecificOutput: { additionalContext: 'Context from hook 2' } },
426
stopReason: 'Build failed, should be ignored',
427
},
428
{
429
resultKind: 'success',
430
output: { hookSpecificOutput: { additionalContext: 'Context from hook 3' } },
431
},
432
]);
433
434
const loop = instantiationService.createInstance(
435
TestToolCallingLoop,
436
{
437
conversation,
438
toolCallLimit: 10,
439
request,
440
}
441
);
442
disposables.add(loop);
443
444
// Should NOT throw - stopReason is silently ignored for SessionStart
445
await expect(loop.testRunStartHooks(tokenSource.token)).resolves.not.toThrow();
446
447
// Results with stopReason are skipped, only other results are processed
448
const additionalContext = loop.getAdditionalHookContext();
449
expect(additionalContext).toBe('Context from hook 1\nContext from hook 3');
450
});
451
});
452
453
describe('SessionStart hook error handling', () => {
454
it('should handle hook service throwing error gracefully', async () => {
455
const conversation = createTestConversation(1);
456
const request = createMockChatRequest();
457
458
mockChatHookService.setHookError('SessionStart', new Error('Hook service error'));
459
460
const loop = instantiationService.createInstance(
461
TestToolCallingLoop,
462
{
463
conversation,
464
toolCallLimit: 10,
465
request,
466
}
467
);
468
disposables.add(loop);
469
470
// Should not throw
471
await expect(loop.testRunStartHooks(tokenSource.token)).resolves.not.toThrow();
472
473
// additionalContext should be undefined since error occurred
474
const additionalContext = loop.getAdditionalHookContext();
475
expect(additionalContext).toBeUndefined();
476
});
477
478
it('should handle empty hook results', async () => {
479
const conversation = createTestConversation(1);
480
const request = createMockChatRequest();
481
482
mockChatHookService.setHookResults('SessionStart', []);
483
484
const loop = instantiationService.createInstance(
485
TestToolCallingLoop,
486
{
487
conversation,
488
toolCallLimit: 10,
489
request,
490
}
491
);
492
disposables.add(loop);
493
494
await loop.testRunStartHooks(tokenSource.token);
495
496
const additionalContext = loop.getAdditionalHookContext();
497
expect(additionalContext).toBeUndefined();
498
});
499
});
500
501
describe('SessionStart hook context integration', () => {
502
it('should pass additionalHookContext to prompt builder context', async () => {
503
const conversation = createTestConversation(1);
504
const request = createMockChatRequest();
505
506
mockChatHookService.setHookResults('SessionStart', [
507
{
508
resultKind: 'success',
509
output: { hookSpecificOutput: { additionalContext: 'Custom context for prompt' } },
510
},
511
]);
512
513
const loop = instantiationService.createInstance(
514
TestToolCallingLoop,
515
{
516
conversation,
517
toolCallLimit: 10,
518
request,
519
}
520
);
521
disposables.add(loop);
522
523
await loop.testRunStartHooks(tokenSource.token);
524
525
// Verify the context is available through createPromptContext
526
const promptContext = loop.getAdditionalHookContext();
527
expect(promptContext).toBe('Custom context for prompt');
528
});
529
530
it('should combine SessionStart and appended hook context', async () => {
531
const conversation = createTestConversation(1);
532
const request = createMockChatRequest();
533
534
mockChatHookService.setHookResults('SessionStart', [
535
{
536
resultKind: 'success',
537
output: { hookSpecificOutput: { additionalContext: 'Context from SessionStart' } },
538
},
539
]);
540
541
const loop = instantiationService.createInstance(
542
TestToolCallingLoop,
543
{
544
conversation,
545
toolCallLimit: 10,
546
request,
547
}
548
);
549
disposables.add(loop);
550
551
await loop.testRunStartHooks(tokenSource.token);
552
loop.appendAdditionalHookContext('Context from UserPromptSubmit');
553
554
const additionalContext = loop.getAdditionalHookContext();
555
expect(additionalContext).toBe('Context from SessionStart\nContext from UserPromptSubmit');
556
});
557
});
558
});
559
560
describe('ToolCallingLoop SubagentStart hook', () => {
561
let disposables: DisposableStore;
562
let instantiationService: IInstantiationService;
563
let mockChatHookService: MockChatHookService;
564
let tokenSource: CancellationTokenSource;
565
566
beforeEach(() => {
567
disposables = new DisposableStore();
568
mockChatHookService = new MockChatHookService();
569
570
const serviceCollection = disposables.add(createExtensionUnitTestingServices());
571
serviceCollection.define(IChatHookService, mockChatHookService);
572
serviceCollection.define(IOTelService, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })));
573
574
const accessor = serviceCollection.createTestingAccessor();
575
instantiationService = accessor.get(IInstantiationService);
576
577
tokenSource = new CancellationTokenSource();
578
disposables.add(tokenSource);
579
});
580
581
afterEach(() => {
582
disposables.dispose();
583
vi.restoreAllMocks();
584
});
585
586
describe('SubagentStart hook execution', () => {
587
it('should execute SubagentStart hook for subagent requests', async () => {
588
const conversation = createTestConversation(1);
589
const request = createMockChatRequest({
590
subAgentInvocationId: 'subagent-456',
591
subAgentName: 'PlanAgent',
592
} as Partial<ChatRequest>);
593
594
const loop = instantiationService.createInstance(
595
TestToolCallingLoop,
596
{
597
conversation,
598
toolCallLimit: 10,
599
request,
600
}
601
);
602
disposables.add(loop);
603
604
await loop.testRunStartHooks(tokenSource.token);
605
606
const subagentStartCalls = mockChatHookService.getCallsForHook('SubagentStart');
607
expect(subagentStartCalls).toHaveLength(1);
608
609
const input = subagentStartCalls[0].input as SubagentStartHookInput;
610
expect(input.agent_id).toBe('subagent-456');
611
expect(input.agent_type).toBe('PlanAgent');
612
});
613
614
it('should use default agent_type when subAgentName is not provided', async () => {
615
const conversation = createTestConversation(1);
616
const request = createMockChatRequest({
617
subAgentInvocationId: 'subagent-789',
618
// subAgentName not provided
619
} as Partial<ChatRequest>);
620
621
const loop = instantiationService.createInstance(
622
TestToolCallingLoop,
623
{
624
conversation,
625
toolCallLimit: 10,
626
request,
627
}
628
);
629
disposables.add(loop);
630
631
await loop.testRunStartHooks(tokenSource.token);
632
633
const subagentStartCalls = mockChatHookService.getCallsForHook('SubagentStart');
634
expect(subagentStartCalls).toHaveLength(1);
635
636
const input = subagentStartCalls[0].input as SubagentStartHookInput;
637
expect(input.agent_type).toBe('default');
638
});
639
640
it('should execute SubagentStart hook only once when runStartHooks and run are both called', async () => {
641
const conversation = createTestConversation(1);
642
const request = createMockChatRequest({
643
subAgentInvocationId: 'subagent-dedup',
644
subAgentName: 'DedupAgent',
645
} as Partial<ChatRequest>);
646
647
const loop = instantiationService.createInstance(
648
TestToolCallingLoop,
649
{
650
conversation,
651
toolCallLimit: 10,
652
request,
653
}
654
);
655
disposables.add(loop);
656
657
// First call: runStartHooks should execute SubagentStart once
658
await loop.testRunStartHooks(tokenSource.token);
659
660
// Second call: run() should NOT execute SubagentStart again
661
// run() will throw because fetch() is not implemented, but SubagentStart
662
// happens before fetch, so we need to verify it wasn't called again
663
await expect(loop.run(undefined, tokenSource.token)).rejects.toThrow();
664
665
// SubagentStart should have been called exactly once (from runStartHooks only)
666
const subagentStartCalls = mockChatHookService.getCallsForHook('SubagentStart');
667
expect(subagentStartCalls).toHaveLength(1);
668
});
669
});
670
671
describe('SubagentStart hook result collection', () => {
672
it('should collect additionalContext from SubagentStart hook', async () => {
673
const conversation = createTestConversation(1);
674
const request = createMockChatRequest({
675
subAgentInvocationId: 'subagent-test',
676
subAgentName: 'TestAgent',
677
} as Partial<ChatRequest>);
678
679
mockChatHookService.setHookResults('SubagentStart', [
680
{
681
resultKind: 'success',
682
output: { hookSpecificOutput: { additionalContext: 'Subagent-specific context' } },
683
},
684
]);
685
686
const loop = instantiationService.createInstance(
687
TestToolCallingLoop,
688
{
689
conversation,
690
toolCallLimit: 10,
691
request,
692
}
693
);
694
disposables.add(loop);
695
696
await loop.testRunStartHooks(tokenSource.token);
697
698
const additionalContext = loop.getAdditionalHookContext();
699
expect(additionalContext).toBe('Subagent-specific context');
700
});
701
702
it('should concatenate additionalContext from multiple SubagentStart hooks', async () => {
703
const conversation = createTestConversation(1);
704
const request = createMockChatRequest({
705
subAgentInvocationId: 'subagent-multi',
706
subAgentName: 'MultiHookAgent',
707
} as Partial<ChatRequest>);
708
709
mockChatHookService.setHookResults('SubagentStart', [
710
{
711
resultKind: 'success',
712
output: { hookSpecificOutput: { additionalContext: 'First subagent context' } },
713
},
714
{
715
resultKind: 'success',
716
output: { hookSpecificOutput: { additionalContext: 'Second subagent context' } },
717
},
718
]);
719
720
const loop = instantiationService.createInstance(
721
TestToolCallingLoop,
722
{
723
conversation,
724
toolCallLimit: 10,
725
request,
726
}
727
);
728
disposables.add(loop);
729
730
await loop.testRunStartHooks(tokenSource.token);
731
732
const additionalContext = loop.getAdditionalHookContext();
733
expect(additionalContext).toBe('First subagent context\nSecond subagent context');
734
});
735
});
736
737
describe('SubagentStart hook error handling', () => {
738
it('should handle SubagentStart hook error gracefully', async () => {
739
const conversation = createTestConversation(1);
740
const request = createMockChatRequest({
741
subAgentInvocationId: 'subagent-error',
742
subAgentName: 'ErrorAgent',
743
} as Partial<ChatRequest>);
744
745
mockChatHookService.setHookError('SubagentStart', new Error('Subagent hook failed'));
746
747
const loop = instantiationService.createInstance(
748
TestToolCallingLoop,
749
{
750
conversation,
751
toolCallLimit: 10,
752
request,
753
}
754
);
755
disposables.add(loop);
756
757
// Should not throw
758
await expect(loop.testRunStartHooks(tokenSource.token)).resolves.not.toThrow();
759
760
// additionalContext should be undefined since error occurred
761
const additionalContext = loop.getAdditionalHookContext();
762
expect(additionalContext).toBeUndefined();
763
});
764
});
765
});
766
767
describe('ToolCallingLoop Stop hook', () => {
768
let disposables: DisposableStore;
769
let instantiationService: IInstantiationService;
770
let mockChatHookService: MockChatHookService;
771
let tokenSource: CancellationTokenSource;
772
773
beforeEach(() => {
774
disposables = new DisposableStore();
775
mockChatHookService = new MockChatHookService();
776
777
const serviceCollection = disposables.add(createExtensionUnitTestingServices());
778
serviceCollection.define(IChatHookService, mockChatHookService);
779
serviceCollection.define(IOTelService, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })));
780
781
const accessor = serviceCollection.createTestingAccessor();
782
instantiationService = accessor.get(IInstantiationService);
783
784
tokenSource = new CancellationTokenSource();
785
disposables.add(tokenSource);
786
});
787
788
afterEach(() => {
789
disposables.dispose();
790
vi.restoreAllMocks();
791
});
792
793
it('should return shouldContinue=false when no hooks are configured', async () => {
794
const conversation = createTestConversation(1);
795
const request = createMockChatRequest();
796
797
const loop = instantiationService.createInstance(
798
TestToolCallingLoop,
799
{ conversation, toolCallLimit: 10, request }
800
);
801
disposables.add(loop);
802
803
const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);
804
expect(result.shouldContinue).toBe(false);
805
expect(result.reasons).toBeUndefined();
806
});
807
808
it('should block when hook returns decision=block with hookSpecificOutput wrapper', async () => {
809
const conversation = createTestConversation(1);
810
const request = createMockChatRequest();
811
812
mockChatHookService.setHookResults('Stop', [
813
{
814
resultKind: 'success',
815
output: {
816
hookSpecificOutput: {
817
hookEventName: 'Stop',
818
decision: 'block',
819
reason: 'Tests are failing. Fix the implementation until all tests pass before finishing.',
820
},
821
},
822
},
823
]);
824
825
const loop = instantiationService.createInstance(
826
TestToolCallingLoop,
827
{ conversation, toolCallLimit: 10, request }
828
);
829
disposables.add(loop);
830
831
const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);
832
expect(result.shouldContinue).toBe(true);
833
expect(result.reasons).toEqual(['Tests are failing. Fix the implementation until all tests pass before finishing.']);
834
});
835
836
it('should allow stopping when hook returns decision other than block', async () => {
837
const conversation = createTestConversation(1);
838
const request = createMockChatRequest();
839
840
mockChatHookService.setHookResults('Stop', [
841
{
842
resultKind: 'success',
843
output: {
844
hookSpecificOutput: {
845
hookEventName: 'Stop',
846
// no decision field
847
},
848
},
849
},
850
]);
851
852
const loop = instantiationService.createInstance(
853
TestToolCallingLoop,
854
{ conversation, toolCallLimit: 10, request }
855
);
856
disposables.add(loop);
857
858
const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);
859
expect(result.shouldContinue).toBe(false);
860
});
861
862
it('should allow stopping when hookSpecificOutput is missing', async () => {
863
const conversation = createTestConversation(1);
864
const request = createMockChatRequest();
865
866
mockChatHookService.setHookResults('Stop', [
867
{
868
resultKind: 'success',
869
output: {},
870
},
871
]);
872
873
const loop = instantiationService.createInstance(
874
TestToolCallingLoop,
875
{ conversation, toolCallLimit: 10, request }
876
);
877
disposables.add(loop);
878
879
const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);
880
expect(result.shouldContinue).toBe(false);
881
});
882
883
it('should not block when decision is block but reason is missing', async () => {
884
const conversation = createTestConversation(1);
885
const request = createMockChatRequest();
886
887
mockChatHookService.setHookResults('Stop', [
888
{
889
resultKind: 'success',
890
output: {
891
hookSpecificOutput: {
892
decision: 'block',
893
// no reason
894
},
895
},
896
},
897
]);
898
899
const loop = instantiationService.createInstance(
900
TestToolCallingLoop,
901
{ conversation, toolCallLimit: 10, request }
902
);
903
disposables.add(loop);
904
905
const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);
906
expect(result.shouldContinue).toBe(false);
907
});
908
909
it('should collect blocking reasons from multiple hooks', async () => {
910
const conversation = createTestConversation(1);
911
const request = createMockChatRequest();
912
913
mockChatHookService.setHookResults('Stop', [
914
{
915
resultKind: 'success',
916
output: {
917
hookSpecificOutput: {
918
decision: 'block',
919
reason: 'Tests are failing.',
920
},
921
},
922
},
923
{
924
resultKind: 'success',
925
output: {
926
hookSpecificOutput: {
927
decision: 'block',
928
reason: 'Lint errors found.',
929
},
930
},
931
},
932
]);
933
934
const loop = instantiationService.createInstance(
935
TestToolCallingLoop,
936
{ conversation, toolCallLimit: 10, request }
937
);
938
disposables.add(loop);
939
940
const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);
941
expect(result.shouldContinue).toBe(true);
942
expect(result.reasons).toContain('Tests are failing.');
943
expect(result.reasons).toContain('Lint errors found.');
944
});
945
946
it('should collect error results as blocking reasons', async () => {
947
const conversation = createTestConversation(1);
948
const request = createMockChatRequest();
949
950
mockChatHookService.setHookResults('Stop', [
951
{
952
resultKind: 'error',
953
output: 'Hook script failed with exit code 2',
954
},
955
]);
956
957
const loop = instantiationService.createInstance(
958
TestToolCallingLoop,
959
{ conversation, toolCallLimit: 10, request }
960
);
961
disposables.add(loop);
962
963
const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);
964
expect(result.shouldContinue).toBe(true);
965
expect(result.reasons).toEqual(['Hook script failed with exit code 2']);
966
});
967
968
it('should handle hook service errors gracefully', async () => {
969
const conversation = createTestConversation(1);
970
const request = createMockChatRequest();
971
972
mockChatHookService.setHookError('Stop', new Error('Service unavailable'));
973
974
const loop = instantiationService.createInstance(
975
TestToolCallingLoop,
976
{ conversation, toolCallLimit: 10, request }
977
);
978
disposables.add(loop);
979
980
const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);
981
expect(result.shouldContinue).toBe(false);
982
});
983
});
984
985
describe('ToolCallingLoop SubagentStop hook', () => {
986
let disposables: DisposableStore;
987
let instantiationService: IInstantiationService;
988
let mockChatHookService: MockChatHookService;
989
let tokenSource: CancellationTokenSource;
990
991
beforeEach(() => {
992
disposables = new DisposableStore();
993
mockChatHookService = new MockChatHookService();
994
995
const serviceCollection = disposables.add(createExtensionUnitTestingServices());
996
serviceCollection.define(IChatHookService, mockChatHookService);
997
serviceCollection.define(IOTelService, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })));
998
999
const accessor = serviceCollection.createTestingAccessor();
1000
instantiationService = accessor.get(IInstantiationService);
1001
1002
tokenSource = new CancellationTokenSource();
1003
disposables.add(tokenSource);
1004
});
1005
1006
afterEach(() => {
1007
disposables.dispose();
1008
vi.restoreAllMocks();
1009
});
1010
1011
it('should block when SubagentStop hook returns decision=block with hookSpecificOutput wrapper', async () => {
1012
const conversation = createTestConversation(1);
1013
const request = createMockChatRequest();
1014
1015
mockChatHookService.setHookResults('SubagentStop', [
1016
{
1017
resultKind: 'success',
1018
output: {
1019
hookSpecificOutput: {
1020
hookEventName: 'SubagentStop',
1021
decision: 'block',
1022
reason: 'Subagent has not completed its task.',
1023
},
1024
},
1025
},
1026
]);
1027
1028
const loop = instantiationService.createInstance(
1029
TestToolCallingLoop,
1030
{ conversation, toolCallLimit: 10, request }
1031
);
1032
disposables.add(loop);
1033
1034
const result = await loop.testExecuteSubagentStopHook(
1035
{ agent_id: 'agent-1', agent_type: 'execution', stop_hook_active: false },
1036
'session-1',
1037
tokenSource.token
1038
);
1039
expect(result.shouldContinue).toBe(true);
1040
expect(result.reasons).toEqual(['Subagent has not completed its task.']);
1041
});
1042
1043
it('should allow stopping when SubagentStop hookSpecificOutput is missing', async () => {
1044
const conversation = createTestConversation(1);
1045
const request = createMockChatRequest();
1046
1047
mockChatHookService.setHookResults('SubagentStop', [
1048
{
1049
resultKind: 'success',
1050
output: {},
1051
},
1052
]);
1053
1054
const loop = instantiationService.createInstance(
1055
TestToolCallingLoop,
1056
{ conversation, toolCallLimit: 10, request }
1057
);
1058
disposables.add(loop);
1059
1060
const result = await loop.testExecuteSubagentStopHook(
1061
{ agent_id: 'agent-1', agent_type: 'execution', stop_hook_active: false },
1062
'session-1',
1063
tokenSource.token
1064
);
1065
expect(result.shouldContinue).toBe(false);
1066
});
1067
1068
it('should handle SubagentStop hook service errors gracefully', async () => {
1069
const conversation = createTestConversation(1);
1070
const request = createMockChatRequest();
1071
1072
mockChatHookService.setHookError('SubagentStop', new Error('Service unavailable'));
1073
1074
const loop = instantiationService.createInstance(
1075
TestToolCallingLoop,
1076
{ conversation, toolCallLimit: 10, request }
1077
);
1078
disposables.add(loop);
1079
1080
const result = await loop.testExecuteSubagentStopHook(
1081
{ agent_id: 'agent-1', agent_type: 'execution', stop_hook_active: false },
1082
'session-1',
1083
tokenSource.token
1084
);
1085
expect(result.shouldContinue).toBe(false);
1086
});
1087
});
1088
1089