Path: blob/main/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts
5241 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 assert from 'assert';6import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';7import { URI } from '../../../../../base/common/uri.js';8import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';9import { NullLogService } from '../../../../../platform/log/common/log.js';10import { HookCommandResultKind, IHookCommandResult } from '../../common/hooks/hooksCommandTypes.js';11import { HooksExecutionService, IHooksExecutionProxy } from '../../common/hooks/hooksExecutionService.js';12import { HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js';13import { IOutputChannel, IOutputService } from '../../../../services/output/common/output.js';1415function cmd(command: string): IHookCommand {16return { type: 'command', command, cwd: URI.file('/') };17}1819function createMockOutputService(): IOutputService {20const mockChannel: Partial<IOutputChannel> = {21append: () => { },22};23return {24_serviceBrand: undefined,25getChannel: () => mockChannel as IOutputChannel,26} as unknown as IOutputService;27}2829suite('HooksExecutionService', () => {30const store = ensureNoDisposablesAreLeakedInTestSuite();3132let service: HooksExecutionService;33const sessionUri = URI.file('/test/session');3435setup(() => {36service = store.add(new HooksExecutionService(new NullLogService(), createMockOutputService()));37});3839suite('registerHooks', () => {40test('registers hooks for a session', () => {41const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };42store.add(service.registerHooks(sessionUri, hooks));4344assert.strictEqual(service.getHooksForSession(sessionUri), hooks);45});4647test('returns disposable that unregisters hooks', () => {48const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };49const disposable = service.registerHooks(sessionUri, hooks);5051assert.strictEqual(service.getHooksForSession(sessionUri), hooks);5253disposable.dispose();5455assert.strictEqual(service.getHooksForSession(sessionUri), undefined);56});5758test('different sessions have independent hooks', () => {59const session1 = URI.file('/test/session1');60const session2 = URI.file('/test/session2');61const hooks1 = { [HookType.PreToolUse]: [cmd('echo 1')] };62const hooks2 = { [HookType.PostToolUse]: [cmd('echo 2')] };6364store.add(service.registerHooks(session1, hooks1));65store.add(service.registerHooks(session2, hooks2));6667assert.strictEqual(service.getHooksForSession(session1), hooks1);68assert.strictEqual(service.getHooksForSession(session2), hooks2);69});70});7172suite('getHooksForSession', () => {73test('returns undefined for unregistered session', () => {74assert.strictEqual(service.getHooksForSession(sessionUri), undefined);75});76});7778suite('executeHook', () => {79test('returns empty array when no proxy set', async () => {80const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };81store.add(service.registerHooks(sessionUri, hooks));8283const results = await service.executeHook(HookType.PreToolUse, sessionUri);84assert.deepStrictEqual(results, []);85});8687test('returns empty array when no hooks registered for session', async () => {88const proxy = createMockProxy();89service.setProxy(proxy);9091const results = await service.executeHook(HookType.PreToolUse, sessionUri);92assert.deepStrictEqual(results, []);93});9495test('returns empty array when no hooks of requested type', async () => {96const proxy = createMockProxy();97service.setProxy(proxy);98const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };99store.add(service.registerHooks(sessionUri, hooks));100101const results = await service.executeHook(HookType.PostToolUse, sessionUri);102assert.deepStrictEqual(results, []);103});104105test('executes hook commands via proxy and returns semantic results', async () => {106const proxy = createMockProxy((cmd) => ({107kind: HookCommandResultKind.Success,108result: `executed: ${cmd.command}`109}));110service.setProxy(proxy);111112const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };113store.add(service.registerHooks(sessionUri, hooks));114115const results = await service.executeHook(HookType.PreToolUse, sessionUri, { input: 'test-input' });116117assert.strictEqual(results.length, 1);118assert.strictEqual(results[0].resultKind, 'success');119assert.strictEqual(results[0].stopReason, undefined);120assert.strictEqual(results[0].output, 'executed: echo test');121});122123test('executes multiple hook commands in order', async () => {124const executedCommands: string[] = [];125const proxy = createMockProxy((cmd) => {126executedCommands.push(cmd.command ?? '');127return { kind: HookCommandResultKind.Success, result: 'ok' };128});129service.setProxy(proxy);130131const hooks = {132[HookType.PreToolUse]: [cmd('cmd1'), cmd('cmd2'), cmd('cmd3')]133};134store.add(service.registerHooks(sessionUri, hooks));135136const results = await service.executeHook(HookType.PreToolUse, sessionUri);137138assert.strictEqual(results.length, 3);139assert.deepStrictEqual(executedCommands, ['cmd1', 'cmd2', 'cmd3']);140});141142test('wraps proxy errors in error result', async () => {143const proxy = createMockProxy(() => {144throw new Error('proxy failed');145});146service.setProxy(proxy);147148const hooks = { [HookType.PreToolUse]: [cmd('fail')] };149store.add(service.registerHooks(sessionUri, hooks));150151const results = await service.executeHook(HookType.PreToolUse, sessionUri);152153assert.strictEqual(results.length, 1);154assert.strictEqual(results[0].resultKind, 'error');155assert.strictEqual(results[0].output, 'proxy failed');156// Error results still have default common fields157assert.strictEqual(results[0].stopReason, undefined);158});159160test('passes cancellation token to proxy', async () => {161let receivedToken: CancellationToken | undefined;162const proxy = createMockProxy((_cmd, _input, token) => {163receivedToken = token;164return { kind: HookCommandResultKind.Success, result: 'ok' };165});166service.setProxy(proxy);167168const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };169store.add(service.registerHooks(sessionUri, hooks));170171const cts = store.add(new CancellationTokenSource());172await service.executeHook(HookType.PreToolUse, sessionUri, { token: cts.token });173174assert.strictEqual(receivedToken, cts.token);175});176177test('uses CancellationToken.None when no token provided', async () => {178let receivedToken: CancellationToken | undefined;179const proxy = createMockProxy((_cmd, _input, token) => {180receivedToken = token;181return { kind: HookCommandResultKind.Success, result: 'ok' };182});183service.setProxy(proxy);184185const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };186store.add(service.registerHooks(sessionUri, hooks));187188await service.executeHook(HookType.PreToolUse, sessionUri);189190assert.strictEqual(receivedToken, CancellationToken.None);191});192193test('extracts common fields from successful result', async () => {194const proxy = createMockProxy(() => ({195kind: HookCommandResultKind.Success,196result: {197stopReason: 'User requested stop',198systemMessage: 'Warning: hook triggered',199hookSpecificOutput: {200permissionDecision: 'allow'201}202}203}));204service.setProxy(proxy);205206const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };207store.add(service.registerHooks(sessionUri, hooks));208209const results = await service.executeHook(HookType.PreToolUse, sessionUri);210211assert.strictEqual(results.length, 1);212assert.strictEqual(results[0].resultKind, 'success');213assert.strictEqual(results[0].stopReason, 'User requested stop');214assert.strictEqual(results[0].warningMessage, 'Warning: hook triggered');215// Hook-specific fields are in output with wrapper216assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } });217});218219test('uses defaults when no common fields present', async () => {220const proxy = createMockProxy(() => ({221kind: HookCommandResultKind.Success,222result: {223hookSpecificOutput: {224permissionDecision: 'allow'225}226}227}));228service.setProxy(proxy);229230const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };231store.add(service.registerHooks(sessionUri, hooks));232233const results = await service.executeHook(HookType.PreToolUse, sessionUri);234235assert.strictEqual(results.length, 1);236assert.strictEqual(results[0].stopReason, undefined);237assert.strictEqual(results[0].warningMessage, undefined);238assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } });239});240241test('handles error results from command', async () => {242const proxy = createMockProxy(() => ({243kind: HookCommandResultKind.Error,244result: 'command failed with error'245}));246service.setProxy(proxy);247248const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };249store.add(service.registerHooks(sessionUri, hooks));250251const results = await service.executeHook(HookType.PreToolUse, sessionUri);252253assert.strictEqual(results.length, 1);254assert.strictEqual(results[0].resultKind, 'error');255assert.strictEqual(results[0].output, 'command failed with error');256// Defaults are still applied257assert.strictEqual(results[0].stopReason, undefined);258});259260test('handles non-blocking error results from command', async () => {261const proxy = createMockProxy(() => ({262kind: HookCommandResultKind.NonBlockingError,263result: 'non-blocking warning message'264}));265service.setProxy(proxy);266267const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };268store.add(service.registerHooks(sessionUri, hooks));269270const results = await service.executeHook(HookType.PreToolUse, sessionUri);271272assert.strictEqual(results.length, 1);273assert.strictEqual(results[0].resultKind, 'warning');274assert.strictEqual(results[0].output, undefined);275assert.strictEqual(results[0].warningMessage, 'non-blocking warning message');276assert.strictEqual(results[0].stopReason, undefined);277});278279test('handles non-blocking error with object result', async () => {280const proxy = createMockProxy(() => ({281kind: HookCommandResultKind.NonBlockingError,282result: { code: 'WARN_001', message: 'Something went wrong' }283}));284service.setProxy(proxy);285286const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };287store.add(service.registerHooks(sessionUri, hooks));288289const results = await service.executeHook(HookType.PreToolUse, sessionUri);290291assert.strictEqual(results.length, 1);292assert.strictEqual(results[0].resultKind, 'warning');293assert.strictEqual(results[0].output, undefined);294assert.strictEqual(results[0].warningMessage, '{"code":"WARN_001","message":"Something went wrong"}');295assert.strictEqual(results[0].stopReason, undefined);296});297298test('passes through hook-specific output fields for non-preToolUse hooks', async () => {299// Stop hooks return different fields (decision, reason) than preToolUse hooks300const proxy = createMockProxy(() => ({301kind: HookCommandResultKind.Success,302result: {303decision: 'block',304reason: 'Please run the tests'305}306}));307service.setProxy(proxy);308309const hooks = { [HookType.Stop]: [cmd('check-stop')] };310store.add(service.registerHooks(sessionUri, hooks));311312const results = await service.executeHook(HookType.Stop, sessionUri);313314assert.strictEqual(results.length, 1);315assert.strictEqual(results[0].resultKind, 'success');316// Hook-specific fields should be in output, not undefined317assert.deepStrictEqual(results[0].output, {318decision: 'block',319reason: 'Please run the tests'320});321});322323test('passes input to proxy', async () => {324let receivedInput: unknown;325const proxy = createMockProxy((_cmd, input) => {326receivedInput = input;327return { kind: HookCommandResultKind.Success, result: 'ok' };328});329service.setProxy(proxy);330331const hooks = { [HookType.PreToolUse]: [cmd('echo test')] };332store.add(service.registerHooks(sessionUri, hooks));333334const testInput = { foo: 'bar', nested: { value: 123 } };335await service.executeHook(HookType.PreToolUse, sessionUri, { input: testInput });336337// Input includes caller properties merged with common hook properties338assert.ok(typeof receivedInput === 'object' && receivedInput !== null);339const input = receivedInput as Record<string, unknown>;340assert.strictEqual(input['foo'], 'bar');341assert.deepStrictEqual(input['nested'], { value: 123 });342// Common properties are also present343assert.strictEqual(typeof input['timestamp'], 'string');344assert.strictEqual(input['hookEventName'], HookType.PreToolUse);345});346});347348suite('executePreToolUseHook', () => {349test('returns allow result when hook allows', async () => {350const proxy = createMockProxy(() => ({351kind: HookCommandResultKind.Success,352result: {353hookSpecificOutput: {354permissionDecision: 'allow',355permissionDecisionReason: 'Tool is safe'356}357}358}));359service.setProxy(proxy);360361const hooks = { [HookType.PreToolUse]: [cmd('hook')] };362store.add(service.registerHooks(sessionUri, hooks));363364const result = await service.executePreToolUseHook(365sessionUri,366{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }367);368369assert.ok(result);370assert.strictEqual(result.permissionDecision, 'allow');371assert.strictEqual(result.permissionDecisionReason, 'Tool is safe');372});373374test('returns ask result when hook requires confirmation', async () => {375const proxy = createMockProxy(() => ({376kind: HookCommandResultKind.Success,377result: {378hookSpecificOutput: {379permissionDecision: 'ask',380permissionDecisionReason: 'Requires user approval'381}382}383}));384service.setProxy(proxy);385386const hooks = { [HookType.PreToolUse]: [cmd('hook')] };387store.add(service.registerHooks(sessionUri, hooks));388389const result = await service.executePreToolUseHook(390sessionUri,391{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }392);393394assert.ok(result);395assert.strictEqual(result.permissionDecision, 'ask');396assert.strictEqual(result.permissionDecisionReason, 'Requires user approval');397});398399test('deny takes priority over ask and allow', async () => {400let callCount = 0;401const proxy = createMockProxy(() => {402callCount++;403// First hook returns allow, second returns ask, third returns deny404if (callCount === 1) {405return {406kind: HookCommandResultKind.Success,407result: { hookSpecificOutput: { permissionDecision: 'allow' } }408};409} else if (callCount === 2) {410return {411kind: HookCommandResultKind.Success,412result: { hookSpecificOutput: { permissionDecision: 'ask' } }413};414} else {415return {416kind: HookCommandResultKind.Success,417result: { hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'Blocked' } }418};419}420});421service.setProxy(proxy);422423const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2'), cmd('hook3')] };424store.add(service.registerHooks(sessionUri, hooks));425426const result = await service.executePreToolUseHook(427sessionUri,428{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }429);430431assert.ok(result);432assert.strictEqual(result.permissionDecision, 'deny');433assert.strictEqual(result.permissionDecisionReason, 'Blocked');434});435436test('ask takes priority over allow', async () => {437let callCount = 0;438const proxy = createMockProxy(() => {439callCount++;440// First hook returns allow, second returns ask441if (callCount === 1) {442return {443kind: HookCommandResultKind.Success,444result: { hookSpecificOutput: { permissionDecision: 'allow' } }445};446} else {447return {448kind: HookCommandResultKind.Success,449result: { hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'Need confirmation' } }450};451}452});453service.setProxy(proxy);454455const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] };456store.add(service.registerHooks(sessionUri, hooks));457458const result = await service.executePreToolUseHook(459sessionUri,460{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }461);462463assert.ok(result);464assert.strictEqual(result.permissionDecision, 'ask');465assert.strictEqual(result.permissionDecisionReason, 'Need confirmation');466});467468test('ignores results with wrong hookEventName', async () => {469let callCount = 0;470const proxy = createMockProxy(() => {471callCount++;472if (callCount === 1) {473// First hook returns allow but with wrong hookEventName474return {475kind: HookCommandResultKind.Success,476result: {477hookSpecificOutput: {478hookEventName: 'PostToolUse', // Wrong hook type479permissionDecision: 'deny'480}481}482};483} else {484// Second hook returns allow with correct hookEventName485return {486kind: HookCommandResultKind.Success,487result: {488hookSpecificOutput: {489hookEventName: 'PreToolUse',490permissionDecision: 'allow'491}492}493};494}495});496service.setProxy(proxy);497498const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] };499store.add(service.registerHooks(sessionUri, hooks));500501const result = await service.executePreToolUseHook(502sessionUri,503{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }504);505506// The deny with wrong hookEventName should be ignored507assert.ok(result);508assert.strictEqual(result.permissionDecision, 'allow');509});510511test('allows results without hookEventName (optional field)', async () => {512const proxy = createMockProxy(() => ({513kind: HookCommandResultKind.Success,514result: {515hookSpecificOutput: {516// No hookEventName - should be accepted517permissionDecision: 'allow'518}519}520}));521service.setProxy(proxy);522523const hooks = { [HookType.PreToolUse]: [cmd('hook')] };524store.add(service.registerHooks(sessionUri, hooks));525526const result = await service.executePreToolUseHook(527sessionUri,528{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }529);530531assert.ok(result);532assert.strictEqual(result.permissionDecision, 'allow');533});534535test('returns updatedInput when hook provides it', async () => {536const proxy = createMockProxy(() => ({537kind: HookCommandResultKind.Success,538result: {539hookSpecificOutput: {540permissionDecision: 'allow',541updatedInput: { path: '/safe/path.ts' }542}543}544}));545service.setProxy(proxy);546547const hooks = { [HookType.PreToolUse]: [cmd('hook')] };548store.add(service.registerHooks(sessionUri, hooks));549550const result = await service.executePreToolUseHook(551sessionUri,552{ toolName: 'test-tool', toolInput: { path: '/original/path.ts' }, toolCallId: 'call-1' }553);554555assert.ok(result);556assert.strictEqual(result.permissionDecision, 'allow');557assert.deepStrictEqual(result.updatedInput, { path: '/safe/path.ts' });558});559560test('later hook updatedInput overrides earlier one', async () => {561let callCount = 0;562const proxy = createMockProxy(() => {563callCount++;564if (callCount === 1) {565return {566kind: HookCommandResultKind.Success,567result: { hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'first' } } }568};569}570return {571kind: HookCommandResultKind.Success,572result: { hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'second' } } }573};574});575service.setProxy(proxy);576577const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] };578store.add(service.registerHooks(sessionUri, hooks));579580const result = await service.executePreToolUseHook(581sessionUri,582{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }583);584585assert.ok(result);586assert.deepStrictEqual(result.updatedInput, { value: 'second' });587});588589test('returns result with updatedInput even without permission decision', async () => {590const proxy = createMockProxy(() => ({591kind: HookCommandResultKind.Success,592result: {593hookSpecificOutput: {594updatedInput: { modified: true }595}596}597}));598service.setProxy(proxy);599600const hooks = { [HookType.PreToolUse]: [cmd('hook')] };601store.add(service.registerHooks(sessionUri, hooks));602603const result = await service.executePreToolUseHook(604sessionUri,605{ toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' }606);607608assert.ok(result);609assert.deepStrictEqual(result.updatedInput, { modified: true });610assert.strictEqual(result.permissionDecision, undefined);611});612613test('updatedInput combined with ask shows modified input to user', async () => {614const proxy = createMockProxy(() => ({615kind: HookCommandResultKind.Success,616result: {617hookSpecificOutput: {618permissionDecision: 'ask',619permissionDecisionReason: 'Modified input needs review',620updatedInput: { command: 'echo safe' }621}622}623}));624service.setProxy(proxy);625626const hooks = { [HookType.PreToolUse]: [cmd('hook')] };627store.add(service.registerHooks(sessionUri, hooks));628629const result = await service.executePreToolUseHook(630sessionUri,631{ toolName: 'test-tool', toolInput: { command: 'rm -rf /' }, toolCallId: 'call-1' }632);633634assert.ok(result);635assert.strictEqual(result.permissionDecision, 'ask');636assert.strictEqual(result.permissionDecisionReason, 'Modified input needs review');637assert.deepStrictEqual(result.updatedInput, { command: 'echo safe' });638});639});640641suite('executePostToolUseHook', () => {642test('returns undefined when no hooks configured', async () => {643const proxy = createMockProxy();644service.setProxy(proxy);645646const hooks = { [HookType.PreToolUse]: [cmd('hook')] };647store.add(service.registerHooks(sessionUri, hooks));648649const result = await service.executePostToolUseHook(650sessionUri,651{ toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' }652);653654assert.strictEqual(result, undefined);655});656657test('returns block decision when hook blocks', async () => {658const proxy = createMockProxy(() => ({659kind: HookCommandResultKind.Success,660result: {661decision: 'block',662reason: 'Lint errors found'663}664}));665service.setProxy(proxy);666667const hooks = { [HookType.PostToolUse]: [cmd('hook')] };668store.add(service.registerHooks(sessionUri, hooks));669670const result = await service.executePostToolUseHook(671sessionUri,672{ toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' }673);674675assert.ok(result);676assert.strictEqual(result.decision, 'block');677assert.strictEqual(result.reason, 'Lint errors found');678});679680test('returns additionalContext from hookSpecificOutput', async () => {681const proxy = createMockProxy(() => ({682kind: HookCommandResultKind.Success,683result: {684hookSpecificOutput: {685hookEventName: 'PostToolUse',686additionalContext: 'File was modified successfully'687}688}689}));690service.setProxy(proxy);691692const hooks = { [HookType.PostToolUse]: [cmd('hook')] };693store.add(service.registerHooks(sessionUri, hooks));694695const result = await service.executePostToolUseHook(696sessionUri,697{ toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' }698);699700assert.ok(result);701assert.deepStrictEqual(result.additionalContext, ['File was modified successfully']);702assert.strictEqual(result.decision, undefined);703});704705test('block takes priority and collects all additionalContext', async () => {706let callCount = 0;707const proxy = createMockProxy(() => {708callCount++;709if (callCount === 1) {710return {711kind: HookCommandResultKind.Success,712result: {713decision: 'block',714reason: 'Tests failed'715}716};717} else {718return {719kind: HookCommandResultKind.Success,720result: {721hookSpecificOutput: {722additionalContext: 'Extra context from second hook'723}724}725};726}727});728service.setProxy(proxy);729730const hooks = { [HookType.PostToolUse]: [cmd('hook1'), cmd('hook2')] };731store.add(service.registerHooks(sessionUri, hooks));732733const result = await service.executePostToolUseHook(734sessionUri,735{ toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' }736);737738assert.ok(result);739assert.strictEqual(result.decision, 'block');740assert.strictEqual(result.reason, 'Tests failed');741assert.deepStrictEqual(result.additionalContext, ['Extra context from second hook']);742});743744test('ignores results with wrong hookEventName', async () => {745let callCount = 0;746const proxy = createMockProxy(() => {747callCount++;748if (callCount === 1) {749return {750kind: HookCommandResultKind.Success,751result: {752hookSpecificOutput: {753hookEventName: 'PreToolUse',754additionalContext: 'Should be ignored'755}756}757};758} else {759return {760kind: HookCommandResultKind.Success,761result: {762hookSpecificOutput: {763hookEventName: 'PostToolUse',764additionalContext: 'Correct context'765}766}767};768}769});770service.setProxy(proxy);771772const hooks = { [HookType.PostToolUse]: [cmd('hook1'), cmd('hook2')] };773store.add(service.registerHooks(sessionUri, hooks));774775const result = await service.executePostToolUseHook(776sessionUri,777{ toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' }778);779780assert.ok(result);781assert.deepStrictEqual(result.additionalContext, ['Correct context']);782});783784test('passes tool response text as string to external command', async () => {785let receivedInput: unknown;786const proxy = createMockProxy((_cmd, input) => {787receivedInput = input;788return { kind: HookCommandResultKind.Success, result: {} };789});790service.setProxy(proxy);791792const hooks = { [HookType.PostToolUse]: [cmd('hook')] };793store.add(service.registerHooks(sessionUri, hooks));794795await service.executePostToolUseHook(796sessionUri,797{ toolName: 'my-tool', toolInput: { arg: 'val' }, getToolResponseText: () => 'file contents here', toolCallId: 'call-42' }798);799800assert.ok(typeof receivedInput === 'object' && receivedInput !== null);801const input = receivedInput as Record<string, unknown>;802assert.strictEqual(input['tool_name'], 'my-tool');803assert.deepStrictEqual(input['tool_input'], { arg: 'val' });804assert.strictEqual(input['tool_response'], 'file contents here');805assert.strictEqual(input['tool_use_id'], 'call-42');806assert.strictEqual(input['hookEventName'], HookType.PostToolUse);807});808809test('does not call getter when no PostToolUse hooks registered', async () => {810const proxy = createMockProxy();811service.setProxy(proxy);812813// Register hooks only for PreToolUse, not PostToolUse814const hooks = { [HookType.PreToolUse]: [cmd('hook')] };815store.add(service.registerHooks(sessionUri, hooks));816817let getterCalled = false;818const result = await service.executePostToolUseHook(819sessionUri,820{821toolName: 'test-tool',822toolInput: {},823getToolResponseText: () => { getterCalled = true; return ''; },824toolCallId: 'call-1'825}826);827828assert.strictEqual(result, undefined);829assert.strictEqual(getterCalled, false);830});831});832833suite('preToolUse smoke tests — input → output', () => {834test('single hook: allow', async () => {835const proxy = createMockProxy(() => ({836kind: HookCommandResultKind.Success,837result: {838hookSpecificOutput: {839permissionDecision: 'allow',840permissionDecisionReason: 'Trusted tool',841}842}843}));844service.setProxy(proxy);845846const hooks = { [HookType.PreToolUse]: [cmd('lint-check')] };847store.add(service.registerHooks(sessionUri, hooks));848849const input = { toolName: 'readFile', toolInput: { path: '/src/index.ts' }, toolCallId: 'call-1' };850const result = await service.executePreToolUseHook(sessionUri, input);851852assert.deepStrictEqual(853JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason, additionalContext: result?.additionalContext }),854JSON.stringify({ permissionDecision: 'allow', permissionDecisionReason: 'Trusted tool', additionalContext: undefined })855);856});857858test('single hook: deny', async () => {859const proxy = createMockProxy(() => ({860kind: HookCommandResultKind.Success,861result: {862hookSpecificOutput: {863permissionDecision: 'deny',864permissionDecisionReason: 'Path is outside workspace',865}866}867}));868service.setProxy(proxy);869870const hooks = { [HookType.PreToolUse]: [cmd('path-guard')] };871store.add(service.registerHooks(sessionUri, hooks));872873const input = { toolName: 'writeFile', toolInput: { path: '/etc/passwd' }, toolCallId: 'call-2' };874const result = await service.executePreToolUseHook(sessionUri, input);875876assert.deepStrictEqual(877JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }),878JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: 'Path is outside workspace' })879);880});881882test('multiple hooks: deny wins over allow and ask', async () => {883// Three hooks return allow, ask, deny (in that order).884// deny must win regardless of ordering.885let callCount = 0;886const decisions = ['allow', 'ask', 'deny'] as const;887const proxy = createMockProxy(() => {888const decision = decisions[callCount++];889return {890kind: HookCommandResultKind.Success,891result: { hookSpecificOutput: { permissionDecision: decision, permissionDecisionReason: `hook-${callCount}` } }892};893});894service.setProxy(proxy);895896const hooks = { [HookType.PreToolUse]: [cmd('h1'), cmd('h2'), cmd('h3')] };897store.add(service.registerHooks(sessionUri, hooks));898899const result = await service.executePreToolUseHook(900sessionUri,901{ toolName: 'runCommand', toolInput: { cmd: 'rm -rf /' }, toolCallId: 'call-3' }902);903904assert.deepStrictEqual(905JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }),906JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: 'hook-3' })907);908});909910test('multiple hooks: ask wins over allow', async () => {911let callCount = 0;912const decisions = ['allow', 'ask'] as const;913const proxy = createMockProxy(() => {914const decision = decisions[callCount++];915return {916kind: HookCommandResultKind.Success,917result: { hookSpecificOutput: { permissionDecision: decision, permissionDecisionReason: `reason-${decision}` } }918};919});920service.setProxy(proxy);921922const hooks = { [HookType.PreToolUse]: [cmd('h1'), cmd('h2')] };923store.add(service.registerHooks(sessionUri, hooks));924925const result = await service.executePreToolUseHook(926sessionUri,927{ toolName: 'exec', toolInput: {}, toolCallId: 'call-4' }928);929930assert.deepStrictEqual(931JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }),932JSON.stringify({ permissionDecision: 'ask', permissionDecisionReason: 'reason-ask' })933);934});935});936937suite('postToolUse smoke tests — input → output', () => {938test('single hook: block', async () => {939const proxy = createMockProxy(() => ({940kind: HookCommandResultKind.Success,941result: {942decision: 'block',943reason: 'Lint errors found'944}945}));946service.setProxy(proxy);947948const hooks = { [HookType.PostToolUse]: [cmd('lint')] };949store.add(service.registerHooks(sessionUri, hooks));950951const input = { toolName: 'writeFile', toolInput: { path: 'foo.ts' }, getToolResponseText: () => 'wrote 42 bytes', toolCallId: 'call-5' };952const result = await service.executePostToolUseHook(sessionUri, input);953954assert.deepStrictEqual(955JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }),956JSON.stringify({ decision: 'block', reason: 'Lint errors found', additionalContext: undefined })957);958});959960test('single hook: additionalContext only', async () => {961const proxy = createMockProxy(() => ({962kind: HookCommandResultKind.Success,963result: {964hookSpecificOutput: {965additionalContext: 'Tests still pass after this edit'966}967}968}));969service.setProxy(proxy);970971const hooks = { [HookType.PostToolUse]: [cmd('test-runner')] };972store.add(service.registerHooks(sessionUri, hooks));973974const input = { toolName: 'editFile', toolInput: {}, getToolResponseText: () => 'ok', toolCallId: 'call-6' };975const result = await service.executePostToolUseHook(sessionUri, input);976977assert.deepStrictEqual(978JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }),979JSON.stringify({ decision: undefined, reason: undefined, additionalContext: ['Tests still pass after this edit'] })980);981});982983test('multiple hooks: block wins and all hooks run', async () => {984let callCount = 0;985const proxy = createMockProxy(() => {986callCount++;987if (callCount === 1) {988return { kind: HookCommandResultKind.Success, result: { decision: 'block', reason: 'Tests failed' } };989}990return { kind: HookCommandResultKind.Success, result: { hookSpecificOutput: { additionalContext: 'context from second hook' } } };991});992service.setProxy(proxy);993994const hooks = { [HookType.PostToolUse]: [cmd('test'), cmd('lint')] };995store.add(service.registerHooks(sessionUri, hooks));996997const result = await service.executePostToolUseHook(998sessionUri,999{ toolName: 'writeFile', toolInput: {}, getToolResponseText: () => 'data', toolCallId: 'call-7' }1000);10011002assert.deepStrictEqual(1003JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }),1004JSON.stringify({ decision: 'block', reason: 'Tests failed', additionalContext: ['context from second hook'] })1005);1006});10071008test('no hooks registered → undefined (getter never called)', async () => {1009const proxy = createMockProxy();1010service.setProxy(proxy);10111012// Register PreToolUse only — no PostToolUse1013store.add(service.registerHooks(sessionUri, { [HookType.PreToolUse]: [cmd('h')] }));10141015let getterCalled = false;1016const result = await service.executePostToolUseHook(1017sessionUri,1018{ toolName: 't', toolInput: {}, getToolResponseText: () => { getterCalled = true; return ''; }, toolCallId: 'c' }1019);10201021assert.strictEqual(result, undefined);1022assert.strictEqual(getterCalled, false);1023});1024});10251026function createMockProxy(handler?: (cmd: IHookCommand, input: unknown, token: CancellationToken) => IHookCommandResult): IHooksExecutionProxy {1027return {1028runHookCommand: async (hookCommand, input, token) => {1029if (handler) {1030return handler(hookCommand, input, token);1031}1032return { kind: HookCommandResultKind.Success, result: 'mock result' };1033}1034};1035}1036});103710381039