/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { GenerateContentParameters, GenerateContentResponse, } from '@google/genai'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HookEventHandler } from './hookEventHandler.js'; import type { Config } from '../config/config.js'; import { NotificationType, SessionStartSource, HookEventName, HookType, type HookConfig, type HookExecutionResult, } from './types.js'; import type { HookPlanner } from './hookPlanner.js'; import type { HookRunner } from './hookRunner.js'; import type { HookAggregator } from './hookAggregator.js'; // Mock debugLogger const mockDebugLogger = vi.hoisted(() => ({ log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), })); // Mock coreEvents const mockCoreEvents = vi.hoisted(() => ({ emitFeedback: vi.fn(), emitHookStart: vi.fn(), emitHookEnd: vi.fn(), })); vi.mock('../utils/debugLogger.js', () => ({ debugLogger: mockDebugLogger, })); vi.mock('../utils/events.js', () => ({ coreEvents: mockCoreEvents, })); vi.mock('../telemetry/clearcut-logger/clearcut-logger.js', () => ({ ClearcutLogger: { getInstance: vi.fn().mockReturnValue({ logHookCallEvent: vi.fn(), }), }, })); describe('HookEventHandler', () => { let hookEventHandler: HookEventHandler; let mockConfig: Config; let mockHookPlanner: HookPlanner; let mockHookRunner: HookRunner; let mockHookAggregator: HookAggregator; beforeEach(() => { vi.resetAllMocks(); mockConfig = { getSessionId: vi.fn().mockReturnValue('test-session'), getWorkingDir: vi.fn().mockReturnValue('/test/project'), getGeminiClient: vi.fn().mockReturnValue({ getChatRecordingService: vi.fn().mockReturnValue({ getConversationFilePath: vi .fn() .mockReturnValue('/test/project/.gemini/tmp/chats/session.json'), }), }), } as unknown as Config; mockHookPlanner = { createExecutionPlan: vi.fn(), } as unknown as HookPlanner; mockHookRunner = { executeHooksParallel: vi.fn(), executeHooksSequential: vi.fn(), } as unknown as HookRunner; mockHookAggregator = { aggregateResults: vi.fn(), } as unknown as HookAggregator; hookEventHandler = new HookEventHandler( mockConfig, mockHookPlanner, mockHookRunner, mockHookAggregator, ); }); describe('fireBeforeToolEvent', () => { it('should fire BeforeTool event with correct input', async () => { const mockPlan = [ { hookConfig: { type: HookType.Command, command: './test.sh', } as unknown as HookConfig, eventName: HookEventName.BeforeTool, }, ]; const mockResults: HookExecutionResult[] = [ { success: true, duration: 100, hookConfig: { type: HookType.Command, command: './test.sh', timeout: 30000, }, eventName: HookEventName.BeforeTool, }, ]; const mockAggregated = { success: true, allOutputs: [], errors: [], totalDuration: 100, }; vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ eventName: HookEventName.BeforeTool, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, }); vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue( mockResults, ); vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( mockAggregated, ); const result = await hookEventHandler.fireBeforeToolEvent('EditTool', { file: 'test.txt', }); expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith( HookEventName.BeforeTool, { toolName: 'EditTool' }, ); expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith( [mockPlan[0].hookConfig], HookEventName.BeforeTool, expect.objectContaining({ session_id: 'test-session', cwd: '/test/project', hook_event_name: 'BeforeTool', tool_name: 'EditTool', tool_input: { file: 'test.txt' }, }), expect.any(Function), expect.any(Function), ); // Verify event emission via callbacks const onHookStart = vi.mocked(mockHookRunner.executeHooksParallel).mock .calls[0][3]; const onHookEnd = vi.mocked(mockHookRunner.executeHooksParallel).mock .calls[0][4]; if (onHookStart) onHookStart(mockPlan[0].hookConfig, 0); expect(mockCoreEvents.emitHookStart).toHaveBeenCalledWith({ hookName: './test.sh', eventName: HookEventName.BeforeTool, hookIndex: 1, totalHooks: 1, }); if (onHookEnd) onHookEnd(mockPlan[0].hookConfig, mockResults[0]); expect(mockCoreEvents.emitHookEnd).toHaveBeenCalledWith({ hookName: './test.sh', eventName: HookEventName.BeforeTool, success: true, }); expect(result).toBe(mockAggregated); }); it('should return empty result when no hooks to execute', async () => { vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(null); const result = await hookEventHandler.fireBeforeToolEvent('EditTool', {}); expect(result.success).toBe(true); expect(result.allOutputs).toHaveLength(0); expect(result.errors).toHaveLength(0); expect(result.totalDuration).toBe(0); }); it('should handle execution errors gracefully', async () => { vi.mocked(mockHookPlanner.createExecutionPlan).mockImplementation(() => { throw new Error('Planning failed'); }); const result = await hookEventHandler.fireBeforeToolEvent('EditTool', {}); expect(result.success).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].message).toBe('Planning failed'); expect(mockDebugLogger.error).toHaveBeenCalled(); }); it('should emit feedback when some hooks fail', async () => { const mockPlan = [ { type: HookType.Command, command: './fail.sh', } as HookConfig, ]; const mockResults: HookExecutionResult[] = [ { success: false, duration: 50, hookConfig: mockPlan[0], eventName: HookEventName.BeforeTool, error: new Error('Failed to execute'), }, ]; const mockAggregated = { success: false, allOutputs: [], errors: [new Error('Failed to execute')], totalDuration: 50, }; vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ eventName: HookEventName.BeforeTool, hookConfigs: mockPlan, sequential: false, }); vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue( mockResults, ); vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( mockAggregated, ); await hookEventHandler.fireBeforeToolEvent('EditTool', {}); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( 'warning', expect.stringContaining('./fail.sh'), ); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( 'warning', expect.stringContaining('F12'), ); }); it('should fire BeforeTool event with MCP context when provided', async () => { const mockPlan = [ { hookConfig: { type: HookType.Command, command: './test.sh', } as unknown as HookConfig, eventName: HookEventName.BeforeTool, }, ]; const mockResults: HookExecutionResult[] = [ { success: true, duration: 100, hookConfig: { type: HookType.Command, command: './test.sh', timeout: 30000, }, eventName: HookEventName.BeforeTool, }, ]; const mockAggregated = { success: true, allOutputs: [], errors: [], totalDuration: 100, }; vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ eventName: HookEventName.BeforeTool, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, }); vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue( mockResults, ); vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( mockAggregated, ); const mcpContext = { server_name: 'my-mcp-server', tool_name: 'read_file', command: 'npx', args: ['-y', '@my-org/mcp-server'], }; const result = await hookEventHandler.fireBeforeToolEvent( 'my-mcp-server__read_file', { path: '/etc/passwd' }, mcpContext, ); expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith( [mockPlan[0].hookConfig], HookEventName.BeforeTool, expect.objectContaining({ session_id: 'test-session', cwd: '/test/project', hook_event_name: 'BeforeTool', tool_name: 'my-mcp-server__read_file', tool_input: { path: '/etc/passwd' }, mcp_context: mcpContext, }), expect.any(Function), expect.any(Function), ); expect(result).toBe(mockAggregated); }); it('should not include mcp_context when not provided', async () => { const mockPlan = [ { hookConfig: { type: HookType.Command, command: './test.sh', } as unknown as HookConfig, eventName: HookEventName.BeforeTool, }, ]; const mockResults: HookExecutionResult[] = [ { success: true, duration: 100, hookConfig: { type: HookType.Command, command: './test.sh', timeout: 30000, }, eventName: HookEventName.BeforeTool, }, ]; const mockAggregated = { success: true, allOutputs: [], errors: [], totalDuration: 100, }; vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ eventName: HookEventName.BeforeTool, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, }); vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue( mockResults, ); vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( mockAggregated, ); await hookEventHandler.fireBeforeToolEvent('EditTool', { file: 'test.txt', }); const callArgs = vi.mocked(mockHookRunner.executeHooksParallel).mock .calls[0][2]; expect(callArgs).not.toHaveProperty('mcp_context'); }); }); describe('fireAfterToolEvent', () => { it('should fire AfterTool event with tool response', async () => { const mockPlan = [ { hookConfig: { type: HookType.Command, command: './after.sh', } as unknown as HookConfig, eventName: HookEventName.AfterTool, }, ]; const mockResults: HookExecutionResult[] = [ { success: true, duration: 100, hookConfig: { type: HookType.Command, command: './test.sh', timeout: 30000, }, eventName: HookEventName.BeforeTool, }, ]; const mockAggregated = { success: true, allOutputs: [], errors: [], totalDuration: 100, }; vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ eventName: HookEventName.BeforeTool, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, }); vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue( mockResults, ); vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( mockAggregated, ); const toolInput = { file: 'test.txt' }; const toolResponse = { success: true, content: 'File edited' }; const result = await hookEventHandler.fireAfterToolEvent( 'EditTool', toolInput, toolResponse, ); expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith( [mockPlan[0].hookConfig], HookEventName.AfterTool, expect.objectContaining({ tool_name: 'EditTool', tool_input: toolInput, tool_response: toolResponse, }), expect.any(Function), expect.any(Function), ); expect(result).toBe(mockAggregated); }); it('should fire AfterTool event with MCP context when provided', async () => { const mockPlan = [ { hookConfig: { type: HookType.Command, command: './after.sh', } as unknown as HookConfig, eventName: HookEventName.AfterTool, }, ]; const mockResults: HookExecutionResult[] = [ { success: true, duration: 100, hookConfig: { type: HookType.Command, command: './after.sh', timeout: 30000, }, eventName: HookEventName.AfterTool, }, ]; const mockAggregated = { success: true, allOutputs: [], errors: [], totalDuration: 100, }; vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ eventName: HookEventName.AfterTool, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, }); vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue( mockResults, ); vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( mockAggregated, ); const toolInput = { path: '/etc/passwd' }; const toolResponse = { success: true, content: 'File content' }; const mcpContext = { server_name: 'my-mcp-server', tool_name: 'read_file', url: 'https://mcp.example.com', }; const result = await hookEventHandler.fireAfterToolEvent( 'my-mcp-server__read_file', toolInput, toolResponse, mcpContext, ); expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith( [mockPlan[0].hookConfig], HookEventName.AfterTool, expect.objectContaining({ tool_name: 'my-mcp-server__read_file', tool_input: toolInput, tool_response: toolResponse, mcp_context: mcpContext, }), expect.any(Function), expect.any(Function), ); expect(result).toBe(mockAggregated); }); }); describe('fireBeforeAgentEvent', () => { it('should fire BeforeAgent event with prompt', async () => { const mockPlan = [ { hookConfig: { type: HookType.Command, command: './before_agent.sh', } as unknown as HookConfig, eventName: HookEventName.BeforeAgent, }, ]; const mockResults: HookExecutionResult[] = [ { success: true, duration: 100, hookConfig: { type: HookType.Command, command: './test.sh', timeout: 30000, }, eventName: HookEventName.BeforeTool, }, ]; const mockAggregated = { success: true, allOutputs: [], errors: [], totalDuration: 100, }; vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ eventName: HookEventName.BeforeTool, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, }); vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue( mockResults, ); vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( mockAggregated, ); const prompt = 'Please help me with this task'; const result = await hookEventHandler.fireBeforeAgentEvent(prompt); expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith( [mockPlan[0].hookConfig], HookEventName.BeforeAgent, expect.objectContaining({ prompt, }), expect.any(Function), expect.any(Function), ); expect(result).toBe(mockAggregated); }); }); describe('fireNotificationEvent', () => { it('should fire Notification event', async () => { const mockPlan = [ { hookConfig: { type: HookType.Command, command: './notification-hook.sh', } as HookConfig, eventName: HookEventName.Notification, }, ]; const mockResults: HookExecutionResult[] = [ { success: true, duration: 50, hookConfig: { type: HookType.Command, command: './notification-hook.sh', timeout: 30000, }, eventName: HookEventName.Notification, }, ]; const mockAggregated = { success: true, allOutputs: [], errors: [], totalDuration: 50, }; vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ eventName: HookEventName.Notification, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, }); vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue( mockResults, ); vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( mockAggregated, ); const message = 'Tool execution requires permission'; const result = await hookEventHandler.fireNotificationEvent( NotificationType.ToolPermission, message, { type: 'ToolPermission', title: 'Test Permission' }, ); expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith( [mockPlan[0].hookConfig], HookEventName.Notification, expect.objectContaining({ notification_type: 'ToolPermission', details: { type: 'ToolPermission', title: 'Test Permission' }, }), expect.any(Function), expect.any(Function), ); expect(result).toBe(mockAggregated); }); }); describe('fireSessionStartEvent', () => { it('should fire SessionStart event with source', async () => { const mockPlan = [ { hookConfig: { type: HookType.Command, command: './session_start.sh', } as unknown as HookConfig, eventName: HookEventName.SessionStart, }, ]; const mockResults: HookExecutionResult[] = [ { success: true, duration: 200, hookConfig: { type: HookType.Command, command: './session_start.sh', timeout: 30000, }, eventName: HookEventName.SessionStart, }, ]; const mockAggregated = { success: true, allOutputs: [], errors: [], totalDuration: 200, }; vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ eventName: HookEventName.SessionStart, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, }); vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue( mockResults, ); vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( mockAggregated, ); const result = await hookEventHandler.fireSessionStartEvent( SessionStartSource.Startup, ); expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith( HookEventName.SessionStart, { trigger: 'startup' }, ); expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith( [mockPlan[0].hookConfig], HookEventName.SessionStart, expect.objectContaining({ source: 'startup', }), expect.any(Function), expect.any(Function), ); expect(result).toBe(mockAggregated); }); }); describe('fireBeforeModelEvent', () => { it('should fire BeforeModel event with LLM request', async () => { const mockPlan = [ { hookConfig: { type: HookType.Command, command: './model-hook.sh', } as HookConfig, eventName: HookEventName.BeforeModel, }, ]; const mockResults: HookExecutionResult[] = [ { success: true, duration: 150, hookConfig: { type: HookType.Command, command: './model-hook.sh', timeout: 30000, }, eventName: HookEventName.BeforeModel, }, ]; const mockAggregated = { success: true, allOutputs: [], errors: [], totalDuration: 150, }; vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ eventName: HookEventName.BeforeModel, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, }); vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue( mockResults, ); vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( mockAggregated, ); const llmRequest = { model: 'gemini-pro', config: { temperature: 0.7 }, contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], }; const result = await hookEventHandler.fireBeforeModelEvent(llmRequest); expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith( [mockPlan[0].hookConfig], HookEventName.BeforeModel, expect.objectContaining({ llm_request: expect.objectContaining({ model: 'gemini-pro', messages: expect.arrayContaining([ expect.objectContaining({ role: 'user', content: 'Hello', }), ]), }), }), expect.any(Function), expect.any(Function), ); expect(result).toBe(mockAggregated); }); }); describe('failure suppression', () => { it('should suppress duplicate feedback for the same failing hook and request context', async () => { const mockHook: HookConfig = { type: HookType.Command, command: './fail.sh', name: 'failing-hook', }; const mockResults: HookExecutionResult[] = [ { success: false, duration: 10, hookConfig: mockHook, eventName: HookEventName.AfterModel, error: new Error('Failed'), }, ]; const mockAggregated = { success: false, allOutputs: [], errors: [new Error('Failed')], totalDuration: 10, }; vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ eventName: HookEventName.AfterModel, hookConfigs: [mockHook], sequential: false, }); vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue( mockResults, ); vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( mockAggregated, ); const llmRequest = { model: 'test', contents: [] }; const llmResponse = { candidates: [] }; // First call - should emit feedback await hookEventHandler.fireAfterModelEvent( llmRequest as unknown as GenerateContentParameters, llmResponse as unknown as GenerateContentResponse, ); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledTimes(1); // Second call with SAME request - should NOT emit feedback await hookEventHandler.fireAfterModelEvent( llmRequest as unknown as GenerateContentParameters, llmResponse as unknown as GenerateContentResponse, ); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledTimes(1); // Third call with DIFFERENT request - should emit feedback again const differentRequest = { model: 'different', contents: [] }; await hookEventHandler.fireAfterModelEvent( differentRequest as unknown as GenerateContentParameters, llmResponse as unknown as GenerateContentResponse, ); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledTimes(2); }); }); describe('createBaseInput', () => { it('should create base input with correct fields', async () => { const mockPlan = [ { hookConfig: { type: HookType.Command, command: './test.sh', } as unknown as HookConfig, eventName: HookEventName.BeforeTool, }, ]; vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ eventName: HookEventName.BeforeTool, hookConfigs: mockPlan.map((p) => p.hookConfig), sequential: false, }); vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue({ success: true, allOutputs: [], errors: [], totalDuration: 0, }); await hookEventHandler.fireBeforeToolEvent('TestTool', {}); expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith( expect.any(Array), HookEventName.BeforeTool, expect.objectContaining({ session_id: 'test-session', transcript_path: '/test/project/.gemini/tmp/chats/session.json', cwd: '/test/project', hook_event_name: 'BeforeTool', timestamp: expect.any(String), }), expect.any(Function), expect.any(Function), ); }); }); });