Path: blob/main/extensions/copilot/src/extension/chat/test/vscode-node/chatHookService.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 } from 'vitest';6import type { ChatHookCommand, ChatHookResult, ChatHookResultKind, ChatRequestHooks, Uri } from 'vscode';7import { IPostToolUseHookResult, IPreToolUseHookResult } from '../../../../platform/chat/common/chatHookService';8import { HookCommandResultKind, IHookCommandResult } from '../../../../platform/chat/common/hookExecutor';9import { IToolValidationResult } from '../../../tools/common/toolsService';10import { isCompatibleHookEventName } from '../../vscode-node/chatHookService';1112function cmd(command: string, cwd?: Uri): ChatHookCommand {13return { command, cwd } as ChatHookCommand;14}1516/**17* A testable version of ChatHookService.executeHook logic,18* reimplemented here to stay within the layering constraints.19* This mirrors the real implementation's result conversion and iteration logic.20*/21class TestableExecuteHookService {22public executorCalls: Array<{ hookCommand: ChatHookCommand; input: unknown }> = [];23public executorHandler: (hookCommand: ChatHookCommand, input: unknown) => IHookCommandResult = () => ({ kind: HookCommandResultKind.Success, result: '' });24public transcriptPath: Uri | undefined;25public flushedSessionIds: string[] = [];2627async executeHook(hookType: string, hooks: ChatRequestHooks | undefined, input: unknown, sessionId?: string): Promise<ChatHookResult[]> {28if (!hooks) {29return [];30}3132const hookCommands = hooks[hookType];33if (!hookCommands || hookCommands.length === 0) {34return [];35}3637if (sessionId) {38this.flushedSessionIds.push(sessionId);39}4041const commonInput = {42timestamp: new Date().toISOString(),43hook_event_name: hookType,44...(sessionId ? { session_id: sessionId } : undefined),45...(this.transcriptPath ? { transcript_path: this.transcriptPath } : undefined),46};47const fullInput = (typeof input === 'object' && input !== null)48? { ...commonInput, ...input }49: commonInput;5051const results: ChatHookResult[] = [];5253for (const hookCommand of hookCommands) {54try {55const commandInput = hookCommand.cwd56? { ...fullInput, cwd: hookCommand.cwd }57: fullInput;58this.executorCalls.push({ hookCommand, input: commandInput });59const commandResult = this.executorHandler(hookCommand, commandInput);60const result = this._toHookResult(hookType, commandResult);61results.push(result);6263if (result.stopReason !== undefined) {64break;65}66} catch (err) {67results.push({68resultKind: 'warning',69output: undefined,70warningMessage: err instanceof Error ? err.message : String(err),71});72}73}7475return results;76}7778private _toHookResult(hookType: string, commandResult: IHookCommandResult): ChatHookResult {79switch (commandResult.kind) {80case HookCommandResultKind.Error: {81const message = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);82return { resultKind: 'error', output: message };83}84case HookCommandResultKind.NonBlockingError: {85const errorMessage = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);86return { resultKind: 'warning', output: undefined, warningMessage: errorMessage };87}88case HookCommandResultKind.Success: {89if (typeof commandResult.result !== 'object') {90return { resultKind: 'success', output: commandResult.result };91}9293const resultObj = commandResult.result as Record<string, unknown>;94const stopReason = typeof resultObj['stopReason'] === 'string' ? resultObj['stopReason'] : undefined;95const continueFlag = resultObj['continue'];96const systemMessage = typeof resultObj['systemMessage'] === 'string' ? resultObj['systemMessage'] : undefined;9798let effectiveStopReason = stopReason;99if (continueFlag === false && !effectiveStopReason) {100effectiveStopReason = '';101}102103// Check hookEventName at top level — if present and mismatched, skip this result104const topLevelHookEventName = resultObj['hookEventName'];105if (typeof topLevelHookEventName === 'string' && !isCompatibleHookEventName(topLevelHookEventName, hookType)) {106return { resultKind: 'success', output: undefined };107}108109// Check hookEventName inside hookSpecificOutput — if mismatched, strip hookSpecificOutput but keep the rest110let stripHookSpecificOutput = false;111const hookSpecificOutput = resultObj['hookSpecificOutput'];112if (typeof hookSpecificOutput === 'object' && hookSpecificOutput !== null) {113const nestedHookEventName = (hookSpecificOutput as Record<string, unknown>)['hookEventName'];114if (typeof nestedHookEventName === 'string' && !isCompatibleHookEventName(nestedHookEventName, hookType)) {115stripHookSpecificOutput = true;116}117}118119const commonFields = new Set(['continue', 'stopReason', 'systemMessage']);120if (stripHookSpecificOutput) {121commonFields.add('hookSpecificOutput');122}123const hookOutput: Record<string, unknown> = {};124for (const [key, value] of Object.entries(resultObj)) {125if (value !== undefined && !commonFields.has(key)) {126hookOutput[key] = value;127}128}129130return {131resultKind: 'success',132stopReason: effectiveStopReason,133warningMessage: systemMessage,134output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined,135};136}137default:138return { resultKind: 'warning', warningMessage: `Unexpected hook command result kind: ${(commandResult as IHookCommandResult).kind}`, output: undefined };139}140}141}142143describe('ChatHookService.executeHook', () => {144let service: TestableExecuteHookService;145146beforeEach(() => {147service = new TestableExecuteHookService();148});149150it('returns empty array when hooks is undefined', async () => {151const results = await service.executeHook('Stop', undefined, {});152expect(results).toEqual([]);153});154155it('returns empty array when no commands for hook type', async () => {156const results = await service.executeHook('Stop', { PreToolUse: [cmd('echo test')] }, {});157expect(results).toEqual([]);158});159160it('executes hook and returns success result', async () => {161service.executorHandler = () => ({ kind: HookCommandResultKind.Success, result: { decision: 'block', reason: 'test' } });162const results = await service.executeHook('Stop', { Stop: [cmd('echo test')] }, {});163164expect(results).toHaveLength(1);165expect(results[0].resultKind).toBe('success');166expect(results[0].output).toEqual({ decision: 'block', reason: 'test' });167});168169it('converts exit code 2 to error result with message in output', async () => {170service.executorHandler = () => ({ kind: HookCommandResultKind.Error, result: 'fatal error' });171const results = await service.executeHook('Stop', { Stop: [cmd('fail')] }, {});172173expect(results).toHaveLength(1);174expect(results[0].resultKind).toBe('error');175expect(results[0].output).toBe('fatal error');176expect(results[0].stopReason).toBeUndefined();177});178179it('does not stop processing on error results (callers decide)', async () => {180let callCount = 0;181service.executorHandler = () => {182callCount++;183if (callCount === 1) {184return { kind: HookCommandResultKind.Error, result: 'error from first' };185}186return { kind: HookCommandResultKind.Success, result: 'second ok' };187};188const results = await service.executeHook('Stop', { Stop: [cmd('first'), cmd('second')] }, {});189190expect(results).toHaveLength(2);191expect(callCount).toBe(2);192expect(results[0].resultKind).toBe('error');193expect(results[1].resultKind).toBe('success');194});195196it('converts non-blocking error to warning', async () => {197service.executorHandler = () => ({ kind: HookCommandResultKind.NonBlockingError, result: 'warning msg' });198const results = await service.executeHook('Stop', { Stop: [cmd('warn')] }, {});199200expect(results).toHaveLength(1);201expect(results[0].resultKind).toBe('warning');202expect(results[0].warningMessage).toBe('warning msg');203expect(results[0].stopReason).toBeUndefined();204});205206it('stops processing after first hook with stopReason', async () => {207let callCount = 0;208service.executorHandler = () => {209callCount++;210if (callCount === 1) {211return { kind: HookCommandResultKind.Success, result: { stopReason: 'stop here' } };212}213return { kind: HookCommandResultKind.Success, result: 'second' };214};215const results = await service.executeHook('Stop', { Stop: [cmd('first'), cmd('second')] }, {});216217expect(results).toHaveLength(1);218expect(callCount).toBe(1);219expect(results[0].stopReason).toBe('stop here');220});221222it('stops processing on empty string stopReason (continue: false)', async () => {223let callCount = 0;224service.executorHandler = () => {225callCount++;226return { kind: HookCommandResultKind.Success, result: { continue: false } };227};228const results = await service.executeHook('Stop', { Stop: [cmd('first'), cmd('second')] }, {});229230expect(results).toHaveLength(1);231expect(callCount).toBe(1);232expect(results[0].stopReason).toBe('');233});234235it('catches executor errors and returns warning', async () => {236service.executorHandler = () => { throw new Error('spawn failed'); };237const results = await service.executeHook('Stop', { Stop: [cmd('fail')] }, {});238239expect(results).toHaveLength(1);240expect(results[0].resultKind).toBe('warning');241expect(results[0].warningMessage).toBe('spawn failed');242});243244it('includes sessionId in common input', async () => {245service.executorHandler = () => ({ kind: HookCommandResultKind.Success, result: '' });246await service.executeHook('Stop', { Stop: [cmd('test')] }, {}, 'session-123');247248expect(service.executorCalls[0].input).toMatchObject({ session_id: 'session-123', hook_event_name: 'Stop' });249});250251it('includes cwd from hook command in input', async () => {252const cwdUri = { scheme: 'file', path: '/my/project' } as Uri;253service.executorHandler = () => ({ kind: HookCommandResultKind.Success, result: '' });254await service.executeHook('Stop', { Stop: [cmd('test', cwdUri)] }, {});255256expect(service.executorCalls[0].input).toMatchObject({ cwd: cwdUri });257});258259it('merges caller input with common input', async () => {260service.executorHandler = () => ({ kind: HookCommandResultKind.Success, result: '' });261await service.executeHook('PreToolUse', { PreToolUse: [cmd('test')] }, { tool_name: 'myTool', tool_input: { x: 1 } });262263const input = service.executorCalls[0].input as Record<string, unknown>;264expect(input['tool_name']).toBe('myTool');265expect(input['tool_input']).toEqual({ x: 1 });266expect(input['hook_event_name']).toBe('PreToolUse');267expect(typeof input['timestamp']).toBe('string');268});269270it('includes transcript_path when configured', async () => {271const transcriptUri = { scheme: 'file', path: '/tmp/transcript.jsonl' } as Uri;272service.transcriptPath = transcriptUri;273service.executorHandler = () => ({ kind: HookCommandResultKind.Success, result: '' });274await service.executeHook('Stop', { Stop: [cmd('test')] }, {}, 'session-1');275276expect(service.flushedSessionIds).toContain('session-1');277expect(service.executorCalls[0].input).toMatchObject({ transcript_path: transcriptUri });278});279280it('extracts systemMessage as warningMessage', async () => {281service.executorHandler = () => ({282kind: HookCommandResultKind.Success,283result: { systemMessage: 'be careful' },284});285const results = await service.executeHook('Stop', { Stop: [cmd('test')] }, {});286287expect(results[0].warningMessage).toBe('be careful');288});289290it('separates common fields from hook-specific output', async () => {291service.executorHandler = () => ({292kind: HookCommandResultKind.Success,293result: { continue: true, systemMessage: 'msg', decision: 'block', reason: 'test' },294});295const results = await service.executeHook('Stop', { Stop: [cmd('test')] }, {});296297expect(results[0].output).toEqual({ decision: 'block', reason: 'test' });298expect(results[0].warningMessage).toBe('msg');299expect(results[0].stopReason).toBeUndefined();300});301302it('executes multiple hooks in sequence', async () => {303const commands: string[] = [];304service.executorHandler = (hookCmd) => {305commands.push(hookCmd.command);306return { kind: HookCommandResultKind.Success, result: '' };307};308const results = await service.executeHook('Stop', { Stop: [cmd('a'), cmd('b'), cmd('c')] }, {});309310expect(results).toHaveLength(3);311expect(commands).toEqual(['a', 'b', 'c']);312});313314it('filters out results with mismatched top-level hookEventName', async () => {315service.executorHandler = () => ({316kind: HookCommandResultKind.Success,317result: { hookEventName: 'PreToolUse', decision: 'block', reason: 'wrong event' },318});319const results = await service.executeHook('Stop', { Stop: [cmd('test')] }, {});320321expect(results).toHaveLength(1);322expect(results[0].resultKind).toBe('success');323expect(results[0].output).toBeUndefined();324});325326it('strips hookSpecificOutput with mismatched nested hookEventName but keeps other fields', async () => {327service.executorHandler = () => ({328kind: HookCommandResultKind.Success,329result: { hookSpecificOutput: { hookEventName: 'PostToolUse', permissionDecision: 'deny' }, decision: 'block', reason: 'kept' },330});331const results = await service.executeHook('PreToolUse', { PreToolUse: [cmd('test')] }, {});332333expect(results).toHaveLength(1);334expect(results[0].resultKind).toBe('success');335expect(results[0].output).toEqual({ decision: 'block', reason: 'kept' });336});337338it('discards entire output when hookSpecificOutput is the only non-common field and hookEventName mismatches', async () => {339service.executorHandler = () => ({340kind: HookCommandResultKind.Success,341result: { hookSpecificOutput: { hookEventName: 'PostToolUse', permissionDecision: 'deny' } },342});343const results = await service.executeHook('PreToolUse', { PreToolUse: [cmd('test')] }, {});344345expect(results).toHaveLength(1);346expect(results[0].resultKind).toBe('success');347expect(results[0].output).toBeUndefined();348});349350it('allows results with matching hookEventName', async () => {351service.executorHandler = () => ({352kind: HookCommandResultKind.Success,353result: { hookEventName: 'Stop', decision: 'block', reason: 'correct event' },354});355const results = await service.executeHook('Stop', { Stop: [cmd('test')] }, {});356357expect(results).toHaveLength(1);358expect(results[0].output).toEqual({ hookEventName: 'Stop', decision: 'block', reason: 'correct event' });359});360361it('allows results without hookEventName', async () => {362service.executorHandler = () => ({363kind: HookCommandResultKind.Success,364result: { decision: 'block', reason: 'no event name' },365});366const results = await service.executeHook('Stop', { Stop: [cmd('test')] }, {});367368expect(results).toHaveLength(1);369expect(results[0].output).toEqual({ decision: 'block', reason: 'no event name' });370});371372it('treats Stop hookEventName as compatible with SubagentStop hook type', async () => {373service.executorHandler = () => ({374kind: HookCommandResultKind.Success,375result: { hookSpecificOutput: { hookEventName: 'Stop', decision: 'block', reason: 'tests failing' } },376});377const results = await service.executeHook('SubagentStop', { SubagentStop: [cmd('test')] }, {});378379expect(results).toHaveLength(1);380expect(results[0].resultKind).toBe('success');381expect(results[0].output).toEqual({ hookSpecificOutput: { hookEventName: 'Stop', decision: 'block', reason: 'tests failing' } });382});383384it('does NOT treat SubagentStop hookEventName as compatible with Stop hook type', async () => {385service.executorHandler = () => ({386kind: HookCommandResultKind.Success,387result: { hookSpecificOutput: { hookEventName: 'SubagentStop', decision: 'block', reason: 'not done' } },388});389const results = await service.executeHook('Stop', { Stop: [cmd('test')] }, {});390391expect(results).toHaveLength(1);392expect(results[0].resultKind).toBe('success');393// hookSpecificOutput should be stripped because SubagentStop -> Stop is not compatible394expect(results[0].output).toBeUndefined();395});396397it('treats SessionStart hookEventName as compatible with SubagentStart hook type', async () => {398service.executorHandler = () => ({399kind: HookCommandResultKind.Success,400result: { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'context' } },401});402const results = await service.executeHook('SubagentStart', { SubagentStart: [cmd('test')] }, {});403404expect(results).toHaveLength(1);405expect(results[0].resultKind).toBe('success');406expect(results[0].output).toEqual({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'context' } });407});408409it('does NOT treat SubagentStart hookEventName as compatible with SessionStart hook type', async () => {410service.executorHandler = () => ({411kind: HookCommandResultKind.Success,412result: { hookSpecificOutput: { hookEventName: 'SubagentStart', additionalContext: 'context' } },413});414const results = await service.executeHook('SessionStart', { SessionStart: [cmd('test')] }, {});415416expect(results).toHaveLength(1);417expect(results[0].resultKind).toBe('success');418// hookSpecificOutput should be stripped because SubagentStart -> SessionStart is not compatible419expect(results[0].output).toBeUndefined();420});421422it('treats top-level Stop hookEventName as compatible with SubagentStop', async () => {423service.executorHandler = () => ({424kind: HookCommandResultKind.Success,425result: { hookEventName: 'Stop', decision: 'block', reason: 'from stop hook' },426});427const results = await service.executeHook('SubagentStop', { SubagentStop: [cmd('test')] }, {});428429expect(results).toHaveLength(1);430expect(results[0].resultKind).toBe('success');431expect(results[0].output).toEqual({ hookEventName: 'Stop', decision: 'block', reason: 'from stop hook' });432});433434it('still strips hookSpecificOutput when hookEventName is truly incompatible', async () => {435service.executorHandler = () => ({436kind: HookCommandResultKind.Success,437result: { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } },438});439const results = await service.executeHook('SubagentStop', { SubagentStop: [cmd('test')] }, {});440441expect(results).toHaveLength(1);442expect(results[0].resultKind).toBe('success');443expect(results[0].output).toBeUndefined();444});445});446447/**448* Minimal mock of ChatHookService that exposes executePreToolUseHook449* without requiring the real vscode API.450*451* We replicate the collapsing logic from ChatHookService.executePreToolUseHook452* by subclassing and overriding executeHook to return configurable results.453*/454455interface IPreToolUseHookSpecificOutput {456hookEventName?: string;457permissionDecision?: 'allow' | 'deny' | 'ask';458permissionDecisionReason?: string;459updatedInput?: object;460additionalContext?: string;461}462463const permissionPriority: Record<string, number> = { 'deny': 2, 'ask': 1, 'allow': 0 };464465/**466* A testable version of the executePreToolUseHook collapsing logic,467* decoupled from the vscode API. Takes raw ChatHookResult[] and returns468* the collapsed IPreToolUseHookResult.469*/470function collapsePreToolUseHookResults(results: ChatHookResult[]): IPreToolUseHookResult | undefined {471if (results.length === 0) {472return undefined;473}474475let mostRestrictiveDecision: 'allow' | 'deny' | 'ask' | undefined;476let winningReason: string | undefined;477let lastUpdatedInput: object | undefined;478const allAdditionalContext: string[] = [];479480for (const result of results) {481// Exit code 2 (error) means deny the tool482if (result.resultKind === 'error') {483const reason = typeof result.output === 'string' ? result.output : undefined;484mostRestrictiveDecision = 'deny';485winningReason = reason ?? winningReason;486break;487}488489if (result.resultKind !== 'success' || typeof result.output !== 'object' || result.output === null) {490continue;491}492493const output = result.output as { hookSpecificOutput?: IPreToolUseHookSpecificOutput };494const hookSpecificOutput = output.hookSpecificOutput;495if (!hookSpecificOutput) {496continue;497}498499if (hookSpecificOutput.hookEventName !== undefined && hookSpecificOutput.hookEventName !== 'PreToolUse') {500continue;501}502503if (hookSpecificOutput.additionalContext) {504allAdditionalContext.push(hookSpecificOutput.additionalContext);505}506507if (hookSpecificOutput.updatedInput) {508lastUpdatedInput = hookSpecificOutput.updatedInput;509}510511const decision = hookSpecificOutput.permissionDecision;512if (decision && (mostRestrictiveDecision === undefined || (permissionPriority[decision] ?? 0) > (permissionPriority[mostRestrictiveDecision] ?? 0))) {513mostRestrictiveDecision = decision;514winningReason = hookSpecificOutput.permissionDecisionReason;515}516}517518if (!mostRestrictiveDecision && !lastUpdatedInput && allAdditionalContext.length === 0) {519return undefined;520}521522return {523permissionDecision: mostRestrictiveDecision,524permissionDecisionReason: winningReason,525updatedInput: lastUpdatedInput,526additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,527};528}529530function hookResult(output: unknown, kind: ChatHookResultKind = 'success'): ChatHookResult {531return { resultKind: kind, output } as ChatHookResult;532}533534/**535* A testable ChatHookService that stubs executeHook to return configurable results,536* so we can test executePreToolUseHook's collapsing logic without the real vscode API.537*/538class TestableChatHookService {539public hookResults: ChatHookResult[] = [];540public validateToolInputFn: ((name: string, input: string) => IToolValidationResult) | undefined;541542async executeHook(): Promise<ChatHookResult[]> {543return this.hookResults;544}545546async executePreToolUseHook(547toolName: string,548toolInput: unknown,549toolCallId: string,550toolInvocationToken: unknown,551sessionId?: string,552): Promise<IPreToolUseHookResult | undefined> {553const results = await this.executeHook();554const collapsed = collapsePreToolUseHookResults(results);555if (!collapsed) {556return undefined;557}558559// Validate updatedInput against the tool's input schema, mirroring the real ChatHookService560if (collapsed.updatedInput && this.validateToolInputFn) {561const validationResult = this.validateToolInputFn(toolName, JSON.stringify(collapsed.updatedInput));562if ('error' in validationResult) {563collapsed.updatedInput = undefined;564}565}566567if (!collapsed.permissionDecision && !collapsed.updatedInput && !collapsed.additionalContext?.length) {568return undefined;569}570571return collapsed;572}573}574575describe('ChatHookService.executePreToolUseHook', () => {576let service: TestableChatHookService;577578beforeEach(() => {579service = new TestableChatHookService();580});581582it('returns undefined when no hooks return results', async () => {583service.hookResults = [];584const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);585expect(result).toBeUndefined();586});587588it('returns allow when single hook allows', async () => {589service.hookResults = [590hookResult({ hookSpecificOutput: { permissionDecision: 'allow', permissionDecisionReason: 'Tool is safe' } }),591];592593const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);594expect(result).toEqual({595permissionDecision: 'allow',596permissionDecisionReason: 'Tool is safe',597updatedInput: undefined,598additionalContext: undefined,599});600});601602it('returns deny when single hook denies', async () => {603service.hookResults = [604hookResult({ hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'Blocked' } }),605];606607const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);608expect(result).toEqual({609permissionDecision: 'deny',610permissionDecisionReason: 'Blocked',611updatedInput: undefined,612additionalContext: undefined,613});614});615616it('returns ask when single hook asks', async () => {617service.hookResults = [618hookResult({ hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'Needs review' } }),619];620621const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);622expect(result).toEqual({623permissionDecision: 'ask',624permissionDecisionReason: 'Needs review',625updatedInput: undefined,626additionalContext: undefined,627});628});629630it('deny wins over allow and ask', async () => {631service.hookResults = [632hookResult({ hookSpecificOutput: { permissionDecision: 'allow', permissionDecisionReason: 'ok' } }),633hookResult({ hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'maybe' } }),634hookResult({ hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'nope' } }),635];636637const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);638expect(result?.permissionDecision).toBe('deny');639expect(result?.permissionDecisionReason).toBe('nope');640});641642it('ask wins over allow', async () => {643service.hookResults = [644hookResult({ hookSpecificOutput: { permissionDecision: 'allow', permissionDecisionReason: 'ok' } }),645hookResult({ hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'confirm please' } }),646];647648const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);649expect(result?.permissionDecision).toBe('ask');650expect(result?.permissionDecisionReason).toBe('confirm please');651});652653it('ignores results with wrong hookEventName', async () => {654service.hookResults = [655hookResult({ hookSpecificOutput: { hookEventName: 'PostToolUse', permissionDecision: 'deny' } }),656hookResult({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' } }),657];658659const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);660expect(result?.permissionDecision).toBe('allow');661});662663it('accepts results without hookEventName', async () => {664service.hookResults = [665hookResult({ hookSpecificOutput: { permissionDecision: 'allow' } }),666];667668const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);669expect(result?.permissionDecision).toBe('allow');670});671672it('returns updatedInput from hook', async () => {673service.hookResults = [674hookResult({ hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { path: '/safe/path.ts' } } }),675];676677const result = await service.executePreToolUseHook('tool', { path: '/original' }, 'call-1', undefined);678expect(result?.updatedInput).toEqual({ path: '/safe/path.ts' });679});680681it('later hook updatedInput overrides earlier one', async () => {682service.hookResults = [683hookResult({ hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'first' } } }),684hookResult({ hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'second' } } }),685];686687const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);688expect(result?.updatedInput).toEqual({ value: 'second' });689});690691it('returns updatedInput even without permission decision', async () => {692service.hookResults = [693hookResult({ hookSpecificOutput: { updatedInput: { modified: true } } }),694];695696const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);697expect(result?.updatedInput).toEqual({ modified: true });698expect(result?.permissionDecision).toBeUndefined();699});700701it('discards updatedInput when schema validation fails', async () => {702service.validateToolInputFn = () => ({ error: 'Missing required property "command"' });703service.hookResults = [704hookResult({ hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { invalidField: 'wrong' } } }),705];706707const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);708expect(result?.permissionDecision).toBe('allow');709expect(result?.updatedInput).toBeUndefined();710});711712it('keeps updatedInput when schema validation passes', async () => {713service.validateToolInputFn = (_name, input) => ({ inputObj: JSON.parse(input) });714service.hookResults = [715hookResult({ hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { command: 'safe' } } }),716];717718const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);719expect(result?.permissionDecision).toBe('allow');720expect(result?.updatedInput).toEqual({ command: 'safe' });721});722723it('returns undefined when only updatedInput is present but fails validation', async () => {724service.validateToolInputFn = () => ({ error: 'invalid' });725service.hookResults = [726hookResult({ hookSpecificOutput: { updatedInput: { bad: true } } }),727];728729const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);730expect(result).toBeUndefined();731});732733it('collects additionalContext from all hooks', async () => {734service.hookResults = [735hookResult({ hookSpecificOutput: { permissionDecision: 'allow', additionalContext: 'context from hook 1' } }),736hookResult({ hookSpecificOutput: { permissionDecision: 'allow', additionalContext: 'context from hook 2' } }),737];738739const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);740expect(result?.additionalContext).toEqual(['context from hook 1', 'context from hook 2']);741});742743it('returns undefined additionalContext when no hooks provide it', async () => {744service.hookResults = [745hookResult({ hookSpecificOutput: { permissionDecision: 'allow' } }),746];747748const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);749expect(result?.additionalContext).toBeUndefined();750});751752it('combines updatedInput, additionalContext, and permission decision', async () => {753service.hookResults = [754hookResult({ hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'Modified input needs review', updatedInput: { command: 'echo safe' }, additionalContext: 'audit log enabled' } }),755];756757const result = await service.executePreToolUseHook('tool', { command: 'rm -rf /' }, 'call-1', undefined);758expect(result).toEqual({759permissionDecision: 'ask',760permissionDecisionReason: 'Modified input needs review',761updatedInput: { command: 'echo safe' },762additionalContext: ['audit log enabled'],763});764});765766it('treats error results (exit code 2) as deny', async () => {767service.hookResults = [768hookResult('hook blocked this tool', 'error'),769hookResult({ hookSpecificOutput: { permissionDecision: 'allow' } }),770];771772const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);773expect(result?.permissionDecision).toBe('deny');774expect(result?.permissionDecisionReason).toBe('hook blocked this tool');775});776777it('preserves context from prior hooks when error denies', async () => {778service.hookResults = [779hookResult({ hookSpecificOutput: { permissionDecision: 'allow', additionalContext: 'context from first hook' } }),780hookResult('second hook errored', 'error'),781];782783const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);784expect(result?.permissionDecision).toBe('deny');785expect(result?.additionalContext).toEqual(['context from first hook']);786});787788it('skips warning results', async () => {789service.hookResults = [790hookResult({ hookSpecificOutput: { permissionDecision: 'deny' } }, 'warning'),791hookResult({ hookSpecificOutput: { permissionDecision: 'allow' } }),792];793794const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);795expect(result?.permissionDecision).toBe('allow');796});797798it('skips results with non-object output', async () => {799service.hookResults = [800hookResult('string output'),801hookResult({ hookSpecificOutput: { permissionDecision: 'allow' } }),802];803804const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);805expect(result?.permissionDecision).toBe('allow');806});807808it('skips results without hookSpecificOutput', async () => {809service.hookResults = [810hookResult({ someOtherField: 'value' }),811hookResult({ hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'blocked' } }),812];813814const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);815expect(result?.permissionDecision).toBe('deny');816});817818it('returns undefined when all results are warnings', async () => {819service.hookResults = [820hookResult({ hookSpecificOutput: { permissionDecision: 'deny' } }, 'warning'),821hookResult({ hookSpecificOutput: { permissionDecision: 'allow' } }, 'warning'),822];823824const result = await service.executePreToolUseHook('tool', {}, 'call-1', undefined);825expect(result).toBeUndefined();826});827});828829interface IPostToolUseHookSpecificOutput {830hookEventName?: string;831additionalContext?: string;832}833834function collapsePostToolUseHookResults(results: ChatHookResult[]): IPostToolUseHookResult | undefined {835if (results.length === 0) {836return undefined;837}838839let hasBlock = false;840let blockReason: string | undefined;841const allAdditionalContext: string[] = [];842843for (const result of results) {844// Exit code 2 (error) means block the tool result845if (result.resultKind === 'error') {846const reason = typeof result.output === 'string' ? result.output : undefined;847if (!hasBlock) {848hasBlock = true;849blockReason = reason;850}851break;852}853854if (result.resultKind !== 'success' || typeof result.output !== 'object' || result.output === null) {855continue;856}857858const output = result.output as {859decision?: string;860reason?: string;861hookSpecificOutput?: IPostToolUseHookSpecificOutput;862};863864if (output.hookSpecificOutput?.hookEventName !== undefined && output.hookSpecificOutput.hookEventName !== 'PostToolUse') {865continue;866}867868if (output.hookSpecificOutput?.additionalContext) {869allAdditionalContext.push(output.hookSpecificOutput.additionalContext);870}871872if (output.decision === 'block' && !hasBlock) {873hasBlock = true;874blockReason = output.reason;875}876}877878if (!hasBlock && allAdditionalContext.length === 0) {879return undefined;880}881882return {883decision: hasBlock ? 'block' : undefined,884reason: blockReason,885additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined,886};887}888889class TestablePostToolUseChatHookService {890public hookResults: ChatHookResult[] = [];891892async executeHook(): Promise<ChatHookResult[]> {893return this.hookResults;894}895896async executePostToolUseHook(897toolName: string,898toolInput: unknown,899toolResponseText: string,900toolCallId: string,901toolInvocationToken: unknown,902sessionId?: string,903): Promise<IPostToolUseHookResult | undefined> {904const results = await this.executeHook();905return collapsePostToolUseHookResults(results);906}907}908909describe('ChatHookService.executePostToolUseHook', () => {910let service: TestablePostToolUseChatHookService;911912beforeEach(() => {913service = new TestablePostToolUseChatHookService();914});915916it('returns undefined when no hooks return results', async () => {917service.hookResults = [];918const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);919expect(result).toBeUndefined();920});921922it('returns block decision when hook blocks', async () => {923service.hookResults = [924hookResult({ decision: 'block', reason: 'Lint errors found' }),925];926927const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);928expect(result).toEqual({929decision: 'block',930reason: 'Lint errors found',931additionalContext: undefined,932});933});934935it('returns additionalContext from hookSpecificOutput', async () => {936service.hookResults = [937hookResult({ hookSpecificOutput: { additionalContext: 'Tests still pass' } }),938];939940const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);941expect(result).toEqual({942decision: undefined,943reason: undefined,944additionalContext: ['Tests still pass'],945});946});947948it('collects additionalContext from all hooks', async () => {949service.hookResults = [950hookResult({ hookSpecificOutput: { additionalContext: 'context from hook 1' } }),951hookResult({ hookSpecificOutput: { additionalContext: 'context from hook 2' } }),952];953954const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);955expect(result?.additionalContext).toEqual(['context from hook 1', 'context from hook 2']);956});957958it('first block decision wins', async () => {959service.hookResults = [960hookResult({ decision: 'block', reason: 'First block' }),961hookResult({ decision: 'block', reason: 'Second block' }),962];963964const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);965expect(result?.decision).toBe('block');966expect(result?.reason).toBe('First block');967});968969it('block decision with additionalContext from different hooks', async () => {970service.hookResults = [971hookResult({ decision: 'block', reason: 'Tests failed' }),972hookResult({ hookSpecificOutput: { additionalContext: 'Extra context from linter' } }),973];974975const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);976expect(result).toEqual({977decision: 'block',978reason: 'Tests failed',979additionalContext: ['Extra context from linter'],980});981});982983it('ignores results with wrong hookEventName', async () => {984service.hookResults = [985hookResult({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Should be ignored' } }),986hookResult({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'Correct context' } }),987];988989const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);990expect(result?.additionalContext).toEqual(['Correct context']);991});992993it('accepts results without hookEventName', async () => {994service.hookResults = [995hookResult({ hookSpecificOutput: { additionalContext: 'No event name' } }),996];997998const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);999expect(result?.additionalContext).toEqual(['No event name']);1000});10011002it('treats error results (exit code 2) as block', async () => {1003service.hookResults = [1004hookResult('hook errored', 'error'),1005hookResult({ hookSpecificOutput: { additionalContext: 'Valid context' } }),1006];10071008const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);1009expect(result?.decision).toBe('block');1010expect(result?.reason).toBe('hook errored');1011});10121013it('preserves context from prior hooks when error blocks', async () => {1014service.hookResults = [1015hookResult({ hookSpecificOutput: { additionalContext: 'context from first' } }),1016hookResult('second errored', 'error'),1017];10181019const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);1020expect(result?.decision).toBe('block');1021expect(result?.additionalContext).toEqual(['context from first']);1022});10231024it('skips warning results', async () => {1025service.hookResults = [1026hookResult({ decision: 'block', reason: 'Should be ignored' }, 'warning'),1027hookResult({ hookSpecificOutput: { additionalContext: 'Valid context' } }),1028];10291030const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);1031expect(result?.decision).toBeUndefined();1032expect(result?.additionalContext).toEqual(['Valid context']);1033});10341035it('skips results with non-object output', async () => {1036service.hookResults = [1037hookResult('string output'),1038hookResult({ decision: 'block', reason: 'Valid block' }),1039];10401041const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);1042expect(result?.decision).toBe('block');1043});10441045it('returns undefined when all results are warnings', async () => {1046service.hookResults = [1047hookResult({ decision: 'block' }, 'warning'),1048hookResult({ hookSpecificOutput: { additionalContext: 'ctx' } }, 'warning'),1049];10501051const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);1052expect(result).toBeUndefined();1053});10541055it('returns undefined when no hook provides block or additionalContext', async () => {1056service.hookResults = [1057hookResult({ hookSpecificOutput: {} }),1058];10591060const result = await service.executePostToolUseHook('tool', {}, 'output', 'call-1', undefined);1061expect(result).toBeUndefined();1062});1063});106410651066