Merge pull request #2203 from QwenLM/feat/hook_sessionstart_sessionend

feat(hooks): Implement 10 core event hooks for session lifecycle and tool execution
This commit is contained in:
DennisYu07 2026-03-19 18:57:07 +08:00 committed by GitHub
commit 58bc7a5198
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 12716 additions and 476 deletions

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,11 @@ export default defineConfig({
globalSetup: './globalSetup.ts',
reporters: ['default'],
include: ['**/*.test.ts'],
exclude: ['**/terminal-bench/*.test.ts', '**/node_modules/**'],
exclude: [
'**/terminal-bench/*.test.ts',
'**/hook-integration/**',
'**/node_modules/**',
],
retry: 2,
fileParallelism: true,
poolOptions: {

View file

@ -1411,6 +1411,109 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
Notification: {
type: 'array',
label: 'Notification Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when notifications are sent.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PreToolUse: {
type: 'array',
label: 'Pre Tool Use Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute before tool execution.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PostToolUse: {
type: 'array',
label: 'Post Tool Use Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute after successful tool execution.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PostToolUseFailure: {
type: 'array',
label: 'Post Tool Use Failure Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when tool execution fails. ',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SessionStart: {
type: 'array',
label: 'Session Start Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when a new session starts or resumes.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SessionEnd: {
type: 'array',
label: 'Session End Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when a session ends.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PreCompact: {
type: 'array',
label: 'Pre Compact Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute before conversation compaction.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SubagentStart: {
type: 'array',
label: 'Subagent Start Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when a subagent (Task tool call) is started.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SubagentStop: {
type: 'array',
label: 'Subagent Stop Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute right before a subagent (Task tool call) concludes its response.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PermissionRequest: {
type: 'array',
label: 'Permission Request Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when a permission dialog is displayed.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
},
},

View file

@ -48,6 +48,17 @@ vi.mock('../ui/commands/permissionsCommand.js', async () => {
};
});
vi.mock('../ui/commands/hooksCommand.js', async () => {
const { CommandKind } = await import('../ui/commands/types.js');
return {
hooksCommand: {
name: 'hooks',
description: 'Hooks command',
kind: CommandKind.BUILT_IN,
},
};
});
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
import type { Config } from '@qwen-code/qwen-code-core';
@ -100,6 +111,7 @@ describe('BuiltinCommandLoader', () => {
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
getUseModelRouter: () => false,
getEnableHooks: vi.fn().mockReturnValue(true),
} as unknown as Config;
restoreCommandMock.mockReturnValue({
@ -184,4 +196,19 @@ describe('BuiltinCommandLoader', () => {
expect(modelCmd).toBeDefined();
expect(modelCmd?.name).toBe('model');
});
it('should include hooks command when enableHooks is true', async () => {
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const hooksCmd = commands.find((c) => c.name === 'hooks');
expect(hooksCmd).toBeDefined();
});
it('should exclude hooks command when enableHooks is false', async () => {
(mockConfig.getEnableHooks as Mock).mockReturnValue(false);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const hooksCmd = commands.find((c) => c.name === 'hooks');
expect(hooksCmd).toBeUndefined();
});
});

View file

@ -77,7 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
exportCommand,
extensionsCommand,
helpCommand,
hooksCommand,
...(this.config?.getEnableHooks() ? [hooksCommand] : []),
await ideCommand(),
initCommand,
languageCommand,

View file

@ -39,6 +39,8 @@ import {
getAllGeminiMdFilenames,
ShellExecutionService,
Storage,
SessionEndReason,
SessionStartSource,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { validateAuthMethod } from '../config/auth.js';
@ -291,7 +293,42 @@ export const AppContainer = (props: AppContainerProps) => {
);
historyManager.loadHistory(historyItems);
}
// Fire SessionStart event after config is initialized
const sessionStartSource = resumedSessionData
? SessionStartSource.Resume
: SessionStartSource.Startup;
const hookSystem = config.getHookSystem();
if (hookSystem) {
hookSystem
.fireSessionStartEvent(sessionStartSource, config.getModel() ?? '')
.then(() => {
debugLogger.debug('SessionStart event completed successfully');
})
.catch((err) => {
debugLogger.warn(`SessionStart hook failed: ${err}`);
});
} else {
debugLogger.debug(
'SessionStart: HookSystem not available, skipping event',
);
}
})();
// Register SessionEnd cleanup for process exit
registerCleanup(async () => {
try {
await config
.getHookSystem()
?.fireSessionEndEvent(SessionEndReason.PromptInputExit);
debugLogger.debug('SessionEnd event completed successfully!!!');
} catch (err) {
debugLogger.error(`SessionEnd hook failed: ${err}`);
}
});
registerCleanup(async () => {
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
@ -1071,6 +1108,7 @@ export const AppContainer = (props: AppContainerProps) => {
streamingState,
elapsedTime,
settings,
config,
});
// Dialog close functionality

View file

@ -8,6 +8,10 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
import { clearCommand } from './clearCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import {
SessionEndReason,
SessionStartSource,
} from '@qwen-code/qwen-code-core';
// Mock the telemetry service
vi.mock('@qwen-code/qwen-code-core', async () => {
@ -26,10 +30,19 @@ describe('clearCommand', () => {
let mockContext: CommandContext;
let mockResetChat: ReturnType<typeof vi.fn>;
let mockStartNewSession: ReturnType<typeof vi.fn>;
let mockFireSessionEndEvent: ReturnType<typeof vi.fn>;
let mockFireSessionStartEvent: ReturnType<typeof vi.fn>;
let mockGetHookSystem: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockResetChat = vi.fn().mockResolvedValue(undefined);
mockStartNewSession = vi.fn().mockReturnValue('new-session-id');
mockFireSessionEndEvent = vi.fn().mockResolvedValue(undefined);
mockFireSessionStartEvent = vi.fn().mockResolvedValue(undefined);
mockGetHookSystem = vi.fn().mockReturnValue({
fireSessionEndEvent: mockFireSessionEndEvent,
fireSessionStartEvent: mockFireSessionStartEvent,
});
vi.clearAllMocks();
mockContext = createMockCommandContext({
@ -40,6 +53,11 @@ describe('clearCommand', () => {
resetChat: mockResetChat,
}) as unknown as GeminiClient,
startNewSession: mockStartNewSession,
getHookSystem: mockGetHookSystem,
getDebugLogger: () => ({
warn: vi.fn(),
}),
getModel: () => 'test-model',
getToolRegistry: () => undefined,
},
},
@ -76,6 +94,50 @@ describe('clearCommand', () => {
expect(mockContext.ui.clear).toHaveBeenCalled();
});
it('should fire SessionEnd event before clearing and SessionStart event after clearing', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
await clearCommand.action(mockContext, '');
expect(mockGetHookSystem).toHaveBeenCalled();
expect(mockFireSessionEndEvent).toHaveBeenCalledWith(
SessionEndReason.Clear,
);
expect(mockFireSessionStartEvent).toHaveBeenCalledWith(
SessionStartSource.Clear,
'test-model',
);
// SessionEnd should be called before SessionStart
const sessionEndCallOrder =
mockFireSessionEndEvent.mock.invocationCallOrder[0];
const sessionStartCallOrder =
mockFireSessionStartEvent.mock.invocationCallOrder[0];
expect(sessionEndCallOrder).toBeLessThan(sessionStartCallOrder);
});
it('should handle hook errors gracefully and continue execution', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
mockFireSessionEndEvent.mockRejectedValue(
new Error('SessionEnd hook failed'),
);
mockFireSessionStartEvent.mockRejectedValue(
new Error('SessionStart hook failed'),
);
await clearCommand.action(mockContext, '');
// Should still complete the clear operation despite hook errors
expect(mockStartNewSession).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
});
it('should not attempt to reset chat if config service is not available', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');

View file

@ -9,6 +9,8 @@ import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import {
uiTelemetryService,
SessionEndReason,
SessionStartSource,
ToolNames,
SkillTool,
} from '@qwen-code/qwen-code-core';
@ -24,6 +26,15 @@ export const clearCommand: SlashCommand = {
const { config } = context.services;
if (config) {
// Fire SessionEnd event before clearing (current session ends)
try {
await config
.getHookSystem()
?.fireSessionEndEvent(SessionEndReason.Clear);
} catch (err) {
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
}
const newSessionId = config.startNewSession();
// Reset UI telemetry metrics for the new session
@ -53,6 +64,18 @@ export const clearCommand: SlashCommand = {
} else {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}
// Fire SessionStart event after clearing (new session starts)
try {
await config
.getHookSystem()
?.fireSessionStartEvent(
SessionStartSource.Clear,
config.getModel() ?? '',
);
} catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
}
} else {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}

View file

@ -11,6 +11,11 @@ import {
AttentionNotificationReason,
} from '../../utils/attentionNotification.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
fireNotificationHook,
NotificationType,
} from '@qwen-code/qwen-code-core';
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
@ -19,6 +24,7 @@ interface UseAttentionNotificationsOptions {
streamingState: StreamingState;
elapsedTime: number;
settings: LoadedSettings;
config?: Config;
}
export const useAttentionNotifications = ({
@ -26,10 +32,12 @@ export const useAttentionNotifications = ({
streamingState,
elapsedTime,
settings,
config,
}: UseAttentionNotificationsOptions) => {
const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true;
const awaitingNotificationSentRef = useRef(false);
const respondingElapsedRef = useRef(0);
const idleNotificationSentRef = useRef(false);
useEffect(() => {
if (
@ -51,6 +59,8 @@ export const useAttentionNotifications = ({
useEffect(() => {
if (streamingState === StreamingState.Responding) {
respondingElapsedRef.current = elapsedTime;
// Reset idle notification flag when responding
idleNotificationSentRef.current = false;
return;
}
@ -65,7 +75,28 @@ export const useAttentionNotifications = ({
}
// Reset tracking for next task
respondingElapsedRef.current = 0;
// Fire idle_prompt notification hook when entering idle state
if (config && !idleNotificationSentRef.current) {
const messageBus = config.getMessageBus();
const hooksEnabled = config.getEnableHooks();
if (hooksEnabled && messageBus) {
fireNotificationHook(
messageBus,
'Qwen Code is waiting for your input',
NotificationType.IdlePrompt,
'Waiting for input',
).catch(() => {
// Silently ignore errors - fireNotificationHook has internal error handling
// and notification hooks should not block the idle flow
});
}
idleNotificationSentRef.current = true;
}
return;
}
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled]);
// Reset idle notification flag when in WaitingForConfirmation state
idleNotificationSentRef.current = false;
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled, config]);
};

View file

@ -142,6 +142,11 @@ describe('useResumeCommand', () => {
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
getDebugLogger: () => ({
warn: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
} as unknown as import('@qwen-code/qwen-code-core').Config;
const { result } = renderHook(() =>

View file

@ -5,7 +5,11 @@
*/
import { useState, useCallback } from 'react';
import { SessionService, type Config } from '@qwen-code/qwen-code-core';
import {
SessionService,
type Config,
SessionStartSource,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@ -67,6 +71,18 @@ export function useResumeCommand(
config.startNewSession(sessionId, sessionData);
await config.getGeminiClient()?.initialize?.();
// Fire SessionStart event after resuming session
try {
await config
.getHookSystem()
?.fireSessionStartEvent(
SessionStartSource.Resume,
config.getModel() ?? '',
);
} catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
}
// Refresh terminal UI.
remount?.();
},

View file

@ -68,6 +68,15 @@ const mockConfig = {
getGeminiClient: () => null, // No client needed for these tests
getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }),
getChatRecordingService: () => undefined,
getMessageBus: vi.fn().mockReturnValue(undefined),
getEnableHooks: vi.fn().mockReturnValue(false),
getHookSystem: vi.fn().mockReturnValue(undefined),
getDebugLogger: vi.fn().mockReturnValue({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
} as unknown as Config;
const mockTool = new MockTool({

View file

@ -36,6 +36,8 @@ import { RipGrepTool } from '../tools/ripGrep.js';
import { logRipgrepFallback } from '../telemetry/loggers.js';
import { RipgrepFallbackEvent } from '../telemetry/types.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { fireNotificationHook } from '../core/toolHookTriggers.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
function createToolMock(toolName: string) {
const ToolMock = vi.fn();
@ -195,6 +197,10 @@ vi.mock('../ide/ide-client.js', () => ({
import { BaseLlmClient } from '../core/baseLlmClient.js';
vi.mock('../core/baseLlmClient.js');
// Mock fireNotificationHook from toolHookTriggers
vi.mock('../core/toolHookTriggers.js', () => ({
fireNotificationHook: vi.fn().mockResolvedValue({}),
}));
describe('Server Config (config.ts)', () => {
const MODEL = 'qwen3-coder-plus';
@ -337,6 +343,64 @@ describe('Server Config (config.ts)', () => {
expect(GeminiClient).toHaveBeenCalledWith(config);
});
it('should fire auth_success notification hook when hooks are enabled', async () => {
const mockMessageBus = { request: vi.fn() };
const config = new Config({
...baseParams,
enableHooks: true,
});
// Set messageBus using the setter
config.setMessageBus(mockMessageBus as unknown as MessageBus);
const authType = AuthType.USE_GEMINI;
const mockContentConfig = {
apiKey: 'test-key',
model: 'qwen3-coder-plus',
authType,
};
vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({
config: mockContentConfig as ContentGeneratorConfig,
sources: {},
});
await config.refreshAuth(authType);
// Verify that fireNotificationHook was called with correct parameters
expect(fireNotificationHook).toHaveBeenCalledWith(
mockMessageBus,
`Successfully authenticated with ${authType}`,
'auth_success',
'Authentication successful',
);
});
it('should not fire notification hook when hooks are disabled', async () => {
const config = new Config({
...baseParams,
enableHooks: false,
});
const authType = AuthType.USE_GEMINI;
const mockContentConfig = {
apiKey: 'test-key',
model: 'qwen3-coder-plus',
authType,
};
vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({
config: mockContentConfig as ContentGeneratorConfig,
sources: {},
});
// Clear any previous calls
vi.mocked(fireNotificationHook).mockClear();
await config.refreshAuth(authType);
// Verify that fireNotificationHook was not called
expect(fireNotificationHook).not.toHaveBeenCalled();
});
it('should not strip thoughts when switching from Vertex to GenAI', async () => {
const config = new Config(baseParams);

View file

@ -93,6 +93,12 @@ import {
type HookExecutionRequest,
type HookExecutionResponse,
} from '../confirmation-bus/types.js';
import {
PermissionMode,
NotificationType,
type PermissionSuggestion,
} from '../hooks/types.js';
import { fireNotificationHook } from '../core/toolHookTriggers.js';
// Utils
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
@ -785,6 +791,73 @@ export class Config {
(input['last_assistant_message'] as string) || '',
);
break;
case 'PreToolUse': {
result = await hookSystem.firePreToolUseEvent(
(input['tool_name'] as string) || '',
(input['tool_input'] as Record<string, unknown>) || {},
(input['tool_use_id'] as string) || '',
(input['permission_mode'] as PermissionMode | undefined) ??
PermissionMode.Default,
);
break;
}
case 'PostToolUse':
result = await hookSystem.firePostToolUseEvent(
(input['tool_name'] as string) || '',
(input['tool_input'] as Record<string, unknown>) || {},
(input['tool_response'] as Record<string, unknown>) || {},
(input['tool_use_id'] as string) || '',
(input['permission_mode'] as PermissionMode) || 'default',
);
break;
case 'PostToolUseFailure':
result = await hookSystem.firePostToolUseFailureEvent(
(input['tool_use_id'] as string) || '',
(input['tool_name'] as string) || '',
(input['tool_input'] as Record<string, unknown>) || {},
(input['error'] as string) || '',
input['is_interrupt'] as boolean | undefined,
(input['permission_mode'] as PermissionMode) || 'default',
);
break;
case 'Notification':
result = await hookSystem.fireNotificationEvent(
(input['message'] as string) || '',
(input['notification_type'] as NotificationType) ||
'permission_prompt',
(input['title'] as string) || undefined,
);
break;
case 'PermissionRequest':
result = await hookSystem.firePermissionRequestEvent(
(input['tool_name'] as string) || '',
(input['tool_input'] as Record<string, unknown>) || {},
(input['permission_mode'] as PermissionMode) ||
PermissionMode.Default,
(input['permission_suggestions'] as
| PermissionSuggestion[]
| undefined) || undefined,
);
break;
case 'SubagentStart':
result = await hookSystem.fireSubagentStartEvent(
(input['agent_id'] as string) || '',
(input['agent_type'] as string) || '',
(input['permission_mode'] as PermissionMode) ||
PermissionMode.Default,
);
break;
case 'SubagentStop':
result = await hookSystem.fireSubagentStopEvent(
(input['agent_id'] as string) || '',
(input['agent_type'] as string) || '',
(input['agent_transcript_path'] as string) || '',
(input['last_assistant_message'] as string) || '',
(input['stop_hook_active'] as boolean) || false,
(input['permission_mode'] as PermissionMode) ||
PermissionMode.Default,
);
break;
default:
this.debugLogger.warn(
`Unknown hook event: ${request.eventName}`,
@ -812,6 +885,8 @@ export class Config {
);
this.debugLogger.debug('MessageBus initialized with hook subscription');
} else {
this.debugLogger.debug('Hook system disabled, skipping initialization');
}
this.subagentManager = new SubagentManager(this);
@ -935,6 +1010,21 @@ export class Config {
// Initialize BaseLlmClient now that the ContentGenerator is available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
// Fire auth_success notification hook (supports both interactive & non-interactive)
const messageBus = this.getMessageBus();
const hooksEnabled = this.getEnableHooks();
if (hooksEnabled && messageBus) {
fireNotificationHook(
messageBus,
`Successfully authenticated with ${authMethod}`,
NotificationType.AuthSuccess,
'Authentication successful',
).catch(() => {
// Silently ignore errors - fireNotificationHook has internal error handling
// and notification hooks should not block the auth flow
});
}
}
/**

View file

@ -362,6 +362,13 @@ describe('Gemini Client (client.ts)', () => {
getEnableHooks: vi.fn().mockReturnValue(false),
getArenaManager: vi.fn().mockReturnValue(null),
getMessageBus: vi.fn().mockReturnValue(undefined),
getHookSystem: vi.fn().mockReturnValue(undefined),
getDebugLogger: vi.fn().mockReturnValue({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
} as unknown as Config;
client = new GeminiClient(mockConfig);

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,17 @@ import type {
ChatRecordingService,
} from '../index.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import {
generateToolUseId,
firePreToolUseHook,
firePostToolUseHook,
firePostToolUseFailureHook,
fireNotificationHook,
firePermissionRequestHook,
appendAdditionalContext,
} from './toolHookTriggers.js';
import { NotificationType } from '../hooks/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
const debugLogger = createDebugLogger('TOOL_SCHEDULER');
import {
@ -901,6 +912,71 @@ export class CoreToolScheduler {
});
}
// Fire PermissionRequest hook before showing the permission dialog.
const messageBus = this.config.getMessageBus() as
| MessageBus
| undefined;
const hooksEnabled = this.config.getEnableHooks();
if (hooksEnabled && messageBus) {
const permissionMode = String(this.config.getApprovalMode());
const hookResult = await firePermissionRequestHook(
messageBus,
reqInfo.name,
(reqInfo.args as Record<string, unknown>) || {},
permissionMode,
);
if (hookResult.hasDecision) {
if (hookResult.shouldAllow) {
// Hook granted permission - apply updated input if provided and proceed
if (
hookResult.updatedInput &&
typeof reqInfo.args === 'object'
) {
this.setArgsInternal(
reqInfo.callId,
hookResult.updatedInput,
);
}
await confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
this.setToolCallOutcome(
reqInfo.callId,
ToolConfirmationOutcome.ProceedOnce,
);
this.setStatusInternal(reqInfo.callId, 'scheduled');
} else {
// Hook denied permission - cancel with optional message
const cancelPayload = hookResult.denyMessage
? { cancelMessage: hookResult.denyMessage }
: undefined;
await confirmationDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
cancelPayload,
);
this.setToolCallOutcome(
reqInfo.callId,
ToolConfirmationOutcome.Cancel,
);
this.setStatusInternal(
reqInfo.callId,
'error',
createErrorResponse(
reqInfo,
new Error(
hookResult.denyMessage ||
`Permission denied by hook for "${reqInfo.name}"`,
),
ToolErrorType.EXECUTION_DENIED,
),
);
}
continue;
}
}
const originalOnConfirm = confirmationDetails.onConfirm;
const wrappedConfirmationDetails: ToolCallConfirmationDetails = {
...confirmationDetails,
@ -921,6 +997,20 @@ export class CoreToolScheduler {
'awaiting_approval',
wrappedConfirmationDetails,
);
// Fire permission_prompt notification hook
if (hooksEnabled && messageBus) {
fireNotificationHook(
messageBus,
`Qwen Code needs your permission to use ${reqInfo.name}`,
NotificationType.PermissionPrompt,
'Permission needed',
).catch((error) => {
debugLogger.warn(
`Permission prompt notification hook failed: ${error instanceof Error ? error.message : String(error)}`,
);
});
}
}
} catch (error) {
if (signal.aborted) {
@ -1115,6 +1205,41 @@ export class CoreToolScheduler {
const scheduledCall = toolCall;
const { callId, name: toolName } = scheduledCall.request;
const invocation = scheduledCall.invocation;
const toolInput = scheduledCall.request.args as Record<string, unknown>;
// Generate unique tool_use_id for hook tracking
const toolUseId = generateToolUseId();
// Get MessageBus for hook execution
const messageBus = this.config.getMessageBus() as MessageBus | undefined;
const hooksEnabled = this.config.getEnableHooks();
// PreToolUse Hook
if (hooksEnabled && messageBus) {
// Convert ApprovalMode to permission_mode string for hooks
const permissionMode = this.config.getApprovalMode();
const preHookResult = await firePreToolUseHook(
messageBus,
toolName,
toolInput,
toolUseId,
permissionMode,
);
if (!preHookResult.shouldProceed) {
// Hook blocked the execution
const blockMessage =
preHookResult.blockReason || 'Tool execution blocked by hook';
const errorResponse = createErrorResponse(
scheduledCall.request,
new Error(blockMessage),
ToolErrorType.EXECUTION_DENIED,
);
this.setStatusInternal(callId, 'error', errorResponse);
return;
}
}
this.setStatusInternal(callId, 'executing');
const liveOutputCallback = scheduledCall.tool.canUpdateOutput
@ -1164,19 +1289,77 @@ export class CoreToolScheduler {
try {
const toolResult: ToolResult = await promise;
if (signal.aborted) {
this.setStatusInternal(
callId,
'cancelled',
'User cancelled tool execution.',
);
return;
// PostToolUseFailure Hook
if (hooksEnabled && messageBus) {
const failureHookResult = await firePostToolUseFailureHook(
messageBus,
toolUseId,
toolName,
toolInput,
'User cancelled tool execution.',
true,
this.config.getApprovalMode(),
);
// Append additional context from hook if provided
let cancelMessage = 'User cancelled tool execution.';
if (failureHookResult.additionalContext) {
cancelMessage += `\n\n${failureHookResult.additionalContext}`;
}
this.setStatusInternal(callId, 'cancelled', cancelMessage);
} else {
this.setStatusInternal(
callId,
'cancelled',
'User cancelled tool execution.',
);
}
return; // Both code paths should return here
}
if (toolResult.error === undefined) {
const content = toolResult.llmContent;
let content = toolResult.llmContent;
const contentLength =
typeof content === 'string' ? content.length : undefined;
// PostToolUse Hook
if (hooksEnabled && messageBus) {
const toolResponse = {
llmContent: content,
returnDisplay: toolResult.returnDisplay,
};
const permissionMode = this.config.getApprovalMode();
const postHookResult = await firePostToolUseHook(
messageBus,
toolName,
toolInput,
toolResponse,
toolUseId,
permissionMode,
);
// Append additional context from hook if provided
if (postHookResult.additionalContext) {
content = appendAdditionalContext(
content,
postHookResult.additionalContext,
);
}
// Check if hook requested to stop execution
if (postHookResult.shouldStop) {
const stopMessage =
postHookResult.stopReason || 'Execution stopped by hook';
const errorResponse = createErrorResponse(
scheduledCall.request,
new Error(stopMessage),
ToolErrorType.EXECUTION_DENIED,
);
this.setStatusInternal(callId, 'error', errorResponse);
return;
}
}
const response = convertToFunctionResponse(toolName, callId, content);
const successResponse: ToolCallResponseInfo = {
callId,
@ -1189,7 +1372,26 @@ export class CoreToolScheduler {
this.setStatusInternal(callId, 'success', successResponse);
} else {
// It is a failure
const error = new Error(toolResult.error.message);
// PostToolUseFailure Hook
let errorMessage = toolResult.error.message;
if (hooksEnabled && messageBus) {
const failureHookResult = await firePostToolUseFailureHook(
messageBus,
toolUseId,
toolName,
toolInput,
toolResult.error.message,
false,
this.config.getApprovalMode(),
);
// Append additional context from hook if provided
if (failureHookResult.additionalContext) {
errorMessage += `\n\n${failureHookResult.additionalContext}`;
}
}
const error = new Error(errorMessage);
const errorResponse = createErrorResponse(
scheduledCall.request,
error,
@ -1198,20 +1400,64 @@ export class CoreToolScheduler {
this.setStatusInternal(callId, 'error', errorResponse);
}
} catch (executionError: unknown) {
const errorMessage =
executionError instanceof Error
? executionError.message
: String(executionError);
if (signal.aborted) {
this.setStatusInternal(
callId,
'cancelled',
'User cancelled tool execution.',
);
// PostToolUseFailure Hook (user interrupt)
if (hooksEnabled && messageBus) {
const failureHookResult = await firePostToolUseFailureHook(
messageBus,
toolUseId,
toolName,
toolInput,
'User cancelled tool execution.',
true,
this.config.getApprovalMode(),
);
// Append additional context from hook if provided
let cancelMessage = 'User cancelled tool execution.';
if (failureHookResult.additionalContext) {
cancelMessage += `\n\n${failureHookResult.additionalContext}`;
}
this.setStatusInternal(callId, 'cancelled', cancelMessage);
} else {
this.setStatusInternal(
callId,
'cancelled',
'User cancelled tool execution.',
);
}
return;
} else {
// PostToolUseFailure Hook
let exceptionErrorMessage = errorMessage;
if (hooksEnabled && messageBus) {
const failureHookResult = await firePostToolUseFailureHook(
messageBus,
toolUseId,
toolName,
toolInput,
errorMessage,
false,
this.config.getApprovalMode(),
);
// Append additional context from hook if provided
if (failureHookResult.additionalContext) {
exceptionErrorMessage += `\n\n${failureHookResult.additionalContext}`;
}
}
this.setStatusInternal(
callId,
'error',
createErrorResponse(
scheduledCall.request,
executionError instanceof Error
? executionError
? new Error(exceptionErrorMessage)
: new Error(String(executionError)),
ToolErrorType.UNHANDLED_EXCEPTION,
),

View file

@ -34,11 +34,8 @@ import {
ContentRetryEvent,
ContentRetryFailureEvent,
} from '../telemetry/types.js';
import type {
UiTelemetryService} from '../telemetry/uiTelemetry.js';
import {
uiTelemetryService,
} from '../telemetry/uiTelemetry.js';
import type { UiTelemetryService } from '../telemetry/uiTelemetry.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
const debugLogger = createDebugLogger('QWEN_CODE_CHAT');

View file

@ -62,6 +62,16 @@ describe('executeToolCall', () => {
getUseModelRouter: () => false,
getGeminiClient: () => null, // No client needed for these tests
getChatRecordingService: () => undefined,
getMessageBus: vi.fn().mockReturnValue(undefined),
getEnableHooks: vi.fn().mockReturnValue(false),
getHookSystem: vi.fn().mockReturnValue(undefined),
getDebugLogger: vi.fn().mockReturnValue({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
isInteractive: vi.fn().mockReturnValue(false),
} as unknown as Config;
abortController = new AbortController();

View file

@ -0,0 +1,980 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import {
generateToolUseId,
firePreToolUseHook,
firePostToolUseHook,
firePostToolUseFailureHook,
fireNotificationHook,
appendAdditionalContext,
firePermissionRequestHook,
} from './toolHookTriggers.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { NotificationType } from '../hooks/types.js';
import { MessageBusType } from '../confirmation-bus/types.js';
// Mock the MessageBus
const createMockMessageBus = () =>
({
request: vi.fn(),
}) as unknown as MessageBus;
describe('toolHookTriggers', () => {
describe('generateToolUseId', () => {
it('should generate unique IDs with the correct prefix', () => {
const id1 = generateToolUseId();
const id2 = generateToolUseId();
expect(id1).toMatch(/^toolu_\d+_[a-z0-9]+$/);
expect(id2).toMatch(/^toolu_\d+_[a-z0-9]+$/);
expect(id1).not.toBe(id2);
});
it('should generate IDs with current timestamp', () => {
const mockTime = Date.now();
vi.spyOn(global.Date, 'now').mockImplementation(() => mockTime);
const id = generateToolUseId();
expect(id).toContain(`toolu_${mockTime}`);
});
});
describe('firePreToolUseHook', () => {
it('should return shouldProceed: true when no messageBus is provided', async () => {
const result = await firePreToolUseHook(
undefined,
'test-tool',
{},
'test-id',
'auto',
);
expect(result).toEqual({ shouldProceed: true });
});
it('should return shouldProceed: true when hook execution fails', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: false,
});
const result = await firePreToolUseHook(
mockMessageBus,
'test-tool',
{},
'test-id',
'auto',
);
expect(result).toEqual({ shouldProceed: true });
});
it('should return shouldProceed: true when hook output is empty', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {},
});
const result = await firePreToolUseHook(
mockMessageBus,
'test-tool',
{},
'test-id',
'auto',
);
expect(result).toEqual({ shouldProceed: true });
});
it('should return shouldProceed: false with denied type when tool is denied', async () => {
const mockOutput = {
hookSpecificOutput: {
permissionDecision: 'deny',
permissionDecisionReason: 'Tool not allowed',
},
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await firePreToolUseHook(
mockMessageBus,
'test-tool',
{},
'test-id',
'auto',
);
expect(result).toEqual({
shouldProceed: false,
blockReason: 'Tool not allowed',
blockType: 'denied',
});
});
it('should return shouldProceed: false with ask type when confirmation is required', async () => {
const mockOutput = {
hookSpecificOutput: {
permissionDecision: 'ask',
permissionDecisionReason: 'User confirmation required',
},
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await firePreToolUseHook(
mockMessageBus,
'test-tool',
{},
'test-id',
'auto',
);
expect(result).toEqual({
shouldProceed: false,
blockReason: 'User confirmation required',
blockType: 'ask',
});
});
it('should return shouldProceed: false with stop type when execution should stop', async () => {
const mockOutput = {
continue: false,
reason: 'Execution stopped by policy',
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await firePreToolUseHook(
mockMessageBus,
'test-tool',
{},
'test-id',
'auto',
);
expect(result).toEqual({
shouldProceed: false,
blockReason: 'Execution stopped by policy',
blockType: 'stop',
});
});
it('should return shouldProceed: true with additional context when available', async () => {
const mockOutput = {
hookSpecificOutput: {
additionalContext: 'Additional context here',
},
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await firePreToolUseHook(
mockMessageBus,
'test-tool',
{},
'test-id',
'auto',
);
expect(result).toEqual({
shouldProceed: true,
additionalContext: 'Additional context here',
});
});
it('should handle hook execution errors gracefully', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Network error'),
);
const result = await firePreToolUseHook(
mockMessageBus,
'test-tool',
{},
'test-id',
'auto',
);
expect(result).toEqual({ shouldProceed: true });
});
});
describe('firePostToolUseHook', () => {
it('should return shouldStop: false when no messageBus is provided', async () => {
const result = await firePostToolUseHook(
undefined,
'test-tool',
{},
{},
'test-id',
'auto',
);
expect(result).toEqual({ shouldStop: false });
});
it('should return shouldStop: false when hook execution fails', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: false,
});
const result = await firePostToolUseHook(
mockMessageBus,
'test-tool',
{},
{},
'test-id',
'auto',
);
expect(result).toEqual({ shouldStop: false });
});
it('should return shouldStop: false when hook output is empty', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {},
});
const result = await firePostToolUseHook(
mockMessageBus,
'test-tool',
{},
{},
'test-id',
'auto',
);
expect(result).toEqual({ shouldStop: false });
});
it('should return shouldStop: true with stop reason when execution should stop', async () => {
const mockOutput = {
continue: false,
reason: 'Execution stopped by policy',
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await firePostToolUseHook(
mockMessageBus,
'test-tool',
{},
{},
'test-id',
'auto',
);
expect(result).toEqual({
shouldStop: true,
stopReason: 'Execution stopped by policy',
});
});
it('should return shouldStop: false with additional context when available', async () => {
const mockOutput = {
hookSpecificOutput: {
additionalContext: 'Additional context here',
},
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await firePostToolUseHook(
mockMessageBus,
'test-tool',
{},
{},
'test-id',
'auto',
);
expect(result).toEqual({
shouldStop: false,
additionalContext: 'Additional context here',
});
});
it('should handle hook execution errors gracefully', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Network error'),
);
const result = await firePostToolUseHook(
mockMessageBus,
'test-tool',
{},
{},
'test-id',
'auto',
);
expect(result).toEqual({ shouldStop: false });
});
});
describe('firePostToolUseFailureHook', () => {
it('should return empty object when no messageBus is provided', async () => {
const result = await firePostToolUseFailureHook(
undefined,
'test-id',
'test-tool',
{},
'error message',
);
expect(result).toEqual({});
});
it('should return empty object when hook execution fails', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: false,
});
const result = await firePostToolUseFailureHook(
mockMessageBus,
'test-id',
'test-tool',
{},
'error message',
);
expect(result).toEqual({});
});
it('should return empty object when hook output is empty', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {},
});
const result = await firePostToolUseFailureHook(
mockMessageBus,
'test-id',
'test-tool',
{},
'error message',
);
expect(result).toEqual({});
});
it('should return additional context when available', async () => {
const mockOutput = {
hookSpecificOutput: {
additionalContext: 'Additional context about the failure',
},
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await firePostToolUseFailureHook(
mockMessageBus,
'test-id',
'test-tool',
{},
'error message',
);
expect(result).toEqual({
additionalContext: 'Additional context about the failure',
});
});
it('should handle hook execution errors gracefully', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Network error'),
);
const result = await firePostToolUseFailureHook(
mockMessageBus,
'test-id',
'test-tool',
{},
'error message',
);
expect(result).toEqual({});
});
});
describe('appendAdditionalContext', () => {
it('should return original content when no additional context is provided', () => {
const result = appendAdditionalContext('original content', undefined);
expect(result).toBe('original content');
});
it('should append context to string content', () => {
const result = appendAdditionalContext(
'original content',
'additional context',
);
expect(result).toBe('original content\n\nadditional context');
});
it('should append context as text part to PartListUnion array', () => {
const originalContent = [{ text: 'original' }];
const result = appendAdditionalContext(
originalContent,
'additional context',
);
expect(result).toEqual([
{ text: 'original' },
{ text: 'additional context' },
]);
});
it('should handle non-array PartListUnion content', () => {
const originalContent = { text: 'original' };
const result = appendAdditionalContext(
originalContent,
'additional context',
);
expect(result).toEqual({ text: 'original' });
});
it('should return original array content when no additional context is provided', () => {
const originalContent = [{ text: 'original' }];
const result = appendAdditionalContext(originalContent, undefined);
expect(result).toEqual([{ text: 'original' }]);
});
});
describe('fireNotificationHook', () => {
it('should return empty object when no messageBus is provided', async () => {
const result = await fireNotificationHook(
undefined,
'Test notification',
NotificationType.PermissionPrompt,
'Test Title',
);
expect(result).toEqual({});
});
it('should return empty object when hook execution fails', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: false,
});
const result = await fireNotificationHook(
mockMessageBus,
'Test notification',
NotificationType.PermissionPrompt,
);
expect(result).toEqual({});
});
it('should return empty object when hook output is empty', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {},
});
const result = await fireNotificationHook(
mockMessageBus,
'Test notification',
NotificationType.IdlePrompt,
);
expect(result).toEqual({});
});
it('should return additional context when available', async () => {
const mockOutput = {
hookSpecificOutput: {
additionalContext: 'Additional context from notification hook',
},
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await fireNotificationHook(
mockMessageBus,
'Test notification',
NotificationType.AuthSuccess,
);
expect(result).toEqual({
additionalContext: 'Additional context from notification hook',
});
});
it('should send correct parameters to MessageBus for permission_prompt', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {},
});
await fireNotificationHook(
mockMessageBus,
'Qwen Code needs your permission to use Bash',
NotificationType.PermissionPrompt,
'Permission needed',
);
expect(mockMessageBus.request).toHaveBeenCalledWith(
{
type: MessageBusType.HOOK_EXECUTION_REQUEST,
eventName: 'Notification',
input: {
message: 'Qwen Code needs your permission to use Bash',
notification_type: 'permission_prompt',
title: 'Permission needed',
},
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
});
it('should send correct parameters to MessageBus for idle_prompt', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {},
});
await fireNotificationHook(
mockMessageBus,
'Qwen Code is waiting for your input',
NotificationType.IdlePrompt,
'Waiting for input',
);
expect(mockMessageBus.request).toHaveBeenCalledWith(
{
type: MessageBusType.HOOK_EXECUTION_REQUEST,
eventName: 'Notification',
input: {
message: 'Qwen Code is waiting for your input',
notification_type: 'idle_prompt',
title: 'Waiting for input',
},
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
});
it('should send correct parameters to MessageBus for auth_success', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {},
});
await fireNotificationHook(
mockMessageBus,
'Authentication successful',
NotificationType.AuthSuccess,
);
expect(mockMessageBus.request).toHaveBeenCalledWith(
{
type: MessageBusType.HOOK_EXECUTION_REQUEST,
eventName: 'Notification',
input: {
message: 'Authentication successful',
notification_type: 'auth_success',
title: undefined,
},
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
});
it('should send correct parameters to MessageBus for elicitation_dialog', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {},
});
await fireNotificationHook(
mockMessageBus,
'Dialog shown to user',
NotificationType.ElicitationDialog,
'Dialog',
);
expect(mockMessageBus.request).toHaveBeenCalledWith(
{
type: MessageBusType.HOOK_EXECUTION_REQUEST,
eventName: 'Notification',
input: {
message: 'Dialog shown to user',
notification_type: 'elicitation_dialog',
title: 'Dialog',
},
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
});
it('should handle hook execution errors gracefully', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Network error'),
);
const result = await fireNotificationHook(
mockMessageBus,
'Test notification',
NotificationType.PermissionPrompt,
);
expect(result).toEqual({});
});
it('should handle notification without title', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {},
});
await fireNotificationHook(
mockMessageBus,
'Test notification without title',
NotificationType.IdlePrompt,
);
expect(mockMessageBus.request).toHaveBeenCalledWith(
{
type: MessageBusType.HOOK_EXECUTION_REQUEST,
eventName: 'Notification',
input: {
message: 'Test notification without title',
notification_type: 'idle_prompt',
title: undefined,
},
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
});
});
describe('firePermissionRequestHook', () => {
it('should return hasDecision: false when no messageBus is provided', async () => {
const result = await firePermissionRequestHook(
undefined,
'test-tool',
{},
'auto',
);
expect(result).toEqual({ hasDecision: false });
});
it('should return hasDecision: false when hook execution fails', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: false,
});
const result = await firePermissionRequestHook(
mockMessageBus,
'test-tool',
{},
'auto',
);
expect(result).toEqual({ hasDecision: false });
});
it('should return hasDecision: false when hook output is empty', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {},
});
const result = await firePermissionRequestHook(
mockMessageBus,
'test-tool',
{},
'auto',
);
expect(result).toEqual({ hasDecision: false });
});
it('should return hasDecision: true with allow decision when tool is allowed', async () => {
const mockOutput = {
hookSpecificOutput: {
decision: {
behavior: 'allow',
updatedInput: { command: 'ls -la' },
message: 'Tool allowed by policy',
},
},
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await firePermissionRequestHook(
mockMessageBus,
'run_shell_command',
{ command: 'ls' },
'auto',
);
expect(result).toEqual({
hasDecision: true,
shouldAllow: true,
updatedInput: { command: 'ls -la' },
denyMessage: undefined,
shouldInterrupt: undefined,
});
});
it('should return hasDecision: true with deny decision when tool is denied', async () => {
const mockOutput = {
hookSpecificOutput: {
decision: {
behavior: 'deny',
message: 'Tool denied by policy',
interrupt: true,
},
},
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await firePermissionRequestHook(
mockMessageBus,
'run_shell_command',
{ command: 'rm -rf /' },
'auto',
);
expect(result).toEqual({
hasDecision: true,
shouldAllow: false,
denyMessage: 'Tool denied by policy',
shouldInterrupt: true,
});
});
it('should send correct parameters to MessageBus', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {},
});
await firePermissionRequestHook(
mockMessageBus,
'run_shell_command',
{ command: 'ls' },
'auto',
[
{
type: 'always_allow',
tool: 'run_shell_command',
},
],
);
expect(mockMessageBus.request).toHaveBeenCalledWith(
{
type: MessageBusType.HOOK_EXECUTION_REQUEST,
eventName: 'PermissionRequest',
input: {
tool_name: 'run_shell_command',
tool_input: { command: 'ls' },
permission_mode: 'auto',
permission_suggestions: [
{
type: 'always_allow',
tool: 'run_shell_command',
},
],
},
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
});
it('should handle missing updated_input in allow decision', async () => {
const mockOutput = {
hookSpecificOutput: {
decision: {
behavior: 'allow',
message: 'Tool allowed',
},
},
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await firePermissionRequestHook(
mockMessageBus,
'test-tool',
{},
'auto',
);
expect(result).toEqual({
hasDecision: true,
shouldAllow: true,
denyMessage: undefined,
shouldInterrupt: undefined,
});
});
it('should handle missing message in decision', async () => {
const mockOutput = {
hookSpecificOutput: {
decision: {
behavior: 'deny',
},
},
};
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: mockOutput,
});
const result = await firePermissionRequestHook(
mockMessageBus,
'test-tool',
{},
'auto',
);
expect(result).toEqual({
hasDecision: true,
shouldAllow: false,
denyMessage: undefined,
shouldInterrupt: undefined,
});
});
it('should handle hook execution errors gracefully', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Network error'),
);
const result = await firePermissionRequestHook(
mockMessageBus,
'test-tool',
{},
'auto',
);
expect(result).toEqual({ hasDecision: false });
});
it('should handle permission_suggestions being undefined', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: {},
});
await firePermissionRequestHook(
mockMessageBus,
'run_shell_command',
{ command: 'ls' },
'auto',
undefined,
);
expect(mockMessageBus.request).toHaveBeenCalledWith(
{
type: MessageBusType.HOOK_EXECUTION_REQUEST,
eventName: 'PermissionRequest',
input: {
tool_name: 'run_shell_command',
tool_input: { command: 'ls' },
permission_mode: 'auto',
permission_suggestions: undefined,
},
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
});
it('should handle different permission modes', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: true,
output: { hookSpecificOutput: { decision: { behavior: 'allow' } } },
});
const result1 = await firePermissionRequestHook(
mockMessageBus,
'test-tool',
{},
'plan',
);
expect(result1.hasDecision).toBe(true);
const result2 = await firePermissionRequestHook(
mockMessageBus,
'test-tool',
{},
'yolo',
);
expect(result2.hasDecision).toBe(true);
});
});
});

View file

@ -0,0 +1,478 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import type {
HookExecutionRequest,
HookExecutionResponse,
} from '../confirmation-bus/types.js';
import {
createHookOutput,
type PreToolUseHookOutput,
type PostToolUseHookOutput,
type PostToolUseFailureHookOutput,
type NotificationType,
type PermissionRequestHookOutput,
type PermissionSuggestion,
} from '../hooks/types.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import type { Part, PartListUnion } from '@google/genai';
const debugLogger = createDebugLogger('TOOL_HOOKS');
/**
* Generate a unique tool_use_id for tracking tool executions
*/
export function generateToolUseId(): string {
return `toolu_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
/**
* Result of PreToolUse hook execution
*/
export interface PreToolUseHookResult {
/** Whether the tool execution should proceed */
shouldProceed: boolean;
/** If blocked, the reason for blocking */
blockReason?: string;
/** If blocked, the error type */
blockType?: 'denied' | 'ask' | 'stop';
/** Additional context to add */
additionalContext?: string;
}
/**
* Result of PostToolUse hook execution
*/
export interface PostToolUseHookResult {
/** Whether execution should stop */
shouldStop: boolean;
/** Stop reason if applicable */
stopReason?: string;
/** Additional context to append to tool response */
additionalContext?: string;
}
/**
* Result of PostToolUseFailure hook execution
*/
export interface PostToolUseFailureHookResult {
/** Additional context about the failure */
additionalContext?: string;
}
/**
* Fire PreToolUse hook via MessageBus and process the result
*
* @param messageBus - The message bus instance
* @param toolName - Name of the tool being executed
* @param toolInput - Input parameters for the tool
* @param toolUseId - Unique identifier for this tool use
* @param permissionMode - Current permission mode
* @returns PreToolUseHookResult indicating whether to proceed and any modifications
*/
export async function firePreToolUseHook(
messageBus: MessageBus | undefined,
toolName: string,
toolInput: Record<string, unknown>,
toolUseId: string,
permissionMode: string,
): Promise<PreToolUseHookResult> {
if (!messageBus) {
return { shouldProceed: true };
}
try {
const response = await messageBus.request<
HookExecutionRequest,
HookExecutionResponse
>(
{
type: MessageBusType.HOOK_EXECUTION_REQUEST,
eventName: 'PreToolUse',
input: {
permission_mode: permissionMode,
tool_name: toolName,
tool_input: toolInput,
tool_use_id: toolUseId,
},
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
if (!response.success || !response.output) {
return { shouldProceed: true };
}
const preToolOutput = createHookOutput(
'PreToolUse',
response.output,
) as PreToolUseHookOutput;
// Check if execution was denied
if (preToolOutput.isDenied()) {
return {
shouldProceed: false,
blockReason:
preToolOutput.getPermissionDecisionReason() ||
preToolOutput.getEffectiveReason(),
blockType: 'denied',
};
}
// Check if user confirmation is required
if (preToolOutput.isAsk()) {
return {
shouldProceed: false,
blockReason:
preToolOutput.getPermissionDecisionReason() ||
'User confirmation required',
blockType: 'ask',
};
}
// Check if execution should stop
if (preToolOutput.shouldStopExecution()) {
return {
shouldProceed: false,
blockReason: preToolOutput.getEffectiveReason(),
blockType: 'stop',
};
}
// Get additional context
const additionalContext = preToolOutput.getAdditionalContext();
return {
shouldProceed: true,
additionalContext,
};
} catch (error) {
// Hook errors should not block tool execution
debugLogger.warn(
`PreToolUse hook error for ${toolName}: ${error instanceof Error ? error.message : String(error)}`,
);
return { shouldProceed: true };
}
}
/**
* Fire PostToolUse hook via MessageBus and process the result
*
* @param messageBus - The message bus instance
* @param toolName - Name of the tool that was executed
* @param toolInput - Input parameters that were used
* @param toolResponse - Response from the tool execution
* @param toolUseId - Unique identifier for this tool use
* @param permissionMode - Current permission mode
* @returns PostToolUseHookResult with any additional context
*/
export async function firePostToolUseHook(
messageBus: MessageBus | undefined,
toolName: string,
toolInput: Record<string, unknown>,
toolResponse: Record<string, unknown>,
toolUseId: string,
permissionMode: string,
): Promise<PostToolUseHookResult> {
if (!messageBus) {
return { shouldStop: false };
}
try {
const response = await messageBus.request<
HookExecutionRequest,
HookExecutionResponse
>(
{
type: MessageBusType.HOOK_EXECUTION_REQUEST,
eventName: 'PostToolUse',
input: {
permission_mode: permissionMode,
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
tool_use_id: toolUseId,
},
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
if (!response.success || !response.output) {
return { shouldStop: false };
}
const postToolOutput = createHookOutput(
'PostToolUse',
response.output,
) as PostToolUseHookOutput;
// Check if execution should stop
if (postToolOutput.shouldStopExecution()) {
return {
shouldStop: true,
stopReason: postToolOutput.getEffectiveReason(),
};
}
// Get additional context
const additionalContext = postToolOutput.getAdditionalContext();
return {
shouldStop: false,
additionalContext,
};
} catch (error) {
// Hook errors should not affect tool result
debugLogger.warn(
`PostToolUse hook error for ${toolName}: ${error instanceof Error ? error.message : String(error)}`,
);
return { shouldStop: false };
}
}
/**
* Fire PostToolUseFailure hook via MessageBus and process the result
*
* @param messageBus - The message bus instance
* @param toolUseId - Unique identifier for this tool use
* @param toolName - Name of the tool that failed
* @param toolInput - Input parameters that were used
* @param errorMessage - Error message describing the failure
* @param errorType - Optional error type classification
* @param isInterrupt - Whether the failure was caused by user interruption
* @returns PostToolUseFailureHookResult with any additional context
*/
export async function firePostToolUseFailureHook(
messageBus: MessageBus | undefined,
toolUseId: string,
toolName: string,
toolInput: Record<string, unknown>,
errorMessage: string,
isInterrupt?: boolean,
permissionMode?: string,
): Promise<PostToolUseFailureHookResult> {
if (!messageBus) {
return {};
}
try {
const response = await messageBus.request<
HookExecutionRequest,
HookExecutionResponse
>(
{
type: MessageBusType.HOOK_EXECUTION_REQUEST,
eventName: 'PostToolUseFailure',
input: {
permission_mode: permissionMode,
tool_use_id: toolUseId,
tool_name: toolName,
tool_input: toolInput,
error: errorMessage,
is_interrupt: isInterrupt,
},
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
if (!response.success || !response.output) {
return {};
}
const failureOutput = createHookOutput(
'PostToolUseFailure',
response.output,
) as PostToolUseFailureHookOutput;
const additionalContext = failureOutput.getAdditionalContext();
return {
additionalContext,
};
} catch (error) {
// Hook errors should not affect error handling
debugLogger.warn(
`PostToolUseFailure hook error for ${toolName}: ${error instanceof Error ? error.message : String(error)}`,
);
return {};
}
}
/**
* Result of Notification hook execution
*/
export interface NotificationHookResult {
/** Additional context from the hook */
additionalContext?: string;
}
/**
* Fire Notification hook via MessageBus
* Called when Qwen Code sends a notification
*/
export async function fireNotificationHook(
messageBus: MessageBus | undefined,
message: string,
notificationType: NotificationType,
title?: string,
): Promise<NotificationHookResult> {
if (!messageBus) {
return {};
}
try {
const response = await messageBus.request<
HookExecutionRequest,
HookExecutionResponse
>(
{
type: MessageBusType.HOOK_EXECUTION_REQUEST,
eventName: 'Notification',
input: {
message,
notification_type: notificationType,
title,
},
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
if (!response.success || !response.output) {
return {};
}
const notificationOutput = createHookOutput(
'Notification',
response.output,
);
const additionalContext = notificationOutput.getAdditionalContext();
return {
additionalContext,
};
} catch (error) {
// Notification hook errors should not affect the notification flow
debugLogger.warn(
`Notification hook error: ${error instanceof Error ? error.message : String(error)}`,
);
return {};
}
}
/**
* Result of PermissionRequest hook execution
*/
export interface PermissionRequestHookResult {
/** Whether the hook made a permission decision */
hasDecision: boolean;
/** If true, the tool execution should proceed */
shouldAllow?: boolean;
/** Updated tool input to use if allowed */
updatedInput?: Record<string, unknown>;
/** Deny message to pass back to the AI if denied */
denyMessage?: string;
/** Whether to interrupt the AI after denial */
shouldInterrupt?: boolean;
}
/**
* Fire PermissionRequest hook via MessageBus
* Called when a permission dialog is about to be shown to the user.
* Returns a decision that can short-circuit the normal permission flow.
*/
export async function firePermissionRequestHook(
messageBus: MessageBus | undefined,
toolName: string,
toolInput: Record<string, unknown>,
permissionMode: string,
permissionSuggestions?: PermissionSuggestion[],
): Promise<PermissionRequestHookResult> {
if (!messageBus) {
return { hasDecision: false };
}
try {
const response = await messageBus.request<
HookExecutionRequest,
HookExecutionResponse
>(
{
type: MessageBusType.HOOK_EXECUTION_REQUEST,
eventName: 'PermissionRequest',
input: {
tool_name: toolName,
tool_input: toolInput,
permission_mode: permissionMode,
permission_suggestions: permissionSuggestions,
},
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);
if (!response.success || !response.output) {
return { hasDecision: false };
}
const permissionOutput = createHookOutput(
'PermissionRequest',
response.output,
) as PermissionRequestHookOutput;
const decision = permissionOutput.getPermissionDecision();
if (!decision) {
return { hasDecision: false };
}
if (decision.behavior === 'allow') {
return {
hasDecision: true,
shouldAllow: true,
updatedInput: decision.updatedInput,
};
}
return {
hasDecision: true,
shouldAllow: false,
denyMessage: decision.message,
shouldInterrupt: decision.interrupt,
};
} catch (error) {
debugLogger.warn(
`PermissionRequest hook error: ${error instanceof Error ? error.message : String(error)}`,
);
return { hasDecision: false };
}
}
/**
* Append additional context to tool response content
*
* @param content - Original content (string or PartListUnion)
* @param additionalContext - Context to append
* @returns Modified content with context appended
*/
export function appendAdditionalContext(
content: string | PartListUnion,
additionalContext: string | undefined,
): string | PartListUnion {
if (!additionalContext) {
return content;
}
if (typeof content === 'string') {
return content + '\n\n' + additionalContext;
}
// For PartListUnion content, append as an additional text part
if (Array.isArray(content)) {
return [...content, { text: additionalContext } as Part];
}
// For non-array content that's still PartListUnion, return as-is
return content;
}

View file

@ -174,12 +174,21 @@ describe('HookAggregator', () => {
it('should preserve other hookSpecificOutput fields', () => {
const outputs: HookOutput[] = [
{
decision: 'allow',
reason: 'Test reason 1',
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: 'ctx',
tailToolCallRequest: { name: 'A' },
},
},
{ hookSpecificOutput: { additionalContext: 'ctx2' } },
{
decision: 'allow',
reason: 'Test reason 2',
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: 'ctx2',
},
},
];
const results: HookExecutionResult[] = outputs.map((output) => ({
@ -194,9 +203,6 @@ describe('HookAggregator', () => {
results,
HookEventName.PostToolUse,
);
expect(
result.finalOutput?.hookSpecificOutput?.['tailToolCallRequest'],
).toEqual({ name: 'A' });
expect(
result.finalOutput?.hookSpecificOutput?.['additionalContext'],
).toBe('ctx\nctx2');
@ -615,4 +621,177 @@ describe('HookAggregator', () => {
expect(result.finalOutput?.decision).toBe('allow');
});
});
describe('SubagentStop - mergeWithOrLogic', () => {
it('should use mergeWithOrLogic for SubagentStop event', () => {
const outputs: HookOutput[] = [
{ reason: 'first reason', decision: 'allow' },
{ reason: 'second reason', decision: 'allow' },
];
const results: HookExecutionResult[] = outputs.map((output) => ({
hookConfig: { type: HookType.Command, command: 'echo test' },
eventName: HookEventName.SubagentStop,
success: true,
output,
duration: 100,
}));
const result = aggregator.aggregateResults(
results,
HookEventName.SubagentStop,
);
expect(result.finalOutput?.reason).toBe('first reason\nsecond reason');
});
it('should block when any SubagentStop hook blocks', () => {
const outputs: HookOutput[] = [
{ reason: 'output looks good', decision: 'allow' },
{ reason: 'output too short', decision: 'block' },
];
const results: HookExecutionResult[] = outputs.map((output) => ({
hookConfig: { type: HookType.Command, command: 'echo test' },
eventName: HookEventName.SubagentStop,
success: true,
output,
duration: 100,
}));
const result = aggregator.aggregateResults(
results,
HookEventName.SubagentStop,
);
expect(result.finalOutput?.decision).toBe('block');
});
it('should concatenate additionalContext for SubagentStop', () => {
const outputs: HookOutput[] = [
{ hookSpecificOutput: { additionalContext: 'context from hook 1' } },
{ hookSpecificOutput: { additionalContext: 'context from hook 2' } },
];
const results: HookExecutionResult[] = outputs.map((output) => ({
hookConfig: { type: HookType.Command, command: 'echo test' },
eventName: HookEventName.SubagentStop,
success: true,
output,
duration: 100,
}));
const result = aggregator.aggregateResults(
results,
HookEventName.SubagentStop,
);
expect(
result.finalOutput?.hookSpecificOutput?.['additionalContext'],
).toBe('context from hook 1\ncontext from hook 2');
});
it('should handle continue=false for SubagentStop', () => {
const outputs: HookOutput[] = [
{ continue: true },
{ continue: false, stopReason: 'subagent should stop' },
];
const results: HookExecutionResult[] = outputs.map((output) => ({
hookConfig: { type: HookType.Command, command: 'echo test' },
eventName: HookEventName.SubagentStop,
success: true,
output,
duration: 100,
}));
const result = aggregator.aggregateResults(
results,
HookEventName.SubagentStop,
);
expect(result.finalOutput?.continue).toBe(false);
expect(result.finalOutput?.stopReason).toBe('subagent should stop');
});
});
describe('createSpecificHookOutput - SubagentStop', () => {
it('should create StopHookOutput for SubagentStop', () => {
const output: HookOutput = {
decision: 'block',
reason: 'Output too short',
};
const results: HookExecutionResult[] = [
{
hookConfig: { type: HookType.Command, command: 'echo test' },
eventName: HookEventName.SubagentStop,
success: true,
output,
duration: 100,
},
];
const result = aggregator.aggregateResults(
results,
HookEventName.SubagentStop,
);
expect(result.finalOutput).toBeDefined();
expect(result.finalOutput?.decision).toBe('block');
expect(result.finalOutput?.reason).toBe('Output too short');
});
it('should create StopHookOutput with isBlockingDecision for SubagentStop', () => {
const output: HookOutput = {
decision: 'block',
reason: 'Continue working on the task',
};
const results: HookExecutionResult[] = [
{
hookConfig: { type: HookType.Command, command: 'echo test' },
eventName: HookEventName.SubagentStop,
success: true,
output,
duration: 100,
},
];
const result = aggregator.aggregateResults(
results,
HookEventName.SubagentStop,
);
// Verify the output can be consumed by StopHookOutput accessors
const hookOutput = createHookOutput(
HookEventName.SubagentStop,
result.finalOutput ?? {},
);
expect(hookOutput.isBlockingDecision()).toBe(true);
expect(hookOutput.getEffectiveReason()).toBe(
'Continue working on the task',
);
});
it('should create StopHookOutput with allow decision for SubagentStop', () => {
const output: HookOutput = {
decision: 'allow',
reason: 'Output looks complete',
};
const results: HookExecutionResult[] = [
{
hookConfig: { type: HookType.Command, command: 'echo test' },
eventName: HookEventName.SubagentStop,
success: true,
output,
duration: 100,
},
];
const result = aggregator.aggregateResults(
results,
HookEventName.SubagentStop,
);
const hookOutput = createHookOutput(
HookEventName.SubagentStop,
result.finalOutput ?? {},
);
expect(hookOutput.isBlockingDecision()).toBe(false);
});
});
});

View file

@ -8,6 +8,8 @@ import {
HookEventName,
DefaultHookOutput,
PreToolUseHookOutput,
PostToolUseHookOutput,
PostToolUseFailureHookOutput,
StopHookOutput,
PermissionRequestHookOutput,
} from './types.js';
@ -89,7 +91,8 @@ export class HookAggregator {
case HookEventName.PostToolUseFailure:
case HookEventName.Stop:
case HookEventName.UserPromptSubmit:
merged = this.mergeWithOrLogic(outputs);
case HookEventName.SubagentStop:
merged = this.mergeWithOrLogic(outputs, eventName);
break;
case HookEventName.PermissionRequest:
merged = this.mergePermissionRequestOutputs(outputs);
@ -109,8 +112,12 @@ export class HookAggregator {
* - Reasons are concatenated with newlines
* - continue=false takes precedence over continue=true
* - Additional context is concatenated
* - For PostToolUse, decision and reason are required fields
*/
private mergeWithOrLogic(outputs: HookOutput[]): HookOutput {
private mergeWithOrLogic(
outputs: HookOutput[],
_eventName?: HookEventName,
): HookOutput {
const merged: HookOutput = {};
const reasons: string[] = [];
const additionalContexts: string[] = [];
@ -337,7 +344,12 @@ export class HookAggregator {
switch (eventName) {
case HookEventName.PreToolUse:
return new PreToolUseHookOutput(output);
case HookEventName.PostToolUse:
return new PostToolUseHookOutput(output);
case HookEventName.PostToolUseFailure:
return new PostToolUseFailureHookOutput(output);
case HookEventName.Stop:
case HookEventName.SubagentStop:
return new StopHookOutput(output);
case HookEventName.PermissionRequest:
return new PermissionRequestHookOutput(output);

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,24 @@ import type {
HookExecutionResult,
UserPromptSubmitInput,
StopInput,
SessionStartInput,
SessionEndInput,
SessionStartSource,
SessionEndReason,
AgentType,
PreToolUseInput,
PostToolUseInput,
PostToolUseFailureInput,
PreCompactInput,
PreCompactTrigger,
NotificationInput,
NotificationType,
PermissionRequestInput,
PermissionSuggestion,
SubagentStartInput,
SubagentStopInput,
} from './types.js';
import { PermissionMode } from './types.js';
import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
@ -73,6 +90,241 @@ export class HookEventHandler {
return this.executeHooks(HookEventName.Stop, input);
}
/**
* Fire a SessionStart event
* Called when a new session starts or resumes
*/
async fireSessionStartEvent(
source: SessionStartSource,
model: string,
permissionMode?: PermissionMode,
agentType?: AgentType,
): Promise<AggregatedHookResult> {
const input: SessionStartInput = {
...this.createBaseInput(HookEventName.SessionStart),
permission_mode: permissionMode ?? PermissionMode.Default,
source,
model,
agent_type: agentType,
};
// Pass source as context for matcher filtering
return this.executeHooks(HookEventName.SessionStart, input, {
trigger: source,
});
}
/**
* Fire a SessionEnd event
* Called when a session ends
*/
async fireSessionEndEvent(
reason: SessionEndReason,
): Promise<AggregatedHookResult> {
const input: SessionEndInput = {
...this.createBaseInput(HookEventName.SessionEnd),
reason,
};
// Pass reason as context for matcher filtering
return this.executeHooks(HookEventName.SessionEnd, input, {
trigger: reason,
});
}
/**
* Fire a PreToolUse event
* Called before tool execution begins
*/
async firePreToolUseEvent(
toolName: string,
toolInput: Record<string, unknown>,
toolUseId: string,
permissionMode: PermissionMode,
): Promise<AggregatedHookResult> {
const input: PreToolUseInput = {
...this.createBaseInput(HookEventName.PreToolUse),
permission_mode: permissionMode,
tool_name: toolName,
tool_input: toolInput,
tool_use_id: toolUseId,
};
// Pass tool name as context for matcher filtering
return this.executeHooks(HookEventName.PreToolUse, input, {
toolName,
});
}
/**
* Fire a PostToolUse event
* Called after successful tool execution
*/
async firePostToolUseEvent(
toolName: string,
toolInput: Record<string, unknown>,
toolResponse: Record<string, unknown>,
toolUseId: string,
permissionMode: PermissionMode,
): Promise<AggregatedHookResult> {
const input: PostToolUseInput = {
...this.createBaseInput(HookEventName.PostToolUse),
permission_mode: permissionMode,
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
tool_use_id: toolUseId,
};
// Pass tool name as context for matcher filtering
return this.executeHooks(HookEventName.PostToolUse, input, {
toolName,
});
}
/**
* Fire a PostToolUseFailure event
* Called when tool execution fails
*/
async firePostToolUseFailureEvent(
toolUseId: string,
toolName: string,
toolInput: Record<string, unknown>,
errorMessage: string,
isInterrupt?: boolean,
permissionMode?: PermissionMode,
): Promise<AggregatedHookResult> {
const input: PostToolUseFailureInput = {
...this.createBaseInput(HookEventName.PostToolUseFailure),
permission_mode: permissionMode ?? PermissionMode.Default,
tool_use_id: toolUseId,
tool_name: toolName,
tool_input: toolInput,
error: errorMessage,
is_interrupt: isInterrupt,
};
// Pass tool name as context for matcher filtering
return this.executeHooks(HookEventName.PostToolUseFailure, input, {
toolName,
});
}
/**
* Fire a PreCompact event
* Called before conversation compaction begins
*/
async firePreCompactEvent(
trigger: PreCompactTrigger,
customInstructions: string = '',
): Promise<AggregatedHookResult> {
const input: PreCompactInput = {
...this.createBaseInput(HookEventName.PreCompact),
trigger,
custom_instructions: customInstructions,
};
// Pass trigger as context for matcher filtering
return this.executeHooks(HookEventName.PreCompact, input, {
trigger,
});
}
/**
* Fire a Notification event
*/
async fireNotificationEvent(
message: string,
notificationType: NotificationType,
title?: string,
): Promise<AggregatedHookResult> {
const input: NotificationInput = {
...this.createBaseInput(HookEventName.Notification),
message,
notification_type: notificationType,
title,
};
// Pass notification_type as context for matcher filtering
return this.executeHooks(HookEventName.Notification, input, {
notificationType,
});
}
/**
* Fire a PermissionRequest event
* Called when a permission dialog is about to be shown to the user
*/
async firePermissionRequestEvent(
toolName: string,
toolInput: Record<string, unknown>,
permissionMode: PermissionMode,
permissionSuggestions?: PermissionSuggestion[],
): Promise<AggregatedHookResult> {
const input: PermissionRequestInput = {
...this.createBaseInput(HookEventName.PermissionRequest),
permission_mode: permissionMode,
tool_name: toolName,
tool_input: toolInput,
permission_suggestions: permissionSuggestions,
};
// Pass tool name as context for matcher filtering
return this.executeHooks(HookEventName.PermissionRequest, input, {
toolName,
});
}
/**
* Fire a SubagentStart event
* Called when a subagent is spawned via the Agent tool
*/
async fireSubagentStartEvent(
agentId: string,
agentType: AgentType | string,
permissionMode: PermissionMode,
): Promise<AggregatedHookResult> {
const input: SubagentStartInput = {
...this.createBaseInput(HookEventName.SubagentStart),
permission_mode: permissionMode,
agent_id: agentId,
agent_type: agentType,
};
// Pass agentType as context for matcher filtering
return this.executeHooks(HookEventName.SubagentStart, input, {
agentType: String(agentType),
});
}
/**
* Fire a SubagentStop event
* Called when a subagent has finished responding
*/
async fireSubagentStopEvent(
agentId: string,
agentType: AgentType | string,
agentTranscriptPath: string,
lastAssistantMessage: string,
stopHookActive: boolean,
permissionMode: PermissionMode,
): Promise<AggregatedHookResult> {
const input: SubagentStopInput = {
...this.createBaseInput(HookEventName.SubagentStop),
permission_mode: permissionMode,
stop_hook_active: stopHookActive,
agent_id: agentId,
agent_type: agentType,
agent_transcript_path: agentTranscriptPath,
last_assistant_message: lastAssistantMessage,
};
// Pass agentType as context for matcher filtering
return this.executeHooks(HookEventName.SubagentStop, input, {
agentType: String(agentType),
});
}
/**
* Execute hooks for a specific event (direct execution without MessageBus)
* Used as fallback when MessageBus is not available

View file

@ -245,14 +245,14 @@ describe('HookPlanner', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SessionStart,
matcher: 'user',
eventName: HookEventName.PreCompact,
matcher: 'auto',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SessionStart, {
trigger: 'user',
const result = planner.createExecutionPlan(HookEventName.PreCompact, {
trigger: 'auto',
});
expect(result).not.toBeNull();
@ -262,14 +262,14 @@ describe('HookPlanner', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SessionStart,
matcher: 'user',
eventName: HookEventName.PreCompact,
matcher: 'auto',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SessionStart, {
trigger: 'api',
const result = planner.createExecutionPlan(HookEventName.PreCompact, {
trigger: 'manual',
});
expect(result).toBeNull();
@ -362,5 +362,356 @@ describe('HookPlanner', () => {
expect(result).toBeNull();
});
it('should match notification type with exact string', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.Notification,
matcher: 'permission_prompt',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.Notification, {
notificationType: 'permission_prompt',
});
expect(result).not.toBeNull();
});
it('should not match notification type with different string', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.Notification,
matcher: 'permission_prompt',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.Notification, {
notificationType: 'idle_prompt',
});
expect(result).toBeNull();
});
it('should match idle_prompt notification type', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.Notification,
matcher: 'idle_prompt',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.Notification, {
notificationType: 'idle_prompt',
});
expect(result).not.toBeNull();
});
it('should match auth_success notification type', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.Notification,
matcher: 'auth_success',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.Notification, {
notificationType: 'auth_success',
});
expect(result).not.toBeNull();
});
it('should match elicitation_dialog notification type', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.Notification,
matcher: 'elicitation_dialog',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.Notification, {
notificationType: 'elicitation_dialog',
});
expect(result).not.toBeNull();
});
it('should match all notification types when matcher is wildcard', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.Notification,
matcher: '*',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.Notification, {
notificationType: 'any_notification_type',
});
expect(result).not.toBeNull();
});
it('should match all notification types when matcher is empty', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.Notification,
matcher: '',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.Notification, {
notificationType: 'any_notification_type',
});
expect(result).not.toBeNull();
});
it('should match all notification types when no matcher provided', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.Notification,
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.Notification, {
notificationType: 'any_notification_type',
});
expect(result).not.toBeNull();
});
it('should match all notification types when no context provided', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.Notification,
matcher: 'permission_prompt',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.Notification);
expect(result).not.toBeNull();
});
it('should match agent type with exact string for SubagentStart', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SubagentStart,
matcher: 'code-reviewer',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SubagentStart, {
agentType: 'code-reviewer',
});
expect(result).not.toBeNull();
});
it('should not match agent type with different string for SubagentStart', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SubagentStart,
matcher: 'code-reviewer',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SubagentStart, {
agentType: 'qwen-tester',
});
expect(result).toBeNull();
});
it('should match agent type with regex for SubagentStart', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SubagentStart,
matcher: '^code-.*',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SubagentStart, {
agentType: 'code-reviewer',
});
expect(result).not.toBeNull();
});
it('should match agent type with wildcard for SubagentStart', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SubagentStart,
matcher: '*',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SubagentStart, {
agentType: 'any-agent',
});
expect(result).not.toBeNull();
});
it('should match all agent types when no context for SubagentStart', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SubagentStart,
matcher: 'code-reviewer',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SubagentStart);
expect(result).not.toBeNull();
});
it('should match all agent types when no matcher for SubagentStart', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SubagentStart,
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SubagentStart, {
agentType: 'any-agent',
});
expect(result).not.toBeNull();
});
it('should match agent type with exact string for SubagentStop', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SubagentStop,
matcher: 'qwen-tester',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SubagentStop, {
agentType: 'qwen-tester',
});
expect(result).not.toBeNull();
});
it('should not match agent type with different string for SubagentStop', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SubagentStop,
matcher: 'qwen-tester',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SubagentStop, {
agentType: 'code-reviewer',
});
expect(result).toBeNull();
});
it('should match agent type with regex for SubagentStop', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SubagentStop,
matcher: '.*tester$',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SubagentStop, {
agentType: 'qwen-tester',
});
expect(result).not.toBeNull();
});
it('should fallback to exact match when regex is invalid for SubagentStart', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SubagentStart,
matcher: '[invalid(regex',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SubagentStart, {
agentType: 'code-reviewer',
});
expect(result).toBeNull();
});
it('should match using fallback exact match when regex is invalid for SubagentStart', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SubagentStart,
matcher: '[invalid(regex',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SubagentStart, {
agentType: '[invalid(regex',
});
expect(result).not.toBeNull();
});
it('should match regex wildcard .* for SubagentStop', () => {
const entry: HookRegistryEntry = {
config: { type: HookType.Command, command: 'echo test' },
source: HooksConfigSource.Project,
eventName: HookEventName.SubagentStop,
matcher: '.*',
enabled: true,
};
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
const result = planner.createExecutionPlan(HookEventName.SubagentStop, {
agentType: 'any-agent-type',
});
expect(result).not.toBeNull();
});
});
});

View file

@ -6,7 +6,7 @@
import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js';
import type { HookExecutionPlan } from './types.js';
import { getHookKey, type HookEventName } from './types.js';
import { getHookKey, HookEventName } from './types.js';
import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
@ -34,9 +34,9 @@ export class HookPlanner {
return null;
}
// Filter hooks by matcher
// Filter hooks by matcher - pass eventName for explicit dispatch
const matchingEntries = hookEntries.filter((entry) =>
this.matchesContext(entry, context),
this.matchesContext(entry, eventName, context),
);
if (matchingEntries.length === 0) {
@ -64,10 +64,14 @@ export class HookPlanner {
}
/**
* Check if a hook entry matches the given context
* Check if a hook entry matches the given context.
* Uses explicit event-based dispatch to avoid ambiguity between events
* that share similar context fields (e.g., SessionStart and SubagentStart
* both have agentType, but use different matcher semantics).
*/
private matchesContext(
entry: HookRegistryEntry,
eventName: HookEventName,
context?: HookEventContext,
): boolean {
if (!entry.matcher || !context) {
@ -80,17 +84,80 @@ export class HookPlanner {
return true; // Empty string or wildcard matches all
}
// For tool events, match against tool name
if (context.toolName) {
return this.matchesToolName(matcher, context.toolName);
}
// Explicit dispatch by event name to avoid ambiguity
switch (eventName) {
// Tool events: match against tool name
case HookEventName.PreToolUse:
case HookEventName.PostToolUse:
case HookEventName.PostToolUseFailure:
case HookEventName.PermissionRequest:
return context.toolName
? this.matchesToolName(matcher, context.toolName)
: true;
// For other events, match against trigger/source
if (context.trigger) {
return this.matchesTrigger(matcher, context.trigger);
}
// Subagent events: match against agent type
case HookEventName.SubagentStart:
case HookEventName.SubagentStop:
return context.agentType
? this.matchesAgentType(matcher, context.agentType)
: true;
return true;
// PreCompact: match against trigger
case HookEventName.PreCompact:
return context.trigger
? this.matchesTrigger(matcher, context.trigger)
: true;
// Notification: match against notification type
case HookEventName.Notification:
return context.notificationType
? this.matchesNotificationType(matcher, context.notificationType)
: true;
// SessionStart/SessionEnd: match against source/reason
case HookEventName.SessionStart:
return context.trigger
? this.matchesSessionTrigger(matcher, context.trigger)
: true;
case HookEventName.SessionEnd:
return context.trigger
? this.matchesSessionTrigger(matcher, context.trigger)
: true;
// Events that don't support matchers: always match
case HookEventName.UserPromptSubmit:
case HookEventName.Stop:
default:
return true;
}
}
/**
* Match notification type against matcher pattern
*/
private matchesNotificationType(
matcher: string,
notificationType: string,
): boolean {
return matcher === notificationType;
}
/**
* Match session source or end reason against matcher pattern
*/
private matchesSessionTrigger(matcher: string, trigger: string): boolean {
try {
// Attempt to treat the matcher as a regular expression.
const regex = new RegExp(matcher);
return regex.test(trigger);
} catch (error) {
// If it's not a valid regex, treat it as a literal string for an exact match.
debugLogger.warn(
`Invalid regex in hook matcher "${matcher}" for session trigger "${trigger}", falling back to exact match: ${error}`,
);
return matcher === trigger;
}
}
/**
@ -117,6 +184,22 @@ export class HookPlanner {
return matcher === trigger;
}
/**
* Match agent type against matcher pattern.
* Supports regex matching, same as tool name matching.
*/
private matchesAgentType(matcher: string, agentType: string): boolean {
try {
const regex = new RegExp(matcher);
return regex.test(agentType);
} catch (error) {
debugLogger.warn(
`Invalid regex in hook matcher "${matcher}" for agent type "${agentType}", falling back to exact match: ${error}`,
);
return matcher === agentType;
}
}
/**
* Deduplicate identical hook configurations
*/
@ -143,4 +226,7 @@ export class HookPlanner {
export interface HookEventContext {
toolName?: string;
trigger?: string;
notificationType?: string;
/** Agent type for SubagentStart/SubagentStop matcher filtering */
agentType?: string;
}

View file

@ -408,12 +408,14 @@ export class HookRunner {
// Success - treat as system message or additional context
return {
decision: 'allow',
reason: 'Hook executed successfully',
systemMessage: text,
};
} else if (exitCode === EXIT_CODE_NON_BLOCKING_ERROR) {
// Non-blocking error (EXIT_CODE_NON_BLOCKING_ERROR = 1)
return {
decision: 'allow',
reason: `Non-blocking error: ${text}`,
systemMessage: `Warning: ${text}`,
};
} else {

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,15 @@ import type { HookRegistryEntry } from './hookRegistry.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import type { DefaultHookOutput } from './types.js';
import { createHookOutput } from './types.js';
import type {
SessionStartSource,
SessionEndReason,
AgentType,
PermissionMode,
PreCompactTrigger,
NotificationType,
PermissionSuggestion,
} from './types.js';
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
@ -100,4 +109,192 @@ export class HookSystem {
? createHookOutput('Stop', result.finalOutput)
: undefined;
}
async fireSessionStartEvent(
source: SessionStartSource,
model: string,
permissionMode?: PermissionMode,
agentType?: AgentType,
): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.fireSessionStartEvent(
source,
model,
permissionMode,
agentType,
);
return result.finalOutput
? createHookOutput('SessionStart', result.finalOutput)
: undefined;
}
async fireSessionEndEvent(
reason: SessionEndReason,
): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.fireSessionEndEvent(reason);
return result.finalOutput
? createHookOutput('SessionEnd', result.finalOutput)
: undefined;
}
/**
* Fire a PreToolUse event - called before tool execution
*/
async firePreToolUseEvent(
toolName: string,
toolInput: Record<string, unknown>,
toolUseId: string,
permissionMode: PermissionMode,
): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.firePreToolUseEvent(
toolName,
toolInput,
toolUseId,
permissionMode,
);
return result.finalOutput
? createHookOutput('PreToolUse', result.finalOutput)
: undefined;
}
/**
* Fire a PostToolUse event - called after successful tool execution
*/
async firePostToolUseEvent(
toolName: string,
toolInput: Record<string, unknown>,
toolResponse: Record<string, unknown>,
toolUseId: string,
permissionMode: PermissionMode,
): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.firePostToolUseEvent(
toolName,
toolInput,
toolResponse,
toolUseId,
permissionMode,
);
return result.finalOutput
? createHookOutput('PostToolUse', result.finalOutput)
: undefined;
}
/**
* Fire a PostToolUseFailure event - called when tool execution fails
*/
async firePostToolUseFailureEvent(
toolUseId: string,
toolName: string,
toolInput: Record<string, unknown>,
errorMessage: string,
isInterrupt?: boolean,
permissionMode?: PermissionMode,
): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.firePostToolUseFailureEvent(
toolUseId,
toolName,
toolInput,
errorMessage,
isInterrupt,
permissionMode,
);
return result.finalOutput
? createHookOutput('PostToolUseFailure', result.finalOutput)
: undefined;
}
/**
* Fire a PreCompact event - called before conversation compaction
*/
async firePreCompactEvent(
trigger: PreCompactTrigger,
customInstructions: string = '',
): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.firePreCompactEvent(
trigger,
customInstructions,
);
return result.finalOutput
? createHookOutput('PreCompact', result.finalOutput)
: undefined;
}
/**
* Fire a Notification event
*/
async fireNotificationEvent(
message: string,
notificationType: NotificationType,
title?: string,
): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.fireNotificationEvent(
message,
notificationType,
title,
);
return result.finalOutput
? createHookOutput('Notification', result.finalOutput)
: undefined;
}
/**
* Fire a SubagentStart event - called when a subagent is spawned
*/
async fireSubagentStartEvent(
agentId: string,
agentType: AgentType | string,
permissionMode: PermissionMode,
): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.fireSubagentStartEvent(
agentId,
agentType,
permissionMode,
);
return result.finalOutput
? createHookOutput('SubagentStart', result.finalOutput)
: undefined;
}
/**
* Fire a SubagentStop event - called when a subagent finishes
*/
async fireSubagentStopEvent(
agentId: string,
agentType: AgentType | string,
agentTranscriptPath: string,
lastAssistantMessage: string,
stopHookActive: boolean,
permissionMode: PermissionMode,
): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.fireSubagentStopEvent(
agentId,
agentType,
agentTranscriptPath,
lastAssistantMessage,
stopHookActive,
permissionMode,
);
return result.finalOutput
? createHookOutput('SubagentStop', result.finalOutput)
: undefined;
}
/**
* Fire a PermissionRequest event
*/
async firePermissionRequestEvent(
toolName: string,
toolInput: Record<string, unknown>,
permissionMode: PermissionMode,
permissionSuggestions?: PermissionSuggestion[],
): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.firePermissionRequestEvent(
toolName,
toolInput,
permissionMode,
permissionSuggestions,
);
return result.finalOutput
? createHookOutput('PermissionRequest', result.finalOutput)
: undefined;
}
}

View file

@ -3,6 +3,9 @@
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
export enum HooksConfigSource {
Project = 'project',
@ -125,7 +128,12 @@ export function createHookOutput(
switch (eventName) {
case HookEventName.PreToolUse:
return new PreToolUseHookOutput(data);
case HookEventName.PostToolUse:
return new PostToolUseHookOutput(data);
case HookEventName.PostToolUseFailure:
return new PostToolUseFailureHookOutput(data);
case HookEventName.Stop:
case HookEventName.SubagentStop:
return new StopHookOutput(data);
case HookEventName.PermissionRequest:
return new PermissionRequestHookOutput(data);
@ -222,21 +230,110 @@ export class DefaultHookOutput implements HookOutput {
*/
export class PreToolUseHookOutput extends DefaultHookOutput {
/**
* Get modified tool input if provided by hook
* Get permission decision from hook output
* @returns 'allow' | 'deny' | 'ask' | undefined
*/
getModifiedToolInput(): Record<string, unknown> | undefined {
if (this.hookSpecificOutput && 'tool_input' in this.hookSpecificOutput) {
const input = this.hookSpecificOutput['tool_input'];
if (
typeof input === 'object' &&
input !== null &&
!Array.isArray(input)
) {
return input as Record<string, unknown>;
getPermissionDecision(): 'allow' | 'deny' | 'ask' | undefined {
if (
this.hookSpecificOutput &&
'permissionDecision' in this.hookSpecificOutput
) {
const decision = this.hookSpecificOutput['permissionDecision'];
if (decision === 'allow' || decision === 'deny' || decision === 'ask') {
return decision;
}
}
// Fall back to base decision field
if (this.decision === 'allow' || this.decision === 'approve') {
return 'allow';
}
if (this.decision === 'deny' || this.decision === 'block') {
return 'deny';
}
if (this.decision === 'ask') {
return 'ask';
}
return undefined;
}
/**
* Get permission decision reason
*/
getPermissionDecisionReason(): string | undefined {
if (
this.hookSpecificOutput &&
'permissionDecisionReason' in this.hookSpecificOutput
) {
const reason = this.hookSpecificOutput['permissionDecisionReason'];
if (typeof reason === 'string') {
return reason;
}
}
return this.reason;
}
/**
* Check if permission was denied
*/
isDenied(): boolean {
return this.getPermissionDecision() === 'deny';
}
/**
* Check if user confirmation is required
*/
isAsk(): boolean {
return this.getPermissionDecision() === 'ask';
}
/**
* Check if permission was allowed
*/
isAllowed(): boolean {
return this.getPermissionDecision() === 'allow';
}
}
/**
* Specific hook output class for PostToolUse events.
* Default behavior is to allow tool usage if the hook does not explicitly set a decision.
* This follows the security model of allowing by default unless explicitly blocked.
*/
export class PostToolUseHookOutput extends DefaultHookOutput {
override decision: HookDecision;
override reason: string;
constructor(data: Partial<HookOutput> = {}) {
super(data);
// Default to allowing tool usage if hook does not provide explicit decision
// This maintains backward compatibility and follows security model of allowing by default
this.decision = data.decision ?? 'allow';
this.reason = data.reason ?? 'No reason provided';
// Log when default values are used to help with debugging
if (data.decision === undefined) {
debugLogger.debug(
'PostToolUseHookOutput: No explicit decision set, defaulting to "allow"',
);
}
if (data.reason === undefined) {
debugLogger.debug(
'PostToolUseHookOutput: No explicit reason set, defaulting to "No reason provided"',
);
}
}
}
/**
* Specific hook output class for PostToolUseFailure events.
*/
export class PostToolUseFailureHookOutput extends DefaultHookOutput {
/**
* Get additional context to provide error handling information
*/
override getAdditionalContext(): string | undefined {
return super.getAdditionalContext();
}
}
/**
@ -353,44 +450,23 @@ export class PermissionRequestHookOutput extends DefaultHookOutput {
}
/**
* Context for MCP tool executions.
* Contains non-sensitive connection information about the MCP server
* identity. Since server_name is user controlled and arbitrary, we
* also include connection information (e.g., command or url) to
* help identify the MCP server.
*
* NOTE: In the future, consider defining a shared sanitized interface
* from MCPServerConfig to avoid duplication and ensure consistency.
* PreToolUse hook input
*/
export interface McpToolContext {
server_name: string;
tool_name: string; // Original tool name from the MCP server
// Connection info (mutually exclusive based on transport type)
command?: string; // For stdio transport
args?: string[]; // For stdio transport
cwd?: string; // For stdio transport
url?: string; // For SSE/HTTP transport
tcp?: string; // For WebSocket transport
}
export interface PreToolUseInput extends HookInput {
permission_mode?: PermissionMode;
permission_mode: PermissionMode;
tool_name: string;
tool_input: Record<string, unknown>;
mcp_context?: McpToolContext;
original_request_name?: string;
tool_use_id: string; // Unique identifier for this tool use instance
}
/**
* PreToolUse hook output
*/
export interface PreToolUseOutput extends HookOutput {
hookSpecificOutput?: {
hookSpecificOutput: {
hookEventName: 'PreToolUse';
tool_input?: Record<string, unknown>;
permissionDecision: 'allow' | 'deny' | 'ask';
permissionDecisionReason: string;
};
}
@ -398,30 +474,24 @@ export interface PreToolUseOutput extends HookOutput {
* PostToolUse hook input
*/
export interface PostToolUseInput extends HookInput {
permission_mode: PermissionMode;
tool_name: string;
tool_input: Record<string, unknown>;
tool_response: Record<string, unknown>;
mcp_context?: McpToolContext;
original_request_name?: string;
tool_use_id: string; // Unique identifier for this tool use instance
}
/**
* PostToolUse hook output
*/
export interface PostToolUseOutput extends HookOutput {
decision: HookDecision;
reason: string;
hookSpecificOutput?: {
hookEventName: 'PostToolUse';
additionalContext?: string;
/**
* Optional request to execute another tool immediately after this one.
* The result of this tail call will replace the original tool's response.
*/
tailToolCallRequest?: {
name: string;
args: Record<string, unknown>;
};
};
updatedMCPToolOutput?: Record<string, unknown>;
}
/**
@ -429,11 +499,11 @@ export interface PostToolUseOutput extends HookOutput {
* Fired when a tool execution fails
*/
export interface PostToolUseFailureInput extends HookInput {
permission_mode: PermissionMode;
tool_use_id: string; // Unique identifier for the tool use
tool_name: string;
tool_input: Record<string, unknown>;
error: string; // Error message describing the failure
error_type?: string; // Type of error (e.g., 'timeout', 'network', 'permission', etc.)
is_interrupt?: boolean; // Whether the failure was caused by user interruption
}
@ -469,18 +539,19 @@ export interface UserPromptSubmitOutput extends HookOutput {
* Notification types
*/
export enum NotificationType {
ToolPermission = 'ToolPermission',
PermissionPrompt = 'permission_prompt',
IdlePrompt = 'idle_prompt',
AuthSuccess = 'auth_success',
ElicitationDialog = 'elicitation_dialog',
}
/**
* Notification hook input
*/
export interface NotificationInput extends HookInput {
permission_mode?: PermissionMode;
notification_type: NotificationType;
message: string;
title?: string;
details: Record<string, unknown>;
notification_type: NotificationType;
}
/**
@ -524,18 +595,18 @@ export enum SessionStartSource {
export enum PermissionMode {
Default = 'default',
Plan = 'plan',
AcceptEdit = 'accept_edit',
DontAsk = 'dont_ask',
BypassPermissions = 'bypass_permissions',
AutoEdit = 'auto_edit',
Yolo = 'yolo',
}
/**
* SessionStart hook input
*/
export interface SessionStartInput extends HookInput {
permission_mode?: PermissionMode;
permission_mode: PermissionMode;
source: SessionStartSource;
model?: string;
model: string;
agent_type?: AgentType;
}
/**
@ -589,7 +660,7 @@ export enum PreCompactTrigger {
*/
export interface PreCompactInput extends HookInput {
trigger: PreCompactTrigger;
custom_instructions?: string;
custom_instructions: string;
}
/**
@ -598,7 +669,7 @@ export interface PreCompactInput extends HookInput {
export interface PreCompactOutput extends HookOutput {
hookSpecificOutput?: {
hookEventName: 'PreCompact';
additionalContext?: string;
additionalContext: string;
};
}
@ -611,12 +682,12 @@ export enum AgentType {
/**
* SubagentStart hook input
* Fired when a subagent (Task tool call) is started
* Fired when a subagent (Agent tool call) is spawned
*/
export interface SubagentStartInput extends HookInput {
permission_mode?: PermissionMode;
permission_mode: PermissionMode;
agent_id: string;
agent_type: AgentType;
agent_type: AgentType | string;
}
/**
@ -631,13 +702,13 @@ export interface SubagentStartOutput extends HookOutput {
/**
* SubagentStop hook input
* Fired right before a subagent (Task tool call) concludes its response
* Fired when a subagent has finished responding
*/
export interface SubagentStopInput extends HookInput {
permission_mode?: PermissionMode;
permission_mode: PermissionMode;
stop_hook_active: boolean;
agent_id: string;
agent_type: AgentType;
agent_type: AgentType | string;
agent_transcript_path: string;
last_assistant_message: string;
}

View file

@ -251,3 +251,9 @@ export * from './test-utils/index.js';
export * from './hooks/types.js';
export { HookSystem, HookRegistry } from './hooks/index.js';
export type { HookRegistryEntry } from './hooks/index.js';
// Export hook triggers for notification hooks
export {
fireNotificationHook,
type NotificationHookResult,
} from './core/toolHookTriggers.js';

View file

@ -16,6 +16,7 @@ import { tokenLimit } from '../core/tokenLimits.js';
import type { GeminiChat } from '../core/geminiChat.js';
import type { Config } from '../config/config.js';
import type { ContentGenerator } from '../core/contentGenerator.js';
import { SessionStartSource, PreCompactTrigger } from '../hooks/types.js';
vi.mock('../telemetry/uiTelemetry.js');
vi.mock('../core/tokenLimits.js');
@ -107,16 +108,27 @@ describe('ChatCompressionService', () => {
let mockConfig: Config;
const mockModel = 'gemini-pro';
const mockPromptId = 'test-prompt-id';
let mockFireSessionStartEvent: ReturnType<typeof vi.fn>;
let mockGetHookSystem: ReturnType<typeof vi.fn>;
beforeEach(() => {
service = new ChatCompressionService();
mockChat = {
getHistory: vi.fn(),
} as unknown as GeminiChat;
mockFireSessionStartEvent = vi.fn().mockResolvedValue(undefined);
mockGetHookSystem = vi.fn().mockReturnValue({
fireSessionStartEvent: mockFireSessionStartEvent,
});
mockConfig = {
getChatCompression: vi.fn(),
getContentGenerator: vi.fn(),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getHookSystem: mockGetHookSystem,
getModel: () => 'test-model',
getDebugLogger: () => ({
warn: vi.fn(),
}),
} as unknown as Config;
vi.mocked(tokenLimit).mockReturnValue(1000);
@ -274,6 +286,11 @@ describe('ChatCompressionService', () => {
expect(result.newHistory).not.toBeNull();
expect(result.newHistory![0].parts![0].text).toBe('Summary');
expect(mockGenerateContent).toHaveBeenCalled();
expect(mockGetHookSystem).toHaveBeenCalled();
expect(mockFireSessionStartEvent).toHaveBeenCalledWith(
SessionStartSource.Compact,
mockModel,
);
});
it('should force compress even if under threshold', async () => {
@ -317,6 +334,10 @@ describe('ChatCompressionService', () => {
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
expect(result.newHistory).not.toBeNull();
expect(mockFireSessionStartEvent).toHaveBeenCalledWith(
SessionStartSource.Compact,
mockModel,
);
});
it('should return FAILED if new token count is inflated', async () => {
@ -481,4 +502,427 @@ describe('ChatCompressionService', () => {
);
expect(result.newHistory).toBeNull();
});
it('should not fire SessionStart event when compression fails', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(10);
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'Summary' }],
},
},
],
usageMetadata: {
promptTokenCount: 1,
candidatesTokenCount: 20,
totalTokenCount: 21,
},
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
} as unknown as ContentGenerator);
const result = await service.compress(
mockChat,
mockPromptId,
true,
mockModel,
mockConfig,
false,
);
expect(result.info.compressionStatus).toBe(
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
);
expect(result.newHistory).toBeNull();
expect(mockFireSessionStartEvent).not.toHaveBeenCalled();
});
it('should handle SessionStart hook errors gracefully', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
{ role: 'user', parts: [{ text: 'msg3' }] },
{ role: 'model', parts: [{ text: 'msg4' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800);
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
model: 'gemini-pro',
contextWindowSize: 1000,
} as unknown as ReturnType<typeof mockConfig.getContentGeneratorConfig>);
mockFireSessionStartEvent.mockRejectedValue(
new Error('SessionStart hook failed'),
);
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'Summary' }],
},
},
],
usageMetadata: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
totalTokenCount: 1650,
},
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
} as unknown as ContentGenerator);
const result = await service.compress(
mockChat,
mockPromptId,
false,
mockModel,
mockConfig,
false,
);
// Should still complete compression despite hook error
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
expect(result.newHistory).not.toBeNull();
});
describe('PreCompact hook', () => {
let mockFirePreCompactEvent: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFirePreCompactEvent = vi.fn().mockResolvedValue(undefined);
mockGetHookSystem.mockReturnValue({
fireSessionStartEvent: mockFireSessionStartEvent,
firePreCompactEvent: mockFirePreCompactEvent,
});
});
it('should fire PreCompact hook with Manual trigger when force=true', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
{ role: 'user', parts: [{ text: 'msg3' }] },
{ role: 'model', parts: [{ text: 'msg4' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
100,
);
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'Summary' }],
},
},
],
usageMetadata: {
promptTokenCount: 1100,
candidatesTokenCount: 50,
totalTokenCount: 1150,
},
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
} as unknown as ContentGenerator);
await service.compress(
mockChat,
mockPromptId,
true, // force = true -> Manual trigger
mockModel,
mockConfig,
false,
);
expect(mockFirePreCompactEvent).toHaveBeenCalledWith(
PreCompactTrigger.Manual,
'',
);
});
it('should fire PreCompact hook with Auto trigger when force=false', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
{ role: 'user', parts: [{ text: 'msg3' }] },
{ role: 'model', parts: [{ text: 'msg4' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
800,
);
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
model: 'gemini-pro',
contextWindowSize: 1000,
} as unknown as ReturnType<typeof mockConfig.getContentGeneratorConfig>);
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'Summary' }],
},
},
],
usageMetadata: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
totalTokenCount: 1650,
},
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
} as unknown as ContentGenerator);
await service.compress(
mockChat,
mockPromptId,
false, // force = false -> Auto trigger
mockModel,
mockConfig,
false,
);
expect(mockFirePreCompactEvent).toHaveBeenCalledWith(
PreCompactTrigger.Auto,
'',
);
});
it('should not fire PreCompact hook when history is empty', async () => {
vi.mocked(mockChat.getHistory).mockReturnValue([]);
const result = await service.compress(
mockChat,
mockPromptId,
true,
mockModel,
mockConfig,
false,
);
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
expect(mockFirePreCompactEvent).not.toHaveBeenCalled();
});
it('should not fire PreCompact hook when threshold is 0', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(mockConfig.getChatCompression).mockReturnValue({
contextPercentageThreshold: 0,
});
const result = await service.compress(
mockChat,
mockPromptId,
true,
mockModel,
mockConfig,
false,
);
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
expect(mockFirePreCompactEvent).not.toHaveBeenCalled();
});
it('should not fire PreCompact hook when under threshold and not forced', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
600,
);
vi.mocked(tokenLimit).mockReturnValue(1000);
const result = await service.compress(
mockChat,
mockPromptId,
false,
mockModel,
mockConfig,
false,
);
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
expect(mockFirePreCompactEvent).not.toHaveBeenCalled();
});
it('should handle PreCompact hook errors gracefully', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
{ role: 'user', parts: [{ text: 'msg3' }] },
{ role: 'model', parts: [{ text: 'msg4' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
800,
);
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
model: 'gemini-pro',
contextWindowSize: 1000,
} as unknown as ReturnType<typeof mockConfig.getContentGeneratorConfig>);
mockFirePreCompactEvent.mockRejectedValue(
new Error('PreCompact hook failed'),
);
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'Summary' }],
},
},
],
usageMetadata: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
totalTokenCount: 1650,
},
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
} as unknown as ContentGenerator);
const result = await service.compress(
mockChat,
mockPromptId,
false,
mockModel,
mockConfig,
false,
);
// Should still complete compression despite hook error
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
expect(result.newHistory).not.toBeNull();
expect(mockFirePreCompactEvent).toHaveBeenCalled();
});
it('should fire PreCompact hook before compression and SessionStart after', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
{ role: 'user', parts: [{ text: 'msg3' }] },
{ role: 'model', parts: [{ text: 'msg4' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
800,
);
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
model: 'gemini-pro',
contextWindowSize: 1000,
} as unknown as ReturnType<typeof mockConfig.getContentGeneratorConfig>);
const callOrder: string[] = [];
mockFirePreCompactEvent.mockImplementation(async () => {
callOrder.push('PreCompact');
});
mockFireSessionStartEvent.mockImplementation(async () => {
callOrder.push('SessionStart');
});
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'Summary' }],
},
},
],
usageMetadata: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
totalTokenCount: 1650,
},
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
} as unknown as ContentGenerator);
await service.compress(
mockChat,
mockPromptId,
false,
mockModel,
mockConfig,
false,
);
// PreCompact should be called before SessionStart
expect(callOrder).toEqual(['PreCompact', 'SessionStart']);
});
it('should not fire PreCompact hook when hookSystem is null', async () => {
mockGetHookSystem.mockReturnValue(null);
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
{ role: 'user', parts: [{ text: 'msg3' }] },
{ role: 'model', parts: [{ text: 'msg4' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
800,
);
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
model: 'gemini-pro',
contextWindowSize: 1000,
} as unknown as ReturnType<typeof mockConfig.getContentGeneratorConfig>);
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'Summary' }],
},
},
],
usageMetadata: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
totalTokenCount: 1650,
},
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
} as unknown as ContentGenerator);
const result = await service.compress(
mockChat,
mockPromptId,
false,
mockModel,
mockConfig,
false,
);
// Should still complete compression without hook
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
expect(result.newHistory).not.toBeNull();
// mockFirePreCompactEvent should not be called since hookSystem is null
expect(mockFirePreCompactEvent).not.toHaveBeenCalled();
});
});
});

View file

@ -14,6 +14,7 @@ import { getCompressionPrompt } from '../core/prompts.js';
import { getResponseText } from '../utils/partUtils.js';
import { logChatCompression } from '../telemetry/loggers.js';
import { makeChatCompressionEvent } from '../telemetry/types.js';
import { SessionStartSource, PreCompactTrigger } from '../hooks/types.js';
/**
* Threshold for compression token count as a fraction of the model's token limit.
@ -124,6 +125,17 @@ export class ChatCompressionService {
}
}
// Fire PreCompact hook before compression begins
const hookSystem = config.getHookSystem();
if (hookSystem) {
const trigger = force ? PreCompactTrigger.Manual : PreCompactTrigger.Auto;
try {
await hookSystem.firePreCompactEvent(trigger, '');
} catch (err) {
config.getDebugLogger().warn(`PreCompact hook failed: ${err}`);
}
}
const splitPoint = findCompressSplitPoint(
curatedHistory,
1 - COMPRESSION_PRESERVE_THRESHOLD,
@ -261,6 +273,16 @@ export class ChatCompressionService {
};
} else {
uiTelemetryService.setLastPromptTokenCount(newTokenCount);
// Fire SessionStart event after successful compression
try {
await config
.getHookSystem()
?.fireSessionStartEvent(SessionStartSource.Compact, model ?? '');
} catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
}
return {
newHistory: extraHistory,
info: {

View file

@ -17,6 +17,8 @@ import {
ContextState,
} from '../agents/runtime/agent-headless.js';
import { partToString } from '../utils/partUtils.js';
import type { HookSystem } from '../hooks/hookSystem.js';
import { PermissionMode } from '../hooks/types.js';
// Type for accessing protected methods in tests
type TaskToolWithProtectedMethods = TaskTool & {
@ -73,6 +75,8 @@ describe('TaskTool', () => {
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getSubagentManager: vi.fn(),
getGeminiClient: vi.fn().mockReturnValue(undefined),
getHookSystem: vi.fn().mockReturnValue(undefined),
getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'),
} as unknown as Config;
changeListeners = [];
@ -294,11 +298,11 @@ describe('TaskTool', () => {
});
describe('TaskToolInvocation', () => {
let mockSubagentScope: AgentHeadless;
let mockAgent: AgentHeadless;
let mockContextState: ContextState;
beforeEach(() => {
mockSubagentScope = {
mockAgent = {
execute: vi.fn().mockResolvedValue(undefined),
result: 'Task completed successfully',
terminateMode: AgentTerminateMode.GOAL,
@ -357,7 +361,7 @@ describe('TaskTool', () => {
mockSubagents[0],
);
vi.mocked(mockSubagentManager.createAgentHeadless).mockResolvedValue(
mockSubagentScope,
mockAgent,
);
});
@ -381,7 +385,7 @@ describe('TaskTool', () => {
config,
expect.any(Object), // eventEmitter parameter
);
expect(mockSubagentScope.execute).toHaveBeenCalledWith(
expect(mockAgent.execute).toHaveBeenCalledWith(
mockContextState,
undefined, // signal parameter (undefined when not provided)
);
@ -535,4 +539,464 @@ describe('TaskTool', () => {
expect(description).toBe('file-search subagent: "Search files"');
});
});
describe('SubagentStart hook integration', () => {
let mockAgent: AgentHeadless;
let mockContextState: ContextState;
let mockHookSystem: HookSystem;
beforeEach(() => {
mockAgent = {
execute: vi.fn().mockResolvedValue(undefined),
result: 'Task completed successfully',
terminateMode: AgentTerminateMode.GOAL,
getFinalText: vi.fn().mockReturnValue('Task completed successfully'),
formatCompactResult: vi.fn().mockReturnValue('✅ Success'),
getExecutionSummary: vi.fn().mockReturnValue({
rounds: 1,
totalDurationMs: 500,
totalToolCalls: 1,
successfulToolCalls: 1,
failedToolCalls: 0,
successRate: 100,
inputTokens: 100,
outputTokens: 50,
totalTokens: 150,
estimatedCost: 0.01,
toolUsage: [],
}),
getStatistics: vi.fn().mockReturnValue({
rounds: 1,
totalDurationMs: 500,
totalToolCalls: 1,
successfulToolCalls: 1,
failedToolCalls: 0,
}),
getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL),
} as unknown as AgentHeadless;
mockContextState = {
set: vi.fn(),
} as unknown as ContextState;
MockedContextState.mockImplementation(() => mockContextState);
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(
mockSubagents[0],
);
vi.mocked(mockSubagentManager.createAgentHeadless).mockResolvedValue(
mockAgent,
);
mockHookSystem = {
fireSubagentStartEvent: vi.fn().mockResolvedValue(undefined),
fireSubagentStopEvent: vi.fn().mockResolvedValue(undefined),
} as unknown as HookSystem;
vi.mocked(config.getGeminiClient).mockReturnValue(undefined as never);
(config as unknown as Record<string, unknown>)['getHookSystem'] = vi
.fn()
.mockReturnValue(mockHookSystem);
(config as unknown as Record<string, unknown>)['getTranscriptPath'] = vi
.fn()
.mockReturnValue('/test/transcript');
});
it('should call fireSubagentStartEvent before execution', async () => {
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
await invocation.execute();
expect(mockHookSystem.fireSubagentStartEvent).toHaveBeenCalledWith(
expect.stringContaining('file-search-'),
'file-search',
PermissionMode.Default,
);
});
it('should inject additionalContext from SubagentStart hook into context', async () => {
const mockStartOutput = {
getAdditionalContext: vi
.fn()
.mockReturnValue('Extra context from hook'),
};
vi.mocked(mockHookSystem.fireSubagentStartEvent).mockResolvedValue(
mockStartOutput as never,
);
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
await invocation.execute();
expect(mockContextState.set).toHaveBeenCalledWith(
'hook_context',
'Extra context from hook',
);
});
it('should not inject hook_context when additionalContext is undefined', async () => {
const mockStartOutput = {
getAdditionalContext: vi.fn().mockReturnValue(undefined),
};
vi.mocked(mockHookSystem.fireSubagentStartEvent).mockResolvedValue(
mockStartOutput as never,
);
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
await invocation.execute();
expect(mockContextState.set).not.toHaveBeenCalledWith(
'hook_context',
expect.anything(),
);
});
it('should continue execution when SubagentStart hook fails', async () => {
vi.mocked(mockHookSystem.fireSubagentStartEvent).mockRejectedValue(
new Error('Hook failed'),
);
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
const result = await invocation.execute();
// Should still complete successfully despite hook failure
const llmText = partToString(result.llmContent);
expect(llmText).toBe('Task completed successfully');
const display = result.returnDisplay as TaskResultDisplay;
expect(display.status).toBe('completed');
});
it('should skip hooks when hookSystem is not available', async () => {
(config as unknown as Record<string, unknown>)['getHookSystem'] = vi
.fn()
.mockReturnValue(undefined);
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
const result = await invocation.execute();
expect(mockHookSystem.fireSubagentStartEvent).not.toHaveBeenCalled();
const llmText = partToString(result.llmContent);
expect(llmText).toBe('Task completed successfully');
});
});
describe('SubagentStop hook integration', () => {
let mockAgent: AgentHeadless;
let mockContextState: ContextState;
let mockHookSystem: HookSystem;
beforeEach(() => {
mockAgent = {
execute: vi.fn().mockResolvedValue(undefined),
result: 'Task completed successfully',
terminateMode: AgentTerminateMode.GOAL,
getFinalText: vi.fn().mockReturnValue('Task completed successfully'),
formatCompactResult: vi.fn().mockReturnValue('✅ Success'),
getExecutionSummary: vi.fn().mockReturnValue({
rounds: 1,
totalDurationMs: 500,
totalToolCalls: 1,
successfulToolCalls: 1,
failedToolCalls: 0,
successRate: 100,
inputTokens: 100,
outputTokens: 50,
totalTokens: 150,
estimatedCost: 0.01,
toolUsage: [],
}),
getStatistics: vi.fn().mockReturnValue({
rounds: 1,
totalDurationMs: 500,
totalToolCalls: 1,
successfulToolCalls: 1,
failedToolCalls: 0,
}),
getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL),
} as unknown as AgentHeadless;
mockContextState = {
set: vi.fn(),
} as unknown as ContextState;
MockedContextState.mockImplementation(() => mockContextState);
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(
mockSubagents[0],
);
vi.mocked(mockSubagentManager.createAgentHeadless).mockResolvedValue(
mockAgent,
);
mockHookSystem = {
fireSubagentStartEvent: vi.fn().mockResolvedValue(undefined),
fireSubagentStopEvent: vi.fn().mockResolvedValue(undefined),
} as unknown as HookSystem;
vi.mocked(config.getGeminiClient).mockReturnValue(undefined as never);
(config as unknown as Record<string, unknown>)['getHookSystem'] = vi
.fn()
.mockReturnValue(mockHookSystem);
(config as unknown as Record<string, unknown>)['getTranscriptPath'] = vi
.fn()
.mockReturnValue('/test/transcript');
});
it('should call fireSubagentStopEvent after execution', async () => {
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
await invocation.execute();
expect(mockHookSystem.fireSubagentStopEvent).toHaveBeenCalledWith(
expect.stringContaining('file-search-'),
'file-search',
'/test/transcript',
'Task completed successfully',
false,
PermissionMode.Default,
);
});
it('should re-execute subagent when stop hook returns blocking decision', async () => {
const mockBlockOutput = {
isBlockingDecision: vi
.fn()
.mockReturnValueOnce(true)
.mockReturnValueOnce(false),
shouldStopExecution: vi.fn().mockReturnValue(false),
getEffectiveReason: vi
.fn()
.mockReturnValue('Continue working on the task'),
};
// First call returns block, second call returns allow (no output)
vi.mocked(mockHookSystem.fireSubagentStopEvent)
.mockResolvedValueOnce(mockBlockOutput as never)
.mockResolvedValueOnce(undefined as never);
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
await invocation.execute();
// Should have called execute twice (initial + re-execution)
expect(mockAgent.execute).toHaveBeenCalledTimes(2);
// Stop hook should have been called twice
expect(mockHookSystem.fireSubagentStopEvent).toHaveBeenCalledTimes(2);
// Second call should have stopHookActive=true
expect(mockHookSystem.fireSubagentStopEvent).toHaveBeenNthCalledWith(
2,
expect.stringContaining('file-search-'),
'file-search',
'/test/transcript',
'Task completed successfully',
true,
PermissionMode.Default,
);
});
it('should re-execute subagent when stop hook returns shouldStopExecution', async () => {
const mockStopOutput = {
isBlockingDecision: vi.fn().mockReturnValue(false),
shouldStopExecution: vi.fn().mockReturnValueOnce(true),
getEffectiveReason: vi.fn().mockReturnValue('Output is incomplete'),
};
vi.mocked(mockHookSystem.fireSubagentStopEvent)
.mockResolvedValueOnce(mockStopOutput as never)
.mockResolvedValueOnce(undefined as never);
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
await invocation.execute();
expect(mockAgent.execute).toHaveBeenCalledTimes(2);
});
it('should allow stop when SubagentStop hook fails', async () => {
vi.mocked(mockHookSystem.fireSubagentStopEvent).mockRejectedValue(
new Error('Stop hook failed'),
);
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
const result = await invocation.execute();
// Should still complete successfully despite hook failure
const llmText = partToString(result.llmContent);
expect(llmText).toBe('Task completed successfully');
const display = result.returnDisplay as TaskResultDisplay;
expect(display.status).toBe('completed');
});
it('should skip SubagentStop hook when signal is aborted', async () => {
const abortController = new AbortController();
abortController.abort();
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
await invocation.execute(abortController.signal);
expect(mockHookSystem.fireSubagentStopEvent).not.toHaveBeenCalled();
});
it('should stop re-execution loop when signal is aborted during block handling', async () => {
const abortController = new AbortController();
const mockBlockOutput = {
isBlockingDecision: vi.fn().mockReturnValue(true),
shouldStopExecution: vi.fn().mockReturnValue(false),
getEffectiveReason: vi.fn().mockReturnValue('Keep working'),
};
vi.mocked(mockHookSystem.fireSubagentStopEvent).mockResolvedValue(
mockBlockOutput as never,
);
// Abort after first re-execution
vi.mocked(mockAgent.execute).mockImplementation(async () => {
const callCount = vi.mocked(mockAgent.execute).mock.calls.length;
if (callCount >= 2) {
abortController.abort();
}
});
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
await invocation.execute(abortController.signal);
// Should have stopped the loop after abort
expect(mockAgent.execute).toHaveBeenCalledTimes(2);
});
it('should call both start and stop hooks in correct order', async () => {
const callOrder: string[] = [];
vi.mocked(mockHookSystem.fireSubagentStartEvent).mockImplementation(
async () => {
callOrder.push('start');
return undefined;
},
);
vi.mocked(mockHookSystem.fireSubagentStopEvent).mockImplementation(
async () => {
callOrder.push('stop');
return undefined;
},
);
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
await invocation.execute();
expect(callOrder).toEqual(['start', 'stop']);
});
it('should pass consistent agentId to both start and stop hooks', async () => {
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
await invocation.execute();
const startAgentId = vi.mocked(mockHookSystem.fireSubagentStartEvent).mock
.calls[0]?.[0] as string;
const stopAgentId = vi.mocked(mockHookSystem.fireSubagentStopEvent).mock
.calls[0]?.[0] as string;
expect(startAgentId).toBe(stopAgentId);
expect(startAgentId).toMatch(/^file-search-\d+$/);
});
});
});

View file

@ -33,6 +33,8 @@ import type {
AgentApprovalRequestEvent,
} from '../agents/runtime/agent-events.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { PermissionMode } from '../hooks/types.js';
import type { StopHookOutput } from '../hooks/types.js';
export interface TaskParams {
description: string;
@ -511,9 +513,98 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
const contextState = new ContextState();
contextState.set('task_prompt', this.params.prompt);
// Fire SubagentStart hook before execution
const hookSystem = this.config.getHookSystem();
const agentId = `${subagentConfig.name}-${Date.now()}`;
const agentType = this.params.subagent_type;
if (hookSystem) {
try {
const startHookOutput = await hookSystem.fireSubagentStartEvent(
agentId,
agentType,
PermissionMode.Default,
);
// Inject additional context from hook output into subagent context
const additionalContext = startHookOutput?.getAdditionalContext();
if (additionalContext) {
contextState.set('hook_context', additionalContext);
}
} catch (hookError) {
debugLogger.warn(
`[TaskTool] SubagentStart hook failed, continuing execution: ${hookError}`,
);
}
}
// Execute the subagent (blocking)
await subagent.execute(contextState, signal);
// Fire SubagentStop hook after execution and handle block decisions
if (hookSystem && !signal?.aborted) {
const transcriptPath = this.config.getTranscriptPath();
let stopHookActive = false;
// Loop to handle "block" decisions (prevent subagent from stopping)
let continueExecution = true;
let iterationCount = 0;
const maxIterations = 5; // Prevent infinite loops from hook misconfigurations
while (continueExecution) {
iterationCount++;
// Safety check to prevent infinite loops
if (iterationCount >= maxIterations) {
debugLogger.warn(
`[TaskTool] SubagentStop hook reached maximum iterations (${maxIterations}), forcing stop to prevent infinite loop`,
);
continueExecution = false;
break;
}
try {
const stopHookOutput = await hookSystem.fireSubagentStopEvent(
agentId,
agentType,
transcriptPath,
subagent.getFinalText(),
stopHookActive,
PermissionMode.Default,
);
const typedStopOutput = stopHookOutput as
| StopHookOutput
| undefined;
if (
typedStopOutput?.isBlockingDecision() ||
typedStopOutput?.shouldStopExecution()
) {
// Feed the reason back to the subagent and continue execution
const continueReason = typedStopOutput.getEffectiveReason();
stopHookActive = true;
const continueContext = new ContextState();
continueContext.set('task_prompt', continueReason);
await subagent.execute(continueContext, signal);
if (signal?.aborted) {
continueExecution = false;
}
// Loop continues to re-check SubagentStop hook
} else {
continueExecution = false;
}
} catch (hookError) {
debugLogger.warn(
`[TaskTool] SubagentStop hook failed, allowing stop: ${hookError}`,
);
continueExecution = false;
}
}
}
// Get the results
const finalText = subagent.getFinalText();
const terminateMode = subagent.getTerminateMode();

View file

@ -760,6 +760,76 @@
"hooks"
]
}
},
"Notification": {
"description": "Hooks that execute when notifications are sent.",
"type": "array",
"items": {
"type": "string"
}
},
"PreToolUse": {
"description": "Hooks that execute before tool execution.",
"type": "array",
"items": {
"type": "string"
}
},
"PostToolUse": {
"description": "Hooks that execute after successful tool execution.",
"type": "array",
"items": {
"type": "string"
}
},
"PostToolUseFailure": {
"description": "Hooks that execute when tool execution fails. ",
"type": "array",
"items": {
"type": "string"
}
},
"SessionStart": {
"description": "Hooks that execute when a new session starts or resumes.",
"type": "array",
"items": {
"type": "string"
}
},
"SessionEnd": {
"description": "Hooks that execute when a session ends.",
"type": "array",
"items": {
"type": "string"
}
},
"PreCompact": {
"description": "Hooks that execute before conversation compaction.",
"type": "array",
"items": {
"type": "string"
}
},
"SubagentStart": {
"description": "Hooks that execute when a subagent (Task tool call) is started.",
"type": "array",
"items": {
"type": "string"
}
},
"SubagentStop": {
"description": "Hooks that execute right before a subagent (Task tool call) concludes its response.",
"type": "array",
"items": {
"type": "string"
}
},
"PermissionRequest": {
"description": "Hooks that execute when a permission dialog is displayed.",
"type": "array",
"items": {
"type": "string"
}
}
}
},