Path: blob/main/extensions/copilot/src/extension/intents/test/node/toolCallingLoopHooks.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';6import type { CancellationToken, ChatHookResult, ChatHookType, ChatRequest, LanguageModelToolInformation } from 'vscode';7import { IChatHookService, SessionStartHookInput, StopHookInput, SubagentStartHookInput, SubagentStopHookInput } from '../../../../platform/chat/common/chatHookService';8import { NoopOTelService } from '../../../../platform/otel/common/noopOtelService';9import { resolveOTelConfig } from '../../../../platform/otel/common/otelConfig';10import { IOTelService } from '../../../../platform/otel/common/otelService';11import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';12import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';13import { generateUuid } from '../../../../util/vs/base/common/uuid';14import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';15import { Conversation, Turn } from '../../../prompt/common/conversation';16import { IBuildPromptContext } from '../../../prompt/common/intents';17import { IBuildPromptResult, nullRenderPromptResult } from '../../../prompt/node/intents';18import { createExtensionUnitTestingServices } from '../../../test/node/services';19import { IToolCallingLoopOptions, ToolCallingLoop } from '../../node/toolCallingLoop';2021/**22* Configurable mock implementation of IChatHookService for testing.23*24* Allows tests to configure:25* - Hook results to return for specific hook types26* - Error behavior to simulate hook failures27* - Call tracking to verify hook invocations28*/29export class MockChatHookService implements IChatHookService {30declare readonly _serviceBrand: undefined;3132/** Configured results to return per hook type */33private readonly hookResults = new Map<ChatHookType, ChatHookResult[]>();3435/** Configured errors to throw per hook type */36private readonly hookErrors = new Map<ChatHookType, Error>();3738/** Tracks all hook calls for verification */39readonly hookCalls: Array<{ hookType: ChatHookType; input: unknown }> = [];4041logConfiguredHooks(): void { }4243/**44* Configure the results that should be returned when a specific hook type is executed.45*/46setHookResults(hookType: ChatHookType, results: ChatHookResult[]): void {47this.hookResults.set(hookType, results);48}4950/**51* Configure an error to throw when a specific hook type is executed.52*/53setHookError(hookType: ChatHookType, error: Error): void {54this.hookErrors.set(hookType, error);55}5657/**58* Clear all hook calls for test isolation.59*/60clearCalls(): void {61this.hookCalls.length = 0;62}6364/**65* Get all calls for a specific hook type.66*/67getCallsForHook(hookType: ChatHookType): Array<{ hookType: ChatHookType; input: unknown }> {68return this.hookCalls.filter(call => call.hookType === hookType);69}7071async executeHook(hookType: ChatHookType, _hooks: unknown, input: unknown, _sessionId?: string, _token?: CancellationToken): Promise<ChatHookResult[]> {72// Track the call73this.hookCalls.push({ hookType, input });7475// Check if we should throw an error76const error = this.hookErrors.get(hookType);77if (error) {78throw error;79}8081// Return configured results or empty array82return this.hookResults.get(hookType) || [];83}8485async executePreToolUseHook(): Promise<undefined> {86return undefined;87}8889async executePostToolUseHook(): Promise<undefined> {90return undefined;91}92}9394/**95* Minimal concrete implementation of ToolCallingLoop for testing.96* Exposes the abstract base class methods for testing while providing97* simple implementations for the abstract methods.98*/99class TestToolCallingLoop extends ToolCallingLoop<IToolCallingLoopOptions> {100public lastBuildPromptContext: IBuildPromptContext | undefined;101public additionalContextValue: string | undefined;102103protected override async buildPrompt(buildPromptContext: IBuildPromptContext): Promise<IBuildPromptResult> {104this.lastBuildPromptContext = buildPromptContext;105return nullRenderPromptResult();106}107108protected override async getAvailableTools(): Promise<LanguageModelToolInformation[]> {109return [];110}111112protected override async fetch(): Promise<never> {113throw new Error('fetch should not be called in these tests');114}115116// Expose the protected method for testing117public async testRunStartHooks(token: CancellationToken): Promise<void> {118await this.runStartHooks(undefined, token);119}120121// Expose the protected stop hook methods for testing122public async testExecuteStopHook(input: StopHookInput, sessionId: string, token: CancellationToken) {123return this.executeStopHook(input, sessionId, undefined, token);124}125126public async testExecuteSubagentStopHook(input: SubagentStopHookInput, sessionId: string, token: CancellationToken) {127return this.executeSubagentStopHook(input, sessionId, undefined, token);128}129130// Expose additionalHookContext for verification131public getAdditionalHookContext(): string | undefined {132// Access via createPromptContext which uses this.additionalHookContext133const context = this.createPromptContext([], undefined);134return context.additionalHookContext;135}136}137138function createMockChatRequest(overrides: Partial<ChatRequest> = {}): ChatRequest {139return {140prompt: 'test prompt',141command: undefined,142references: [],143location: 1, // ChatLocation.Panel144location2: undefined,145attempt: 0,146enableCommandDetection: false,147isParticipantDetected: false,148toolReferences: [],149toolInvocationToken: {} as ChatRequest['toolInvocationToken'],150model: null!,151tools: new Map(),152id: generateUuid(),153sessionId: generateUuid(),154...overrides,155} as ChatRequest;156}157158function createTestConversation(turnCount: number = 1): Conversation {159const turns: Turn[] = [];160for (let i = 0; i < turnCount; i++) {161turns.push(new Turn(162generateUuid(),163{ message: `test message ${i}`, type: 'user' }164));165}166return new Conversation(generateUuid(), turns);167}168169describe('ToolCallingLoop SessionStart hook', () => {170let disposables: DisposableStore;171let instantiationService: IInstantiationService;172let mockChatHookService: MockChatHookService;173let tokenSource: CancellationTokenSource;174175beforeEach(() => {176disposables = new DisposableStore();177mockChatHookService = new MockChatHookService();178179const serviceCollection = disposables.add(createExtensionUnitTestingServices());180// Must define the mock service BEFORE creating the accessor181serviceCollection.define(IChatHookService, mockChatHookService);182serviceCollection.define(IOTelService, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })));183184const accessor = serviceCollection.createTestingAccessor();185instantiationService = accessor.get(IInstantiationService);186187tokenSource = new CancellationTokenSource();188disposables.add(tokenSource);189});190191afterEach(() => {192disposables.dispose();193vi.restoreAllMocks();194});195196describe('SessionStart hook execution conditions', () => {197it('should execute SessionStart hook on the first turn of regular sessions', async () => {198const conversation = createTestConversation(1); // First turn199const request = createMockChatRequest({200model: { id: 'test-model-id' } as ChatRequest['model'],201participant: 'test-agent',202} as unknown as Partial<ChatRequest>);203204const loop = instantiationService.createInstance(205TestToolCallingLoop,206{207conversation,208toolCallLimit: 10,209request,210}211);212disposables.add(loop);213214// Spy on the hook service215vi.spyOn(mockChatHookService, 'executeHook');216217await loop.testRunStartHooks(tokenSource.token);218219const sessionStartCalls = mockChatHookService.getCallsForHook('SessionStart');220expect(sessionStartCalls).toHaveLength(1);221const input = sessionStartCalls[0].input as SessionStartHookInput;222expect(input).toMatchObject({223source: 'new',224model: 'test-model-id',225agent_type: 'test-agent',226});227});228229it('should NOT execute SessionStart hook on subsequent turns', async () => {230const conversation = createTestConversation(3); // Third turn231const request = createMockChatRequest();232233const loop = instantiationService.createInstance(234TestToolCallingLoop,235{236conversation,237toolCallLimit: 10,238request,239}240);241disposables.add(loop);242243await loop.testRunStartHooks(tokenSource.token);244245const sessionStartCalls = mockChatHookService.getCallsForHook('SessionStart');246expect(sessionStartCalls).toHaveLength(0);247});248249it('should NOT execute SessionStart hook for subagent requests', async () => {250const conversation = createTestConversation(1); // First turn251const request = createMockChatRequest({252subAgentInvocationId: 'subagent-123',253subAgentName: 'TestSubagent',254} as Partial<ChatRequest>);255256const loop = instantiationService.createInstance(257TestToolCallingLoop,258{259conversation,260toolCallLimit: 10,261request,262}263);264disposables.add(loop);265266await loop.testRunStartHooks(tokenSource.token);267268// SessionStart should NOT be called for subagents269const sessionStartCalls = mockChatHookService.getCallsForHook('SessionStart');270expect(sessionStartCalls).toHaveLength(0);271272// SubagentStart should be called instead273const subagentStartCalls = mockChatHookService.getCallsForHook('SubagentStart');274expect(subagentStartCalls).toHaveLength(1);275});276});277278describe('SessionStart hook result collection', () => {279it('should collect additionalContext from single hook result', async () => {280const conversation = createTestConversation(1);281const request = createMockChatRequest();282283mockChatHookService.setHookResults('SessionStart', [284{285resultKind: 'success',286output: { hookSpecificOutput: { additionalContext: 'Context from hook 1' } },287},288]);289290const loop = instantiationService.createInstance(291TestToolCallingLoop,292{293conversation,294toolCallLimit: 10,295request,296}297);298disposables.add(loop);299300await loop.testRunStartHooks(tokenSource.token);301302const additionalContext = loop.getAdditionalHookContext();303expect(additionalContext).toBe('Context from hook 1');304});305306it('should concatenate additionalContext from multiple hook results', async () => {307const conversation = createTestConversation(1);308const request = createMockChatRequest();309310mockChatHookService.setHookResults('SessionStart', [311{312resultKind: 'success',313output: { hookSpecificOutput: { additionalContext: 'Context from hook 1' } },314},315{316resultKind: 'success',317output: { hookSpecificOutput: { additionalContext: 'Context from hook 2' } },318},319{320resultKind: 'success',321output: { hookSpecificOutput: { additionalContext: 'Context from hook 3' } },322},323]);324325const loop = instantiationService.createInstance(326TestToolCallingLoop,327{328conversation,329toolCallLimit: 10,330request,331}332);333disposables.add(loop);334335await loop.testRunStartHooks(tokenSource.token);336337const additionalContext = loop.getAdditionalHookContext();338expect(additionalContext).toBe('Context from hook 1\nContext from hook 2\nContext from hook 3');339});340341it('should ignore hook results with no additionalContext', async () => {342const conversation = createTestConversation(1);343const request = createMockChatRequest();344345mockChatHookService.setHookResults('SessionStart', [346{347resultKind: 'success',348output: { hookSpecificOutput: { additionalContext: 'Context from hook 1' } },349},350{351resultKind: 'success',352output: {}, // No additionalContext353},354{355resultKind: 'success',356output: { hookSpecificOutput: { additionalContext: 'Context from hook 3' } },357},358]);359360const loop = instantiationService.createInstance(361TestToolCallingLoop,362{363conversation,364toolCallLimit: 10,365request,366}367);368disposables.add(loop);369370await loop.testRunStartHooks(tokenSource.token);371372const additionalContext = loop.getAdditionalHookContext();373expect(additionalContext).toBe('Context from hook 1\nContext from hook 3');374});375376it('should silently ignore failed hook results (blocking errors are ignored)', async () => {377const conversation = createTestConversation(1);378const request = createMockChatRequest();379380mockChatHookService.setHookResults('SessionStart', [381{382resultKind: 'success',383output: { hookSpecificOutput: { additionalContext: 'Context from hook 1' } },384},385{386resultKind: 'error',387output: 'Hook error message',388},389{390resultKind: 'success',391output: { hookSpecificOutput: { additionalContext: 'Context from hook 3' } },392},393]);394395const loop = instantiationService.createInstance(396TestToolCallingLoop,397{398conversation,399toolCallLimit: 10,400request,401}402);403disposables.add(loop);404405// Should NOT throw - blocking errors are silently ignored for SessionStart406await expect(loop.testRunStartHooks(tokenSource.token)).resolves.not.toThrow();407408// Only non-error results should be processed409const additionalContext = loop.getAdditionalHookContext();410expect(additionalContext).toBe('Context from hook 1\nContext from hook 3');411});412413it('should silently ignore stopReason (continue: false) from hook results', async () => {414const conversation = createTestConversation(1);415const request = createMockChatRequest();416417mockChatHookService.setHookResults('SessionStart', [418{419resultKind: 'success',420output: { hookSpecificOutput: { additionalContext: 'Context from hook 1' } },421},422{423resultKind: 'success',424output: { hookSpecificOutput: { additionalContext: 'Context from hook 2' } },425stopReason: 'Build failed, should be ignored',426},427{428resultKind: 'success',429output: { hookSpecificOutput: { additionalContext: 'Context from hook 3' } },430},431]);432433const loop = instantiationService.createInstance(434TestToolCallingLoop,435{436conversation,437toolCallLimit: 10,438request,439}440);441disposables.add(loop);442443// Should NOT throw - stopReason is silently ignored for SessionStart444await expect(loop.testRunStartHooks(tokenSource.token)).resolves.not.toThrow();445446// Results with stopReason are skipped, only other results are processed447const additionalContext = loop.getAdditionalHookContext();448expect(additionalContext).toBe('Context from hook 1\nContext from hook 3');449});450});451452describe('SessionStart hook error handling', () => {453it('should handle hook service throwing error gracefully', async () => {454const conversation = createTestConversation(1);455const request = createMockChatRequest();456457mockChatHookService.setHookError('SessionStart', new Error('Hook service error'));458459const loop = instantiationService.createInstance(460TestToolCallingLoop,461{462conversation,463toolCallLimit: 10,464request,465}466);467disposables.add(loop);468469// Should not throw470await expect(loop.testRunStartHooks(tokenSource.token)).resolves.not.toThrow();471472// additionalContext should be undefined since error occurred473const additionalContext = loop.getAdditionalHookContext();474expect(additionalContext).toBeUndefined();475});476477it('should handle empty hook results', async () => {478const conversation = createTestConversation(1);479const request = createMockChatRequest();480481mockChatHookService.setHookResults('SessionStart', []);482483const loop = instantiationService.createInstance(484TestToolCallingLoop,485{486conversation,487toolCallLimit: 10,488request,489}490);491disposables.add(loop);492493await loop.testRunStartHooks(tokenSource.token);494495const additionalContext = loop.getAdditionalHookContext();496expect(additionalContext).toBeUndefined();497});498});499500describe('SessionStart hook context integration', () => {501it('should pass additionalHookContext to prompt builder context', async () => {502const conversation = createTestConversation(1);503const request = createMockChatRequest();504505mockChatHookService.setHookResults('SessionStart', [506{507resultKind: 'success',508output: { hookSpecificOutput: { additionalContext: 'Custom context for prompt' } },509},510]);511512const loop = instantiationService.createInstance(513TestToolCallingLoop,514{515conversation,516toolCallLimit: 10,517request,518}519);520disposables.add(loop);521522await loop.testRunStartHooks(tokenSource.token);523524// Verify the context is available through createPromptContext525const promptContext = loop.getAdditionalHookContext();526expect(promptContext).toBe('Custom context for prompt');527});528529it('should combine SessionStart and appended hook context', async () => {530const conversation = createTestConversation(1);531const request = createMockChatRequest();532533mockChatHookService.setHookResults('SessionStart', [534{535resultKind: 'success',536output: { hookSpecificOutput: { additionalContext: 'Context from SessionStart' } },537},538]);539540const loop = instantiationService.createInstance(541TestToolCallingLoop,542{543conversation,544toolCallLimit: 10,545request,546}547);548disposables.add(loop);549550await loop.testRunStartHooks(tokenSource.token);551loop.appendAdditionalHookContext('Context from UserPromptSubmit');552553const additionalContext = loop.getAdditionalHookContext();554expect(additionalContext).toBe('Context from SessionStart\nContext from UserPromptSubmit');555});556});557});558559describe('ToolCallingLoop SubagentStart hook', () => {560let disposables: DisposableStore;561let instantiationService: IInstantiationService;562let mockChatHookService: MockChatHookService;563let tokenSource: CancellationTokenSource;564565beforeEach(() => {566disposables = new DisposableStore();567mockChatHookService = new MockChatHookService();568569const serviceCollection = disposables.add(createExtensionUnitTestingServices());570serviceCollection.define(IChatHookService, mockChatHookService);571serviceCollection.define(IOTelService, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })));572573const accessor = serviceCollection.createTestingAccessor();574instantiationService = accessor.get(IInstantiationService);575576tokenSource = new CancellationTokenSource();577disposables.add(tokenSource);578});579580afterEach(() => {581disposables.dispose();582vi.restoreAllMocks();583});584585describe('SubagentStart hook execution', () => {586it('should execute SubagentStart hook for subagent requests', async () => {587const conversation = createTestConversation(1);588const request = createMockChatRequest({589subAgentInvocationId: 'subagent-456',590subAgentName: 'PlanAgent',591} as Partial<ChatRequest>);592593const loop = instantiationService.createInstance(594TestToolCallingLoop,595{596conversation,597toolCallLimit: 10,598request,599}600);601disposables.add(loop);602603await loop.testRunStartHooks(tokenSource.token);604605const subagentStartCalls = mockChatHookService.getCallsForHook('SubagentStart');606expect(subagentStartCalls).toHaveLength(1);607608const input = subagentStartCalls[0].input as SubagentStartHookInput;609expect(input.agent_id).toBe('subagent-456');610expect(input.agent_type).toBe('PlanAgent');611});612613it('should use default agent_type when subAgentName is not provided', async () => {614const conversation = createTestConversation(1);615const request = createMockChatRequest({616subAgentInvocationId: 'subagent-789',617// subAgentName not provided618} as Partial<ChatRequest>);619620const loop = instantiationService.createInstance(621TestToolCallingLoop,622{623conversation,624toolCallLimit: 10,625request,626}627);628disposables.add(loop);629630await loop.testRunStartHooks(tokenSource.token);631632const subagentStartCalls = mockChatHookService.getCallsForHook('SubagentStart');633expect(subagentStartCalls).toHaveLength(1);634635const input = subagentStartCalls[0].input as SubagentStartHookInput;636expect(input.agent_type).toBe('default');637});638639it('should execute SubagentStart hook only once when runStartHooks and run are both called', async () => {640const conversation = createTestConversation(1);641const request = createMockChatRequest({642subAgentInvocationId: 'subagent-dedup',643subAgentName: 'DedupAgent',644} as Partial<ChatRequest>);645646const loop = instantiationService.createInstance(647TestToolCallingLoop,648{649conversation,650toolCallLimit: 10,651request,652}653);654disposables.add(loop);655656// First call: runStartHooks should execute SubagentStart once657await loop.testRunStartHooks(tokenSource.token);658659// Second call: run() should NOT execute SubagentStart again660// run() will throw because fetch() is not implemented, but SubagentStart661// happens before fetch, so we need to verify it wasn't called again662await expect(loop.run(undefined, tokenSource.token)).rejects.toThrow();663664// SubagentStart should have been called exactly once (from runStartHooks only)665const subagentStartCalls = mockChatHookService.getCallsForHook('SubagentStart');666expect(subagentStartCalls).toHaveLength(1);667});668});669670describe('SubagentStart hook result collection', () => {671it('should collect additionalContext from SubagentStart hook', async () => {672const conversation = createTestConversation(1);673const request = createMockChatRequest({674subAgentInvocationId: 'subagent-test',675subAgentName: 'TestAgent',676} as Partial<ChatRequest>);677678mockChatHookService.setHookResults('SubagentStart', [679{680resultKind: 'success',681output: { hookSpecificOutput: { additionalContext: 'Subagent-specific context' } },682},683]);684685const loop = instantiationService.createInstance(686TestToolCallingLoop,687{688conversation,689toolCallLimit: 10,690request,691}692);693disposables.add(loop);694695await loop.testRunStartHooks(tokenSource.token);696697const additionalContext = loop.getAdditionalHookContext();698expect(additionalContext).toBe('Subagent-specific context');699});700701it('should concatenate additionalContext from multiple SubagentStart hooks', async () => {702const conversation = createTestConversation(1);703const request = createMockChatRequest({704subAgentInvocationId: 'subagent-multi',705subAgentName: 'MultiHookAgent',706} as Partial<ChatRequest>);707708mockChatHookService.setHookResults('SubagentStart', [709{710resultKind: 'success',711output: { hookSpecificOutput: { additionalContext: 'First subagent context' } },712},713{714resultKind: 'success',715output: { hookSpecificOutput: { additionalContext: 'Second subagent context' } },716},717]);718719const loop = instantiationService.createInstance(720TestToolCallingLoop,721{722conversation,723toolCallLimit: 10,724request,725}726);727disposables.add(loop);728729await loop.testRunStartHooks(tokenSource.token);730731const additionalContext = loop.getAdditionalHookContext();732expect(additionalContext).toBe('First subagent context\nSecond subagent context');733});734});735736describe('SubagentStart hook error handling', () => {737it('should handle SubagentStart hook error gracefully', async () => {738const conversation = createTestConversation(1);739const request = createMockChatRequest({740subAgentInvocationId: 'subagent-error',741subAgentName: 'ErrorAgent',742} as Partial<ChatRequest>);743744mockChatHookService.setHookError('SubagentStart', new Error('Subagent hook failed'));745746const loop = instantiationService.createInstance(747TestToolCallingLoop,748{749conversation,750toolCallLimit: 10,751request,752}753);754disposables.add(loop);755756// Should not throw757await expect(loop.testRunStartHooks(tokenSource.token)).resolves.not.toThrow();758759// additionalContext should be undefined since error occurred760const additionalContext = loop.getAdditionalHookContext();761expect(additionalContext).toBeUndefined();762});763});764});765766describe('ToolCallingLoop Stop hook', () => {767let disposables: DisposableStore;768let instantiationService: IInstantiationService;769let mockChatHookService: MockChatHookService;770let tokenSource: CancellationTokenSource;771772beforeEach(() => {773disposables = new DisposableStore();774mockChatHookService = new MockChatHookService();775776const serviceCollection = disposables.add(createExtensionUnitTestingServices());777serviceCollection.define(IChatHookService, mockChatHookService);778serviceCollection.define(IOTelService, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })));779780const accessor = serviceCollection.createTestingAccessor();781instantiationService = accessor.get(IInstantiationService);782783tokenSource = new CancellationTokenSource();784disposables.add(tokenSource);785});786787afterEach(() => {788disposables.dispose();789vi.restoreAllMocks();790});791792it('should return shouldContinue=false when no hooks are configured', async () => {793const conversation = createTestConversation(1);794const request = createMockChatRequest();795796const loop = instantiationService.createInstance(797TestToolCallingLoop,798{ conversation, toolCallLimit: 10, request }799);800disposables.add(loop);801802const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);803expect(result.shouldContinue).toBe(false);804expect(result.reasons).toBeUndefined();805});806807it('should block when hook returns decision=block with hookSpecificOutput wrapper', async () => {808const conversation = createTestConversation(1);809const request = createMockChatRequest();810811mockChatHookService.setHookResults('Stop', [812{813resultKind: 'success',814output: {815hookSpecificOutput: {816hookEventName: 'Stop',817decision: 'block',818reason: 'Tests are failing. Fix the implementation until all tests pass before finishing.',819},820},821},822]);823824const loop = instantiationService.createInstance(825TestToolCallingLoop,826{ conversation, toolCallLimit: 10, request }827);828disposables.add(loop);829830const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);831expect(result.shouldContinue).toBe(true);832expect(result.reasons).toEqual(['Tests are failing. Fix the implementation until all tests pass before finishing.']);833});834835it('should allow stopping when hook returns decision other than block', async () => {836const conversation = createTestConversation(1);837const request = createMockChatRequest();838839mockChatHookService.setHookResults('Stop', [840{841resultKind: 'success',842output: {843hookSpecificOutput: {844hookEventName: 'Stop',845// no decision field846},847},848},849]);850851const loop = instantiationService.createInstance(852TestToolCallingLoop,853{ conversation, toolCallLimit: 10, request }854);855disposables.add(loop);856857const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);858expect(result.shouldContinue).toBe(false);859});860861it('should allow stopping when hookSpecificOutput is missing', async () => {862const conversation = createTestConversation(1);863const request = createMockChatRequest();864865mockChatHookService.setHookResults('Stop', [866{867resultKind: 'success',868output: {},869},870]);871872const loop = instantiationService.createInstance(873TestToolCallingLoop,874{ conversation, toolCallLimit: 10, request }875);876disposables.add(loop);877878const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);879expect(result.shouldContinue).toBe(false);880});881882it('should not block when decision is block but reason is missing', async () => {883const conversation = createTestConversation(1);884const request = createMockChatRequest();885886mockChatHookService.setHookResults('Stop', [887{888resultKind: 'success',889output: {890hookSpecificOutput: {891decision: 'block',892// no reason893},894},895},896]);897898const loop = instantiationService.createInstance(899TestToolCallingLoop,900{ conversation, toolCallLimit: 10, request }901);902disposables.add(loop);903904const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);905expect(result.shouldContinue).toBe(false);906});907908it('should collect blocking reasons from multiple hooks', async () => {909const conversation = createTestConversation(1);910const request = createMockChatRequest();911912mockChatHookService.setHookResults('Stop', [913{914resultKind: 'success',915output: {916hookSpecificOutput: {917decision: 'block',918reason: 'Tests are failing.',919},920},921},922{923resultKind: 'success',924output: {925hookSpecificOutput: {926decision: 'block',927reason: 'Lint errors found.',928},929},930},931]);932933const loop = instantiationService.createInstance(934TestToolCallingLoop,935{ conversation, toolCallLimit: 10, request }936);937disposables.add(loop);938939const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);940expect(result.shouldContinue).toBe(true);941expect(result.reasons).toContain('Tests are failing.');942expect(result.reasons).toContain('Lint errors found.');943});944945it('should collect error results as blocking reasons', async () => {946const conversation = createTestConversation(1);947const request = createMockChatRequest();948949mockChatHookService.setHookResults('Stop', [950{951resultKind: 'error',952output: 'Hook script failed with exit code 2',953},954]);955956const loop = instantiationService.createInstance(957TestToolCallingLoop,958{ conversation, toolCallLimit: 10, request }959);960disposables.add(loop);961962const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);963expect(result.shouldContinue).toBe(true);964expect(result.reasons).toEqual(['Hook script failed with exit code 2']);965});966967it('should handle hook service errors gracefully', async () => {968const conversation = createTestConversation(1);969const request = createMockChatRequest();970971mockChatHookService.setHookError('Stop', new Error('Service unavailable'));972973const loop = instantiationService.createInstance(974TestToolCallingLoop,975{ conversation, toolCallLimit: 10, request }976);977disposables.add(loop);978979const result = await loop.testExecuteStopHook({ stop_hook_active: false }, 'session-1', tokenSource.token);980expect(result.shouldContinue).toBe(false);981});982});983984describe('ToolCallingLoop SubagentStop hook', () => {985let disposables: DisposableStore;986let instantiationService: IInstantiationService;987let mockChatHookService: MockChatHookService;988let tokenSource: CancellationTokenSource;989990beforeEach(() => {991disposables = new DisposableStore();992mockChatHookService = new MockChatHookService();993994const serviceCollection = disposables.add(createExtensionUnitTestingServices());995serviceCollection.define(IChatHookService, mockChatHookService);996serviceCollection.define(IOTelService, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })));997998const accessor = serviceCollection.createTestingAccessor();999instantiationService = accessor.get(IInstantiationService);10001001tokenSource = new CancellationTokenSource();1002disposables.add(tokenSource);1003});10041005afterEach(() => {1006disposables.dispose();1007vi.restoreAllMocks();1008});10091010it('should block when SubagentStop hook returns decision=block with hookSpecificOutput wrapper', async () => {1011const conversation = createTestConversation(1);1012const request = createMockChatRequest();10131014mockChatHookService.setHookResults('SubagentStop', [1015{1016resultKind: 'success',1017output: {1018hookSpecificOutput: {1019hookEventName: 'SubagentStop',1020decision: 'block',1021reason: 'Subagent has not completed its task.',1022},1023},1024},1025]);10261027const loop = instantiationService.createInstance(1028TestToolCallingLoop,1029{ conversation, toolCallLimit: 10, request }1030);1031disposables.add(loop);10321033const result = await loop.testExecuteSubagentStopHook(1034{ agent_id: 'agent-1', agent_type: 'execution', stop_hook_active: false },1035'session-1',1036tokenSource.token1037);1038expect(result.shouldContinue).toBe(true);1039expect(result.reasons).toEqual(['Subagent has not completed its task.']);1040});10411042it('should allow stopping when SubagentStop hookSpecificOutput is missing', async () => {1043const conversation = createTestConversation(1);1044const request = createMockChatRequest();10451046mockChatHookService.setHookResults('SubagentStop', [1047{1048resultKind: 'success',1049output: {},1050},1051]);10521053const loop = instantiationService.createInstance(1054TestToolCallingLoop,1055{ conversation, toolCallLimit: 10, request }1056);1057disposables.add(loop);10581059const result = await loop.testExecuteSubagentStopHook(1060{ agent_id: 'agent-1', agent_type: 'execution', stop_hook_active: false },1061'session-1',1062tokenSource.token1063);1064expect(result.shouldContinue).toBe(false);1065});10661067it('should handle SubagentStop hook service errors gracefully', async () => {1068const conversation = createTestConversation(1);1069const request = createMockChatRequest();10701071mockChatHookService.setHookError('SubagentStop', new Error('Service unavailable'));10721073const loop = instantiationService.createInstance(1074TestToolCallingLoop,1075{ conversation, toolCallLimit: 10, request }1076);1077disposables.add(loop);10781079const result = await loop.testExecuteSubagentStopHook(1080{ agent_id: 'agent-1', agent_type: 'execution', stop_hook_active: false },1081'session-1',1082tokenSource.token1083);1084expect(result.shouldContinue).toBe(false);1085});1086});108710881089