| |
| |
| |
| |
| |
|
|
| 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'; |
|
|
| |
| const mockDebugLogger = vi.hoisted(() => ({ |
| log: vi.fn(), |
| warn: vi.fn(), |
| error: vi.fn(), |
| debug: vi.fn(), |
| })); |
|
|
| |
| const mockCoreEvents = vi.hoisted(() => ({ |
| emitFeedback: vi.fn(), |
| emitHookStart: vi.fn(), |
| emitHookEnd: vi.fn(), |
| emitHookSystemMessage: 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(); |
|
|
| const mockGeminiClient = { |
| getChatRecordingService: vi.fn().mockReturnValue({ |
| getConversationFilePath: vi |
| .fn() |
| .mockReturnValue('/test/project/.gemini/tmp/chats/session.json'), |
| }), |
| }; |
|
|
| mockConfig = { |
| get config() { |
| return this; |
| }, |
| geminiClient: mockGeminiClient, |
| getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), |
| getSessionId: vi.fn().mockReturnValue('test-session'), |
| getWorkingDir: vi.fn().mockReturnValue('/test/project'), |
| } 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), |
| ); |
|
|
| |
| 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: [] }; |
|
|
| |
| await hookEventHandler.fireAfterModelEvent( |
| llmRequest as unknown as GenerateContentParameters, |
| llmResponse as unknown as GenerateContentResponse, |
| ); |
| expect(mockCoreEvents.emitFeedback).toHaveBeenCalledTimes(1); |
|
|
| |
| await hookEventHandler.fireAfterModelEvent( |
| llmRequest as unknown as GenerateContentParameters, |
| llmResponse as unknown as GenerateContentResponse, |
| ); |
| expect(mockCoreEvents.emitFeedback).toHaveBeenCalledTimes(1); |
|
|
| |
| 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), |
| ); |
| }); |
| }); |
|
|
| describe('systemMessage event emission', () => { |
| const buildMocks = ( |
| outputFormat: 'json' | 'text', |
| systemMessage: string, |
| ) => { |
| const hookConfig: HookConfig = { |
| type: HookType.Command, |
| command: './hook.sh', |
| timeout: 30000, |
| }; |
| const results: HookExecutionResult[] = [ |
| { |
| success: true, |
| duration: 10, |
| hookConfig, |
| eventName: HookEventName.SessionStart, |
| output: { systemMessage }, |
| outputFormat, |
| }, |
| ]; |
| vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ |
| eventName: HookEventName.SessionStart, |
| hookConfigs: [hookConfig], |
| sequential: false, |
| }); |
| vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue(results); |
| vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue({ |
| success: true, |
| allOutputs: [], |
| errors: [], |
| totalDuration: 10, |
| }); |
| }; |
|
|
| it('emits HookSystemMessage for json-format hook output', async () => { |
| buildMocks('json', 'json banner'); |
|
|
| await hookEventHandler.fireSessionStartEvent(SessionStartSource.Startup); |
|
|
| expect(mockCoreEvents.emitHookSystemMessage).toHaveBeenCalledTimes(1); |
| expect(mockCoreEvents.emitHookSystemMessage).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| eventName: HookEventName.SessionStart, |
| message: 'json banner', |
| }), |
| ); |
| }); |
|
|
| it('emits HookSystemMessage for text-format hook output', async () => { |
| buildMocks('text', 'plain-text banner'); |
|
|
| await hookEventHandler.fireSessionStartEvent(SessionStartSource.Startup); |
|
|
| expect(mockCoreEvents.emitHookSystemMessage).toHaveBeenCalledTimes(1); |
| expect(mockCoreEvents.emitHookSystemMessage).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| eventName: HookEventName.SessionStart, |
| message: 'plain-text banner', |
| }), |
| ); |
| }); |
|
|
| it('does not emit when systemMessage is absent', async () => { |
| const hookConfig: HookConfig = { |
| type: HookType.Command, |
| command: './hook.sh', |
| timeout: 30000, |
| }; |
| vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue({ |
| eventName: HookEventName.SessionStart, |
| hookConfigs: [hookConfig], |
| sequential: false, |
| }); |
| vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([ |
| { |
| success: true, |
| duration: 10, |
| hookConfig, |
| eventName: HookEventName.SessionStart, |
| output: {}, |
| outputFormat: 'json', |
| }, |
| ]); |
| vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue({ |
| success: true, |
| allOutputs: [], |
| errors: [], |
| totalDuration: 10, |
| }); |
|
|
| await hookEventHandler.fireSessionStartEvent(SessionStartSource.Startup); |
|
|
| expect(mockCoreEvents.emitHookSystemMessage).not.toHaveBeenCalled(); |
| }); |
| }); |
| }); |
|
|