Skip to content

Commit 8a2e0fa

Browse files
authored
Add other hook wrapper methods to hooksystem (#16361)
1 parent 8656ce8 commit 8a2e0fa

3 files changed

Lines changed: 118 additions & 77 deletions

File tree

packages/core/src/core/client.test.ts

Lines changed: 83 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,8 @@ import type {
4646
ResolvedModelConfig,
4747
} from '../services/modelConfigService.js';
4848
import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
49-
import { HookSystem } from '../hooks/hookSystem.js';
50-
import type { DefaultHookOutput } from '../hooks/types.js';
5149
import * as policyCatalog from '../availability/policyCatalog.js';
50+
import { partToString } from '../utils/partUtils.js';
5251

5352
vi.mock('../services/chatCompressionService.js');
5453

@@ -137,15 +136,22 @@ vi.mock('../telemetry/uiTelemetry.js', () => ({
137136
},
138137
}));
139138
vi.mock('../hooks/hookSystem.js');
140-
vi.mock('./clientHookTriggers.js', () => ({
141-
fireBeforeAgentHook: vi.fn(),
142-
fireAfterAgentHook: vi.fn().mockResolvedValue({
143-
decision: 'allow',
144-
continue: false,
145-
suppressOutput: false,
146-
systemMessage: undefined,
139+
const mockHookSystem = {
140+
fireBeforeAgentEvent: vi.fn().mockResolvedValue({
141+
success: true,
142+
finalOutput: undefined,
143+
allOutputs: [],
144+
errors: [],
145+
totalDuration: 0,
147146
}),
148-
}));
147+
fireAfterAgentEvent: vi.fn().mockResolvedValue({
148+
success: true,
149+
finalOutput: undefined,
150+
allOutputs: [],
151+
errors: [],
152+
totalDuration: 0,
153+
}),
154+
};
149155

150156
/**
151157
* Array.fromAsync ponyfill, which will be available in es 2024.
@@ -286,9 +292,7 @@ describe('Gemini Client (client.ts)', () => {
286292
.fn()
287293
.mockReturnValue(createAvailabilityServiceMock()),
288294
} as unknown as Config;
289-
mockConfig.getHookSystem = vi
290-
.fn()
291-
.mockReturnValue(new HookSystem(mockConfig));
295+
mockConfig.getHookSystem = vi.fn().mockReturnValue(mockHookSystem);
292296

293297
client = new GeminiClient(mockConfig);
294298
await client.initialize();
@@ -2688,9 +2692,6 @@ ${JSON.stringify(
26882692
const promptId = 'test-prompt-hook-1';
26892693
const request = { text: 'Hello Hooks' };
26902694
const signal = new AbortController().signal;
2691-
const { fireBeforeAgentHook, fireAfterAgentHook } = await import(
2692-
'./clientHookTriggers.js'
2693-
);
26942695

26952696
mockTurnRunFn.mockImplementation(async function* (
26962697
this: MockTurnContext,
@@ -2702,11 +2703,10 @@ ${JSON.stringify(
27022703
const stream = client.sendMessageStream(request, signal, promptId);
27032704
while (!(await stream.next()).done);
27042705

2705-
expect(fireBeforeAgentHook).toHaveBeenCalledTimes(1);
2706-
expect(fireAfterAgentHook).toHaveBeenCalledTimes(1);
2707-
expect(fireAfterAgentHook).toHaveBeenCalledWith(
2708-
expect.anything(),
2709-
request,
2706+
expect(mockHookSystem.fireBeforeAgentEvent).toHaveBeenCalledTimes(1);
2707+
expect(mockHookSystem.fireAfterAgentEvent).toHaveBeenCalledTimes(1);
2708+
expect(mockHookSystem.fireAfterAgentEvent).toHaveBeenCalledWith(
2709+
partToString(request),
27102710
'Hook Response',
27112711
);
27122712

@@ -2725,9 +2725,6 @@ ${JSON.stringify(
27252725
const promptId = 'test-prompt-hook-recursive';
27262726
const request = { text: 'Recursion Test' };
27272727
const signal = new AbortController().signal;
2728-
const { fireBeforeAgentHook, fireAfterAgentHook } = await import(
2729-
'./clientHookTriggers.js'
2730-
);
27312728

27322729
let callCount = 0;
27332730
mockTurnRunFn.mockImplementation(async function* (
@@ -2743,15 +2740,14 @@ ${JSON.stringify(
27432740
while (!(await stream.next()).done);
27442741

27452742
// BeforeAgent should fire ONLY once despite multiple internal turns
2746-
expect(fireBeforeAgentHook).toHaveBeenCalledTimes(1);
2743+
expect(mockHookSystem.fireBeforeAgentEvent).toHaveBeenCalledTimes(1);
27472744

27482745
// AfterAgent should fire ONLY when the stack unwinds
2749-
expect(fireAfterAgentHook).toHaveBeenCalledTimes(1);
2746+
expect(mockHookSystem.fireAfterAgentEvent).toHaveBeenCalledTimes(1);
27502747

27512748
// Check cumulative response (separated by newline)
2752-
expect(fireAfterAgentHook).toHaveBeenCalledWith(
2753-
expect.anything(),
2754-
request,
2749+
expect(mockHookSystem.fireAfterAgentEvent).toHaveBeenCalledWith(
2750+
partToString(request),
27552751
'Response 1\nResponse 2',
27562752
);
27572753

@@ -2769,7 +2765,6 @@ ${JSON.stringify(
27692765
const promptId = 'test-prompt-hook-original-req';
27702766
const request = { text: 'Do something' };
27712767
const signal = new AbortController().signal;
2772-
const { fireAfterAgentHook } = await import('./clientHookTriggers.js');
27732768

27742769
mockTurnRunFn.mockImplementation(async function* (
27752770
this: MockTurnContext,
@@ -2781,9 +2776,8 @@ ${JSON.stringify(
27812776
const stream = client.sendMessageStream(request, signal, promptId);
27822777
while (!(await stream.next()).done);
27832778

2784-
expect(fireAfterAgentHook).toHaveBeenCalledWith(
2785-
expect.anything(),
2786-
request, // Should be 'Do something'
2779+
expect(mockHookSystem.fireAfterAgentEvent).toHaveBeenCalledWith(
2780+
partToString(request), // Should be 'Do something'
27872781
expect.stringContaining('Ok'),
27882782
);
27892783
});
@@ -2817,11 +2811,17 @@ ${JSON.stringify(
28172811
});
28182812

28192813
it('should stop execution in BeforeAgent when hook returns continue: false', async () => {
2820-
const { fireBeforeAgentHook } = await import('./clientHookTriggers.js');
2821-
vi.mocked(fireBeforeAgentHook).mockResolvedValue({
2822-
shouldStopExecution: () => true,
2823-
getEffectiveReason: () => 'Stopped by hook',
2824-
} as DefaultHookOutput);
2814+
mockHookSystem.fireBeforeAgentEvent.mockResolvedValue({
2815+
success: true,
2816+
finalOutput: {
2817+
shouldStopExecution: () => true,
2818+
getEffectiveReason: () => 'Stopped by hook',
2819+
systemMessage: undefined,
2820+
},
2821+
allOutputs: [],
2822+
errors: [],
2823+
totalDuration: 0,
2824+
});
28252825

28262826
const mockChat: Partial<GeminiChat> = {
28272827
addHistory: vi.fn(),
@@ -2850,12 +2850,18 @@ ${JSON.stringify(
28502850
});
28512851

28522852
it('should block execution in BeforeAgent when hook returns decision: block', async () => {
2853-
const { fireBeforeAgentHook } = await import('./clientHookTriggers.js');
2854-
vi.mocked(fireBeforeAgentHook).mockResolvedValue({
2855-
shouldStopExecution: () => false,
2856-
isBlockingDecision: () => true,
2857-
getEffectiveReason: () => 'Blocked by hook',
2858-
} as DefaultHookOutput);
2853+
mockHookSystem.fireBeforeAgentEvent.mockResolvedValue({
2854+
success: true,
2855+
finalOutput: {
2856+
shouldStopExecution: () => false,
2857+
isBlockingDecision: () => true,
2858+
getEffectiveReason: () => 'Blocked by hook',
2859+
systemMessage: undefined,
2860+
},
2861+
allOutputs: [],
2862+
errors: [],
2863+
totalDuration: 0,
2864+
});
28592865

28602866
const mockChat: Partial<GeminiChat> = {
28612867
addHistory: vi.fn(),
@@ -2883,11 +2889,17 @@ ${JSON.stringify(
28832889
});
28842890

28852891
it('should stop execution in AfterAgent when hook returns continue: false', async () => {
2886-
const { fireAfterAgentHook } = await import('./clientHookTriggers.js');
2887-
vi.mocked(fireAfterAgentHook).mockResolvedValue({
2888-
shouldStopExecution: () => true,
2889-
getEffectiveReason: () => 'Stopped after agent',
2890-
} as DefaultHookOutput);
2892+
mockHookSystem.fireAfterAgentEvent.mockResolvedValue({
2893+
success: true,
2894+
finalOutput: {
2895+
shouldStopExecution: () => true,
2896+
getEffectiveReason: () => 'Stopped after agent',
2897+
systemMessage: undefined,
2898+
},
2899+
allOutputs: [],
2900+
errors: [],
2901+
totalDuration: 0,
2902+
});
28912903

28922904
mockTurnRunFn.mockImplementation(async function* () {
28932905
yield { type: GeminiEventType.Content, value: 'Hello' };
@@ -2909,17 +2921,30 @@ ${JSON.stringify(
29092921
});
29102922

29112923
it('should yield AgentExecutionBlocked and recurse in AfterAgent when hook returns decision: block', async () => {
2912-
const { fireAfterAgentHook } = await import('./clientHookTriggers.js');
2913-
vi.mocked(fireAfterAgentHook)
2924+
mockHookSystem.fireAfterAgentEvent
29142925
.mockResolvedValueOnce({
2915-
shouldStopExecution: () => false,
2916-
isBlockingDecision: () => true,
2917-
getEffectiveReason: () => 'Please explain',
2918-
} as DefaultHookOutput)
2926+
success: true,
2927+
finalOutput: {
2928+
shouldStopExecution: () => false,
2929+
isBlockingDecision: () => true,
2930+
getEffectiveReason: () => 'Please explain',
2931+
systemMessage: undefined,
2932+
},
2933+
allOutputs: [],
2934+
errors: [],
2935+
totalDuration: 0,
2936+
})
29192937
.mockResolvedValueOnce({
2920-
shouldStopExecution: () => false,
2921-
isBlockingDecision: () => false,
2922-
} as DefaultHookOutput);
2938+
success: true,
2939+
finalOutput: {
2940+
shouldStopExecution: () => false,
2941+
isBlockingDecision: () => false,
2942+
systemMessage: undefined,
2943+
},
2944+
allOutputs: [],
2945+
errors: [],
2946+
totalDuration: 0,
2947+
});
29232948

29242949
mockTurnRunFn.mockImplementation(async function* () {
29252950
yield { type: GeminiEventType.Content, value: 'Response' };

packages/core/src/core/client.ts

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import type {
1212
GenerateContentResponse,
1313
} from '@google/genai';
1414
import { createUserContent } from '@google/genai';
15-
import type { MessageBus } from '../confirmation-bus/message-bus.js';
1615
import {
1716
getDirectoryContextString,
1817
getInitialChatHistory,
@@ -40,10 +39,6 @@ import {
4039
logContentRetryFailure,
4140
logNextSpeakerCheck,
4241
} from '../telemetry/loggers.js';
43-
import {
44-
fireBeforeAgentHook,
45-
fireAfterAgentHook,
46-
} from './clientHookTriggers.js';
4742
import type { DefaultHookOutput } from '../hooks/types.js';
4843
import {
4944
ContentRetryFailureEvent,
@@ -62,6 +57,7 @@ import {
6257
} from '../availability/policyHelpers.js';
6358
import { resolveModel } from '../config/models.js';
6459
import type { RetryAvailabilityContext } from '../utils/retry.js';
60+
import { partToString } from '../utils/partUtils.js';
6561

6662
const MAX_TURNS = 100;
6763

@@ -113,7 +109,6 @@ export class GeminiClient {
113109
>();
114110

115111
private async fireBeforeAgentHookSafe(
116-
messageBus: MessageBus,
117112
request: PartListUnion,
118113
prompt_id: string,
119114
): Promise<BeforeAgentHookReturn> {
@@ -138,7 +133,10 @@ export class GeminiClient {
138133
return undefined;
139134
}
140135

141-
const hookOutput = await fireBeforeAgentHook(messageBus, request);
136+
const hookResult = await this.config
137+
.getHookSystem()
138+
?.fireBeforeAgentEvent(partToString(request));
139+
const hookOutput = hookResult?.finalOutput;
142140
hookState.hasFiredBeforeAgent = true;
143141

144142
if (hookOutput?.shouldStopExecution()) {
@@ -169,7 +167,6 @@ export class GeminiClient {
169167
}
170168

171169
private async fireAfterAgentHookSafe(
172-
messageBus: MessageBus,
173170
currentRequest: PartListUnion,
174171
prompt_id: string,
175172
turn?: Turn,
@@ -190,11 +187,11 @@ export class GeminiClient {
190187
'[no response text]';
191188
const finalRequest = hookState.originalRequest || currentRequest;
192189

193-
const hookOutput = await fireAfterAgentHook(
194-
messageBus,
195-
finalRequest,
196-
finalResponseText,
197-
);
190+
const hookResult = await this.config
191+
.getHookSystem()
192+
?.fireAfterAgentEvent(partToString(finalRequest), finalResponseText);
193+
const hookOutput = hookResult?.finalOutput;
194+
198195
return hookOutput;
199196
}
200197

@@ -757,11 +754,7 @@ export class GeminiClient {
757754
}
758755

759756
if (hooksEnabled && messageBus) {
760-
const hookResult = await this.fireBeforeAgentHookSafe(
761-
messageBus,
762-
request,
763-
prompt_id,
764-
);
757+
const hookResult = await this.fireBeforeAgentHookSafe(request, prompt_id);
765758
if (hookResult) {
766759
if (
767760
'type' in hookResult &&
@@ -802,7 +795,6 @@ export class GeminiClient {
802795
// Fire AfterAgent hook if we have a turn and no pending tools
803796
if (hooksEnabled && messageBus) {
804797
const hookOutput = await this.fireAfterAgentHookSafe(
805-
messageBus,
806798
request,
807799
prompt_id,
808800
turn,

packages/core/src/hooks/hookSystem.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,28 @@ export class HookSystem {
117117
}
118118
return this.hookEventHandler.firePreCompressEvent(trigger);
119119
}
120+
121+
async fireBeforeAgentEvent(
122+
prompt: string,
123+
): Promise<AggregatedHookResult | undefined> {
124+
if (!this.config.getEnableHooks()) {
125+
return undefined;
126+
}
127+
return this.hookEventHandler.fireBeforeAgentEvent(prompt);
128+
}
129+
130+
async fireAfterAgentEvent(
131+
prompt: string,
132+
response: string,
133+
stopHookActive: boolean = false,
134+
): Promise<AggregatedHookResult | undefined> {
135+
if (!this.config.getEnableHooks()) {
136+
return undefined;
137+
}
138+
return this.hookEventHandler.fireAfterAgentEvent(
139+
prompt,
140+
response,
141+
stopHookActive,
142+
);
143+
}
120144
}

0 commit comments

Comments
 (0)