feat(hooks): Hook Configuration Schema and Types (#9074)

Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
This commit is contained in:
Edilmo Palencia
2025-11-02 11:49:16 -08:00
committed by GitHub
parent 02518d2927
commit c0495ce2f9
5 changed files with 217 additions and 2 deletions

View File

@@ -6,11 +6,17 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Mock } from 'vitest';
import type { ConfigParameters, SandboxConfig } from './config.js';
import type {
ConfigParameters,
SandboxConfig,
HookDefinition,
} from './config.js';
import {
Config,
ApprovalMode,
DEFAULT_FILE_FILTERING_OPTIONS,
HookType,
HookEventName,
} from './config.js';
import * as path from 'node:path';
import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js';
@@ -1133,3 +1139,111 @@ describe('BaseLlmClient Lifecycle', () => {
);
});
});
describe('Config getHooks', () => {
const baseParams: ConfigParameters = {
cwd: '/tmp',
targetDir: '/path/to/target',
debugMode: false,
sessionId: 'test-session-id',
model: 'gemini-pro',
usageStatisticsEnabled: false,
};
it('should return undefined when no hooks are provided', () => {
const config = new Config(baseParams);
expect(config.getHooks()).toBeUndefined();
});
it('should return empty object when empty hooks are provided', () => {
const configWithEmptyHooks = new Config({
...baseParams,
hooks: {},
});
expect(configWithEmptyHooks.getHooks()).toEqual({});
});
it('should return the hooks configuration when provided', () => {
const mockHooks: { [K in HookEventName]?: HookDefinition[] } = {
[HookEventName.BeforeTool]: [
{
matcher: 'write_file',
hooks: [
{
type: HookType.Command,
command: 'echo "test hook"',
timeout: 5000,
},
],
},
],
[HookEventName.AfterTool]: [
{
hooks: [
{
type: HookType.Command,
command: './hooks/after-tool.sh',
timeout: 10000,
},
],
},
],
};
const config = new Config({
...baseParams,
hooks: mockHooks,
});
const retrievedHooks = config.getHooks();
expect(retrievedHooks).toEqual(mockHooks);
expect(retrievedHooks).toBe(mockHooks); // Should return the same reference
});
it('should return hooks with all supported event types', () => {
const allEventHooks: { [K in HookEventName]?: HookDefinition[] } = {
[HookEventName.BeforeAgent]: [
{ hooks: [{ type: HookType.Command, command: 'test1' }] },
],
[HookEventName.AfterAgent]: [
{ hooks: [{ type: HookType.Command, command: 'test2' }] },
],
[HookEventName.BeforeTool]: [
{ hooks: [{ type: HookType.Command, command: 'test3' }] },
],
[HookEventName.AfterTool]: [
{ hooks: [{ type: HookType.Command, command: 'test4' }] },
],
[HookEventName.BeforeModel]: [
{ hooks: [{ type: HookType.Command, command: 'test5' }] },
],
[HookEventName.AfterModel]: [
{ hooks: [{ type: HookType.Command, command: 'test6' }] },
],
[HookEventName.BeforeToolSelection]: [
{ hooks: [{ type: HookType.Command, command: 'test7' }] },
],
[HookEventName.Notification]: [
{ hooks: [{ type: HookType.Command, command: 'test8' }] },
],
[HookEventName.SessionStart]: [
{ hooks: [{ type: HookType.Command, command: 'test9' }] },
],
[HookEventName.SessionEnd]: [
{ hooks: [{ type: HookType.Command, command: 'test10' }] },
],
[HookEventName.PreCompress]: [
{ hooks: [{ type: HookType.Command, command: 'test11' }] },
],
};
const config = new Config({
...baseParams,
hooks: allEventHooks,
});
const retrievedHooks = config.getHooks();
expect(retrievedHooks).toEqual(allEventHooks);
expect(Object.keys(retrievedHooks!)).toHaveLength(11); // All hook event types
});
});

View File

@@ -134,6 +134,7 @@ export interface GeminiCLIExtension {
contextFiles: string[];
excludeTools?: string[];
id: string;
hooks?: { [K in HookEventName]?: HookDefinition[] };
}
export interface ExtensionInstallMetadata {
@@ -209,6 +210,50 @@ export interface SandboxConfig {
image: string;
}
/**
* Event names for the hook system
*/
export enum HookEventName {
BeforeTool = 'BeforeTool',
AfterTool = 'AfterTool',
BeforeAgent = 'BeforeAgent',
Notification = 'Notification',
AfterAgent = 'AfterAgent',
SessionStart = 'SessionStart',
SessionEnd = 'SessionEnd',
PreCompress = 'PreCompress',
BeforeModel = 'BeforeModel',
AfterModel = 'AfterModel',
BeforeToolSelection = 'BeforeToolSelection',
}
/**
* Hook configuration entry
*/
export interface CommandHookConfig {
type: HookType.Command;
command: string;
timeout?: number;
}
export type HookConfig = CommandHookConfig;
/**
* Hook definition with matcher
*/
export interface HookDefinition {
matcher?: string;
sequential?: boolean;
hooks: HookConfig[];
}
/**
* Hook implementation types
*/
export enum HookType {
Command = 'command',
}
export interface ConfigParameters {
sessionId: string;
embeddingModel?: string;
@@ -286,6 +331,10 @@ export interface ConfigParameters {
recordResponses?: string;
ptyInfo?: string;
disableYoloMode?: boolean;
enableHooks?: boolean;
hooks?: {
[K in HookEventName]?: HookDefinition[];
};
}
export class Config {
@@ -388,6 +437,10 @@ export class Config {
readonly fakeResponses?: string;
readonly recordResponses?: string;
private readonly disableYoloMode: boolean;
private readonly enableHooks: boolean;
private readonly hooks:
| { [K in HookEventName]?: HookDefinition[] }
| undefined;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
@@ -482,8 +535,16 @@ export class Config {
this.initialUseModelRouter = params.useModelRouter ?? false;
this.useModelRouter = this.initialUseModelRouter;
this.disableModelRouterForAuth = params.disableModelRouterForAuth ?? [];
this.enableHooks = params.enableHooks ?? false;
// Enable MessageBus integration if:
// 1. Explicitly enabled via setting, OR
// 2. Hooks are enabled and hooks are configured
const hasHooks = params.hooks && Object.keys(params.hooks).length > 0;
const hooksNeedMessageBus = this.enableHooks && hasHooks;
this.enableMessageBusIntegration =
params.enableMessageBusIntegration ?? false;
params.enableMessageBusIntegration ??
(hooksNeedMessageBus ? true : false);
this.codebaseInvestigatorSettings = {
enabled: params.codebaseInvestigatorSettings?.enabled ?? false,
maxNumTurns: params.codebaseInvestigatorSettings?.maxNumTurns ?? 15,
@@ -511,6 +572,7 @@ export class Config {
};
this.retryFetchErrors = params.retryFetchErrors ?? false;
this.disableYoloMode = params.disableYoloMode ?? false;
this.hooks = params.hooks;
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
@@ -1118,6 +1180,10 @@ export class Config {
return this.enableMessageBusIntegration;
}
getEnableHooks(): boolean {
return this.enableHooks;
}
getCodebaseInvestigatorSettings(): CodebaseInvestigatorSettings {
return this.codebaseInvestigatorSettings;
}
@@ -1247,6 +1313,13 @@ export class Config {
await registry.discoverAllTools();
return registry;
}
/**
* Get hooks configuration
*/
getHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined {
return this.hooks;
}
}
// Export model constants for use in CLI
export { DEFAULT_GEMINI_FLASH_MODEL };