mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-09 19:42:57 +00:00
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:
commit
58bc7a5198
37 changed files with 12716 additions and 476 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
exportCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
hooksCommand,
|
||||
...(this.config?.getEnableHooks() ? [hooksCommand] : []),
|
||||
await ideCommand(),
|
||||
initCommand,
|
||||
languageCommand,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
980
packages/core/src/core/toolHookTriggers.test.ts
Normal file
980
packages/core/src/core/toolHookTriggers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
478
packages/core/src/core/toolHookTriggers.ts
Normal file
478
packages/core/src/core/toolHookTriggers.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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+$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue