@@ -28,6 +28,18 @@ import { createAvailabilityServiceMock } from '../availability/testUtils.js';
2828import type { ModelAvailabilityService } from '../availability/modelAvailabilityService.js' ;
2929import * as policyHelpers from '../availability/policyHelpers.js' ;
3030import { makeResolvedModelConfig } from '../services/modelConfigServiceTestUtils.js' ;
31+ import {
32+ fireBeforeModelHook ,
33+ fireAfterModelHook ,
34+ fireBeforeToolSelectionHook ,
35+ } from './geminiChatHookTriggers.js' ;
36+
37+ // Mock hook triggers
38+ vi . mock ( './geminiChatHookTriggers.js' , ( ) => ( {
39+ fireBeforeModelHook : vi . fn ( ) ,
40+ fireAfterModelHook : vi . fn ( ) ,
41+ fireBeforeToolSelectionHook : vi . fn ( ) . mockResolvedValue ( { } ) ,
42+ } ) ) ;
3143
3244// Mock fs module to prevent actual file system operations during tests
3345const mockFileSystem = new Map < string , string > ( ) ;
@@ -2269,4 +2281,151 @@ describe('GeminiChat', () => {
22692281 ) ;
22702282 } ) ;
22712283 } ) ;
2284+
2285+ describe ( 'Hook execution control' , ( ) => {
2286+ beforeEach ( ( ) => {
2287+ vi . mocked ( mockConfig . getEnableHooks ) . mockReturnValue ( true ) ;
2288+ // Default to allowing execution
2289+ vi . mocked ( fireBeforeModelHook ) . mockResolvedValue ( { blocked : false } ) ;
2290+ vi . mocked ( fireAfterModelHook ) . mockResolvedValue ( {
2291+ response : { } as GenerateContentResponse ,
2292+ } ) ;
2293+ vi . mocked ( fireBeforeToolSelectionHook ) . mockResolvedValue ( { } ) ;
2294+ } ) ;
2295+
2296+ it ( 'should yield AGENT_EXECUTION_STOPPED when BeforeModel hook stops execution' , async ( ) => {
2297+ vi . mocked ( fireBeforeModelHook ) . mockResolvedValue ( {
2298+ blocked : true ,
2299+ stopped : true ,
2300+ reason : 'stopped by hook' ,
2301+ } ) ;
2302+
2303+ const stream = await chat . sendMessageStream (
2304+ { model : 'gemini-pro' } ,
2305+ 'test' ,
2306+ 'prompt-id' ,
2307+ new AbortController ( ) . signal ,
2308+ ) ;
2309+
2310+ const events : StreamEvent [ ] = [ ] ;
2311+ for await ( const event of stream ) {
2312+ events . push ( event ) ;
2313+ }
2314+
2315+ expect ( events ) . toHaveLength ( 1 ) ;
2316+ expect ( events [ 0 ] ) . toEqual ( {
2317+ type : StreamEventType . AGENT_EXECUTION_STOPPED ,
2318+ reason : 'stopped by hook' ,
2319+ } ) ;
2320+ } ) ;
2321+
2322+ it ( 'should yield AGENT_EXECUTION_BLOCKED and synthetic response when BeforeModel hook blocks execution' , async ( ) => {
2323+ const syntheticResponse = {
2324+ candidates : [ { content : { parts : [ { text : 'blocked' } ] } } ] ,
2325+ } as GenerateContentResponse ;
2326+
2327+ vi . mocked ( fireBeforeModelHook ) . mockResolvedValue ( {
2328+ blocked : true ,
2329+ reason : 'blocked by hook' ,
2330+ syntheticResponse,
2331+ } ) ;
2332+
2333+ const stream = await chat . sendMessageStream (
2334+ { model : 'gemini-pro' } ,
2335+ 'test' ,
2336+ 'prompt-id' ,
2337+ new AbortController ( ) . signal ,
2338+ ) ;
2339+
2340+ const events : StreamEvent [ ] = [ ] ;
2341+ for await ( const event of stream ) {
2342+ events . push ( event ) ;
2343+ }
2344+
2345+ expect ( events ) . toHaveLength ( 2 ) ;
2346+ expect ( events [ 0 ] ) . toEqual ( {
2347+ type : StreamEventType . AGENT_EXECUTION_BLOCKED ,
2348+ reason : 'blocked by hook' ,
2349+ } ) ;
2350+ expect ( events [ 1 ] ) . toEqual ( {
2351+ type : StreamEventType . CHUNK ,
2352+ value : syntheticResponse ,
2353+ } ) ;
2354+ } ) ;
2355+
2356+ it ( 'should yield AGENT_EXECUTION_STOPPED when AfterModel hook stops execution' , async ( ) => {
2357+ // Mock content generator to return a stream
2358+ vi . mocked ( mockContentGenerator . generateContentStream ) . mockResolvedValue (
2359+ ( async function * ( ) {
2360+ yield {
2361+ candidates : [ { content : { parts : [ { text : 'response' } ] } } ] ,
2362+ } as unknown as GenerateContentResponse ;
2363+ } ) ( ) ,
2364+ ) ;
2365+
2366+ vi . mocked ( fireAfterModelHook ) . mockResolvedValue ( {
2367+ response : { } as GenerateContentResponse ,
2368+ stopped : true ,
2369+ reason : 'stopped by after hook' ,
2370+ } ) ;
2371+
2372+ const stream = await chat . sendMessageStream (
2373+ { model : 'gemini-pro' } ,
2374+ 'test' ,
2375+ 'prompt-id' ,
2376+ new AbortController ( ) . signal ,
2377+ ) ;
2378+
2379+ const events : StreamEvent [ ] = [ ] ;
2380+ for await ( const event of stream ) {
2381+ events . push ( event ) ;
2382+ }
2383+
2384+ expect ( events ) . toContainEqual ( {
2385+ type : StreamEventType . AGENT_EXECUTION_STOPPED ,
2386+ reason : 'stopped by after hook' ,
2387+ } ) ;
2388+ } ) ;
2389+
2390+ it ( 'should yield AGENT_EXECUTION_BLOCKED and response when AfterModel hook blocks execution' , async ( ) => {
2391+ const response = {
2392+ candidates : [ { content : { parts : [ { text : 'response' } ] } } ] ,
2393+ } as unknown as GenerateContentResponse ;
2394+
2395+ // Mock content generator to return a stream
2396+ vi . mocked ( mockContentGenerator . generateContentStream ) . mockResolvedValue (
2397+ ( async function * ( ) {
2398+ yield response ;
2399+ } ) ( ) ,
2400+ ) ;
2401+
2402+ vi . mocked ( fireAfterModelHook ) . mockResolvedValue ( {
2403+ response,
2404+ blocked : true ,
2405+ reason : 'blocked by after hook' ,
2406+ } ) ;
2407+
2408+ const stream = await chat . sendMessageStream (
2409+ { model : 'gemini-pro' } ,
2410+ 'test' ,
2411+ 'prompt-id' ,
2412+ new AbortController ( ) . signal ,
2413+ ) ;
2414+
2415+ const events : StreamEvent [ ] = [ ] ;
2416+ for await ( const event of stream ) {
2417+ events . push ( event ) ;
2418+ }
2419+
2420+ expect ( events ) . toContainEqual ( {
2421+ type : StreamEventType . AGENT_EXECUTION_BLOCKED ,
2422+ reason : 'blocked by after hook' ,
2423+ } ) ;
2424+ // Should also contain the chunk (hook response)
2425+ expect ( events ) . toContainEqual ( {
2426+ type : StreamEventType . CHUNK ,
2427+ value : response ,
2428+ } ) ;
2429+ } ) ;
2430+ } ) ;
22722431} ) ;
0 commit comments