Path: blob/main/extensions/copilot/src/extension/intents/test/node/hookResultProcessor.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 { beforeEach, describe, expect, it, vi } from 'vitest';6import type { ChatResponseStream } from 'vscode';7import { TestLogService } from '../../../../platform/testing/common/testLogService';8import { ChatHookType } from '../../../../vscodeTypes';9import { formatHookErrorMessage, HookAbortError, HookResult, isHookAbortError, processHookResults, ProcessHookResultsOptions } from '../../node/hookResultProcessor';1011/**12* Mock implementation of ChatResponseStream that tracks hookProgress calls.13*/14class MockChatResponseStream {15readonly hookProgressCalls: Array<{ hookType: ChatHookType; stopReason?: string; systemMessage?: string }> = [];1617hookProgress(hookType: ChatHookType, stopReason?: string, systemMessage?: string): void {18this.hookProgressCalls.push({ hookType, stopReason, systemMessage });19}20}2122describe('hookResultProcessor', () => {23let logService: TestLogService;24let mockStream: MockChatResponseStream;2526beforeEach(() => {27logService = new TestLogService();28mockStream = new MockChatResponseStream();29});3031describe('HookAbortError', () => {32it('should create error with hookType and stopReason', () => {33const error = new HookAbortError('UserPromptSubmit', 'Build failed');34expect(error.hookType).toBe('UserPromptSubmit');35expect(error.stopReason).toBe('Build failed');36expect(error.message).toBe('Hook UserPromptSubmit aborted: Build failed');37expect(error.name).toBe('HookAbortError');38});3940it('should be identifiable via isHookAbortError', () => {41const hookError = new HookAbortError('Stop', 'reason');42expect(isHookAbortError(hookError)).toBe(true);43expect(isHookAbortError(new Error('regular error'))).toBe(false);44expect(isHookAbortError(null)).toBe(false);45expect(isHookAbortError(undefined)).toBe(false);46});47});4849describe('stopReason handling for all hook types', () => {50const hookTypes: ChatHookType[] = [51'UserPromptSubmit',52'SessionStart',53'Stop',54'SubagentStart',55'SubagentStop',56];5758hookTypes.forEach((hookType) => {59describe(`${hookType} hook`, () => {60it('should throw HookAbortError when stopReason is present', () => {61const results: HookResult[] = [62{63resultKind: 'success',64output: {},65stopReason: 'Build failed, fix errors before continuing',66},67];6869const onSuccess = vi.fn();70const options: ProcessHookResultsOptions = {71hookType,72results,73outputStream: mockStream as unknown as ChatResponseStream,74logService,75onSuccess,76};7778expect(() => processHookResults(options)).toThrow(HookAbortError);79expect(() => processHookResults(options)).toThrow(80`Hook ${hookType} aborted: Build failed, fix errors before continuing`81);82// Verify hookProgress is called with the stopReason83expect(mockStream.hookProgressCalls.length).toBeGreaterThan(0);84expect(mockStream.hookProgressCalls[0].hookType).toBe(hookType);85expect(mockStream.hookProgressCalls[0].stopReason).toContain('Build failed, fix errors before continuing');86});8788it('should not call onSuccess when stopReason is present', () => {89const results: HookResult[] = [90{91resultKind: 'success',92output: { someData: 'value' },93stopReason: 'Processing blocked',94},95];9697const onSuccess = vi.fn();98const options: ProcessHookResultsOptions = {99hookType,100results,101outputStream: mockStream as unknown as ChatResponseStream,102logService,103onSuccess,104};105106try {107processHookResults(options);108} catch {109// Expected to throw110}111112expect(onSuccess).not.toHaveBeenCalled();113});114115it('should throw HookAbortError immediately and stop processing remaining results', () => {116const results: HookResult[] = [117{118resultKind: 'success',119output: { index: 0 },120stopReason: 'First hook aborted',121},122{123resultKind: 'success',124output: { index: 1 },125},126];127128const onSuccess = vi.fn();129const options: ProcessHookResultsOptions = {130hookType,131results,132outputStream: mockStream as unknown as ChatResponseStream,133logService,134onSuccess,135};136137expect(() => processHookResults(options)).toThrow('First hook aborted');138expect(onSuccess).not.toHaveBeenCalled();139// Verify hookProgress is called with the stopReason140expect(mockStream.hookProgressCalls).toHaveLength(1);141expect(mockStream.hookProgressCalls[0].hookType).toBe(hookType);142expect(mockStream.hookProgressCalls[0].stopReason).toContain('First hook aborted');143});144145it('should throw HookAbortError when stopReason is empty string (continue: false)', () => {146const results: HookResult[] = [147{148resultKind: 'success',149output: {},150stopReason: '',151},152];153154const onSuccess = vi.fn();155const options: ProcessHookResultsOptions = {156hookType,157results,158outputStream: mockStream as unknown as ChatResponseStream,159logService,160onSuccess,161};162163expect(() => processHookResults(options)).toThrow(HookAbortError);164expect(onSuccess).not.toHaveBeenCalled();165});166});167});168});169170describe('UserPromptSubmit exit codes', () => {171// Exit code 0 - stdout shown to Claude (onSuccess called)172it('should call onSuccess with output on exit code 0 (success)', () => {173const results: HookResult[] = [174{175resultKind: 'success',176output: 'Additional context for Claude',177},178];179180const onSuccess = vi.fn();181processHookResults({182hookType: 'UserPromptSubmit',183results,184outputStream: mockStream as unknown as ChatResponseStream,185logService,186onSuccess,187});188189expect(onSuccess).toHaveBeenCalledWith('Additional context for Claude');190});191192// Exit code 2 - block processing, erase original prompt, and show stderr to user only193it('should throw HookAbortError and push hookProgress on exit code 2 (error)', () => {194const results: HookResult[] = [195{196resultKind: 'error',197output: 'Validation failed: missing required field',198},199];200201const onSuccess = vi.fn();202expect(() =>203processHookResults({204hookType: 'UserPromptSubmit',205results,206outputStream: mockStream as unknown as ChatResponseStream,207logService,208onSuccess,209})210).toThrow(HookAbortError);211212expect(onSuccess).not.toHaveBeenCalled();213expect(mockStream.hookProgressCalls).toHaveLength(1);214expect(mockStream.hookProgressCalls[0].hookType).toBe('UserPromptSubmit');215expect(mockStream.hookProgressCalls[0].stopReason).toContain('Validation failed: missing required field');216});217218// Other exit codes - show stderr to user only (warnings flow)219it('should show warning to user on other exit codes', () => {220const results: HookResult[] = [221{222resultKind: 'warning',223warningMessage: 'Process exited with code 1: Some warning',224output: undefined,225},226];227228const onSuccess = vi.fn();229processHookResults({230hookType: 'UserPromptSubmit',231results,232outputStream: mockStream as unknown as ChatResponseStream,233logService,234onSuccess,235});236237expect(onSuccess).not.toHaveBeenCalled();238expect(mockStream.hookProgressCalls).toHaveLength(1);239expect(mockStream.hookProgressCalls[0].hookType).toBe('UserPromptSubmit');240expect(mockStream.hookProgressCalls[0].systemMessage).toBe('Process exited with code 1: Some warning');241});242243it('should aggregate multiple warnings', () => {244const results: HookResult[] = [245{ resultKind: 'warning', warningMessage: 'Warning 1', output: undefined },246{ resultKind: 'warning', warningMessage: 'Warning 2', output: undefined },247];248249processHookResults({250hookType: 'UserPromptSubmit',251results,252outputStream: mockStream as unknown as ChatResponseStream,253logService,254onSuccess: () => { },255});256257expect(mockStream.hookProgressCalls).toHaveLength(1);258expect(mockStream.hookProgressCalls[0].systemMessage).toContain('1. Warning 1');259expect(mockStream.hookProgressCalls[0].systemMessage).toContain('2. Warning 2');260});261});262263describe('SessionStart exit codes', () => {264// Exit code 0 - stdout shown to Claude265it('should call onSuccess with output on exit code 0 (success)', () => {266const results: HookResult[] = [267{268resultKind: 'success',269output: { additionalContext: 'Session context data' },270},271];272273const onSuccess = vi.fn();274processHookResults({275hookType: 'SessionStart',276results,277outputStream: mockStream as unknown as ChatResponseStream,278logService,279onSuccess,280});281282expect(onSuccess).toHaveBeenCalledWith({ additionalContext: 'Session context data' });283});284285// Blocking errors are silently ignored (ignoreErrors: true) - no throw, no hookProgress286it('should silently ignore errors when ignoreErrors is true (no throw, no hookProgress)', () => {287const results: HookResult[] = [288{289resultKind: 'error',290output: 'Session hook error',291},292];293294const onSuccess = vi.fn();295expect(() =>296processHookResults({297hookType: 'SessionStart',298results,299outputStream: mockStream as unknown as ChatResponseStream,300logService,301onSuccess,302ignoreErrors: true,303})304).not.toThrow();305306expect(onSuccess).not.toHaveBeenCalled();307// hookProgress should NOT be called when ignoreErrors is true308expect(mockStream.hookProgressCalls).toHaveLength(0);309});310311// stopReason (continue: false) is silently ignored (ignoreErrors: true)312it('should silently ignore stopReason when ignoreErrors is true', () => {313const results: HookResult[] = [314{315resultKind: 'success',316output: { additionalContext: 'Some context' },317stopReason: 'Build failed, should be ignored',318},319];320321const onSuccess = vi.fn();322expect(() =>323processHookResults({324hookType: 'SessionStart',325results,326outputStream: mockStream as unknown as ChatResponseStream,327logService,328onSuccess,329ignoreErrors: true,330})331).not.toThrow();332333// stopReason means the result is ignored entirely, so onSuccess is NOT called334expect(onSuccess).not.toHaveBeenCalled();335// hookProgress should NOT be called when ignoreErrors is true336expect(mockStream.hookProgressCalls).toHaveLength(0);337});338339// Other exit codes - show stderr to user only (warnings)340it('should show warning to user on other exit codes', () => {341const results: HookResult[] = [342{343resultKind: 'warning',344warningMessage: 'Session start warning',345output: undefined,346},347];348349processHookResults({350hookType: 'SessionStart',351results,352outputStream: mockStream as unknown as ChatResponseStream,353logService,354onSuccess: () => { },355});356357expect(mockStream.hookProgressCalls).toHaveLength(1);358expect(mockStream.hookProgressCalls[0].systemMessage).toBe('Session start warning');359});360});361362describe('Stop exit codes', () => {363// Exit code 0 - stdout/stderr not shown (success silently processed)364it('should call onSuccess with output on exit code 0 (success)', () => {365const results: HookResult[] = [366{367resultKind: 'success',368output: { decision: 'allow' },369},370];371372const onSuccess = vi.fn();373processHookResults({374hookType: 'Stop',375results,376outputStream: mockStream as unknown as ChatResponseStream,377logService,378onSuccess,379});380381expect(onSuccess).toHaveBeenCalledWith({ decision: 'allow' });382// No hookProgress for success383expect(mockStream.hookProgressCalls).toHaveLength(0);384});385386// Exit code 2 - show stderr to model and continue conversation (onError callback)387it('should call onError callback on exit code 2 (error) instead of throwing', () => {388const results: HookResult[] = [389{390resultKind: 'error',391output: 'Stop hook blocking reason',392},393];394395const onSuccess = vi.fn();396const onError = vi.fn();397processHookResults({398hookType: 'Stop',399results,400outputStream: mockStream as unknown as ChatResponseStream,401logService,402onSuccess,403onError,404});405406expect(onSuccess).not.toHaveBeenCalled();407expect(onError).toHaveBeenCalledWith('Stop hook blocking reason');408// hookProgress should NOT be called when onError is provided409expect(mockStream.hookProgressCalls).toHaveLength(0);410});411412it('should continue processing remaining results after onError', () => {413const results: HookResult[] = [414{415resultKind: 'error',416output: 'First error',417},418{419resultKind: 'success',420output: { reason: 'keep going' },421},422{423resultKind: 'error',424output: 'Second error',425},426];427428const onSuccess = vi.fn();429const onError = vi.fn();430processHookResults({431hookType: 'Stop',432results,433outputStream: mockStream as unknown as ChatResponseStream,434logService,435onSuccess,436onError,437});438439expect(onError).toHaveBeenCalledTimes(2);440expect(onError).toHaveBeenCalledWith('First error');441expect(onError).toHaveBeenCalledWith('Second error');442expect(onSuccess).toHaveBeenCalledWith({ reason: 'keep going' });443});444445// Other exit codes - show stderr to user only (warnings)446it('should show warning to user on other exit codes', () => {447const results: HookResult[] = [448{449resultKind: 'warning',450warningMessage: 'Stop hook warning',451output: undefined,452},453];454455processHookResults({456hookType: 'Stop',457results,458outputStream: mockStream as unknown as ChatResponseStream,459logService,460onSuccess: () => { },461});462463expect(mockStream.hookProgressCalls).toHaveLength(1);464expect(mockStream.hookProgressCalls[0].systemMessage).toBe('Stop hook warning');465});466});467468describe('SubagentStart exit codes', () => {469// Exit code 0 - stdout shown to subagent470it('should call onSuccess with output on exit code 0 (success)', () => {471const results: HookResult[] = [472{473resultKind: 'success',474output: { additionalContext: 'Subagent context' },475},476];477478const onSuccess = vi.fn();479processHookResults({480hookType: 'SubagentStart',481results,482outputStream: mockStream as unknown as ChatResponseStream,483logService,484onSuccess,485});486487expect(onSuccess).toHaveBeenCalledWith({ additionalContext: 'Subagent context' });488});489490// Blocking errors are silently ignored (ignoreErrors: true) - no throw, no hookProgress491it('should silently ignore errors when ignoreErrors is true (no throw, no hookProgress)', () => {492const results: HookResult[] = [493{494resultKind: 'error',495output: 'Subagent start error',496},497];498499const onSuccess = vi.fn();500expect(() =>501processHookResults({502hookType: 'SubagentStart',503results,504outputStream: mockStream as unknown as ChatResponseStream,505logService,506onSuccess,507ignoreErrors: true,508})509).not.toThrow();510511expect(onSuccess).not.toHaveBeenCalled();512// hookProgress should NOT be called when ignoreErrors is true513expect(mockStream.hookProgressCalls).toHaveLength(0);514});515516// stopReason (continue: false) is silently ignored (ignoreErrors: true)517it('should silently ignore stopReason when ignoreErrors is true', () => {518const results: HookResult[] = [519{520resultKind: 'success',521output: { additionalContext: 'Subagent context' },522stopReason: 'Blocking condition, should be ignored',523},524];525526const onSuccess = vi.fn();527expect(() =>528processHookResults({529hookType: 'SubagentStart',530results,531outputStream: mockStream as unknown as ChatResponseStream,532logService,533onSuccess,534ignoreErrors: true,535})536).not.toThrow();537538// stopReason means the result is ignored entirely, so onSuccess is NOT called539expect(onSuccess).not.toHaveBeenCalled();540// hookProgress should NOT be called when ignoreErrors is true541expect(mockStream.hookProgressCalls).toHaveLength(0);542});543544// Other exit codes - show stderr to user only (warnings)545it('should show warning to user on other exit codes', () => {546const results: HookResult[] = [547{548resultKind: 'warning',549warningMessage: 'Subagent start warning',550output: undefined,551},552];553554processHookResults({555hookType: 'SubagentStart',556results,557outputStream: mockStream as unknown as ChatResponseStream,558logService,559onSuccess: () => { },560});561562expect(mockStream.hookProgressCalls).toHaveLength(1);563expect(mockStream.hookProgressCalls[0].systemMessage).toBe('Subagent start warning');564});565});566567describe('SubagentStop exit codes', () => {568// Exit code 0 - stdout/stderr not shown (success silently processed)569it('should call onSuccess with output on exit code 0 (success)', () => {570const results: HookResult[] = [571{572resultKind: 'success',573output: { decision: 'allow' },574},575];576577const onSuccess = vi.fn();578processHookResults({579hookType: 'SubagentStop',580results,581outputStream: mockStream as unknown as ChatResponseStream,582logService,583onSuccess,584});585586expect(onSuccess).toHaveBeenCalledWith({ decision: 'allow' });587expect(mockStream.hookProgressCalls).toHaveLength(0);588});589590// Exit code 2 - show stderr to subagent and continue having it run (onError callback)591it('should call onError callback on exit code 2 (error) instead of throwing', () => {592const results: HookResult[] = [593{594resultKind: 'error',595output: 'Subagent stop blocking reason',596},597];598599const onSuccess = vi.fn();600const onError = vi.fn();601processHookResults({602hookType: 'SubagentStop',603results,604outputStream: mockStream as unknown as ChatResponseStream,605logService,606onSuccess,607onError,608});609610expect(onSuccess).not.toHaveBeenCalled();611expect(onError).toHaveBeenCalledWith('Subagent stop blocking reason');612// hookProgress should NOT be called when onError is provided613expect(mockStream.hookProgressCalls).toHaveLength(0);614});615616// Other exit codes - show stderr to user only (warnings)617it('should show warning to user on other exit codes', () => {618const results: HookResult[] = [619{620resultKind: 'warning',621warningMessage: 'Subagent stop warning',622output: undefined,623},624];625626processHookResults({627hookType: 'SubagentStop',628results,629outputStream: mockStream as unknown as ChatResponseStream,630logService,631onSuccess: () => { },632});633634expect(mockStream.hookProgressCalls).toHaveLength(1);635expect(mockStream.hookProgressCalls[0].systemMessage).toBe('Subagent stop warning');636});637});638639describe('formatHookErrorMessage', () => {640it('should format error message with details', () => {641const message = formatHookErrorMessage('Connection failed');642expect(message).toContain('Connection failed');643expect(message).toContain('A hook prevented chat from continuing');644});645646it('should format error message without details', () => {647const message = formatHookErrorMessage('');648expect(message).toContain('A hook prevented chat from continuing');649expect(message).not.toContain('Error message:');650});651});652653describe('edge cases', () => {654it('should handle empty results array', () => {655const onSuccess = vi.fn();656processHookResults({657hookType: 'UserPromptSubmit',658results: [],659outputStream: mockStream as unknown as ChatResponseStream,660logService,661onSuccess,662});663664expect(onSuccess).not.toHaveBeenCalled();665expect(mockStream.hookProgressCalls).toHaveLength(0);666});667668it('should handle undefined outputStream', () => {669const results: HookResult[] = [670{ resultKind: 'warning', warningMessage: 'Warning message', output: undefined },671];672673// Should not throw when outputStream is undefined674expect(() =>675processHookResults({676hookType: 'UserPromptSubmit',677results,678outputStream: undefined,679logService,680onSuccess: () => { },681})682).not.toThrow();683});684685it('should include warnings from success results', () => {686const results: HookResult[] = [687{688resultKind: 'success',689output: 'some output',690warningMessage: 'Warning from success result',691},692];693694const onSuccess = vi.fn();695processHookResults({696hookType: 'UserPromptSubmit',697results,698outputStream: mockStream as unknown as ChatResponseStream,699logService,700onSuccess,701});702703expect(onSuccess).toHaveBeenCalledWith('some output');704expect(mockStream.hookProgressCalls).toHaveLength(1);705expect(mockStream.hookProgressCalls[0].systemMessage).toBe('Warning from success result');706});707708it('should handle error result with empty output', () => {709const results: HookResult[] = [710{711resultKind: 'error',712output: '',713},714];715716expect(() =>717processHookResults({718hookType: 'UserPromptSubmit',719results,720outputStream: mockStream as unknown as ChatResponseStream,721logService,722onSuccess: () => { },723})724).toThrow(HookAbortError);725726expect(mockStream.hookProgressCalls).toHaveLength(1);727});728729it('should handle error result with non-string output', () => {730const results: HookResult[] = [731{732resultKind: 'error',733output: { complex: 'object' },734},735];736737expect(() =>738processHookResults({739hookType: 'UserPromptSubmit',740results,741outputStream: mockStream as unknown as ChatResponseStream,742logService,743onSuccess: () => { },744})745).toThrow(HookAbortError);746747// Empty error message when output is not a string748expect(mockStream.hookProgressCalls).toHaveLength(1);749});750751it('should process multiple results in order', () => {752const results: HookResult[] = [753{ resultKind: 'success', output: 'first' },754{ resultKind: 'success', output: 'second' },755{ resultKind: 'success', output: 'third' },756];757758const outputs: unknown[] = [];759processHookResults({760hookType: 'UserPromptSubmit',761results,762outputStream: mockStream as unknown as ChatResponseStream,763logService,764onSuccess: (output) => outputs.push(output),765});766767expect(outputs).toEqual(['first', 'second', 'third']);768});769});770});771772773