Files
gemini-cli/packages/core/src/hooks/hookTranslator.test.ts
T

496 lines
15 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
HookTranslatorGenAIv1,
defaultHookTranslator,
type LLMRequest,
type LLMResponse,
type HookToolConfig,
} from './hookTranslator.js';
import type {
GenerateContentParameters,
GenerateContentResponse,
ToolConfig,
ContentListUnion,
} from '@google/genai';
describe('HookTranslator', () => {
let translator: HookTranslatorGenAIv1;
beforeEach(() => {
translator = new HookTranslatorGenAIv1();
});
describe('defaultHookTranslator', () => {
it('should be an instance of HookTranslatorGenAIv1', () => {
expect(defaultHookTranslator).toBeInstanceOf(HookTranslatorGenAIv1);
});
});
describe('LLM Request Translation', () => {
it('should convert SDK request to hook format', () => {
const sdkRequest: GenerateContentParameters = {
model: 'gemini-1.5-flash',
contents: [
{
role: 'user',
parts: [{ text: 'Hello world' }],
},
],
config: {
temperature: 0.7,
maxOutputTokens: 1000,
},
} as unknown as GenerateContentParameters;
const hookRequest = translator.toHookLLMRequest(sdkRequest);
expect(hookRequest).toEqual({
model: 'gemini-1.5-flash',
messages: [
{
role: 'user',
content: 'Hello world',
},
],
config: {
temperature: 0.7,
maxOutputTokens: 1000,
topP: undefined,
topK: undefined,
},
});
});
it('should handle string contents', () => {
const sdkRequest: GenerateContentParameters = {
model: 'gemini-1.5-flash',
contents: ['Simple string message'],
} as unknown as GenerateContentParameters;
const hookRequest = translator.toHookLLMRequest(sdkRequest);
expect(hookRequest.messages).toEqual([
{
role: 'user',
content: 'Simple string message',
},
]);
});
it('should handle conversion errors gracefully', () => {
const sdkRequest: GenerateContentParameters = {
model: 'gemini-1.5-flash',
contents: [null as unknown as ContentListUnion], // Invalid content
} as unknown as GenerateContentParameters;
const hookRequest = translator.toHookLLMRequest(sdkRequest);
// When contents are invalid, the translator skips them and returns empty messages
expect(hookRequest.messages).toEqual([]);
expect(hookRequest.model).toBe('gemini-1.5-flash');
});
it('should convert hook request back to SDK format', () => {
const hookRequest: LLMRequest = {
model: 'gemini-1.5-flash',
messages: [
{
role: 'user',
content: 'Hello world',
},
],
config: {
temperature: 0.7,
maxOutputTokens: 1000,
},
};
const sdkRequest = translator.fromHookLLMRequest(hookRequest);
expect(sdkRequest.model).toBe('gemini-1.5-flash');
expect(sdkRequest.contents).toEqual([
{
role: 'user',
parts: [{ text: 'Hello world' }],
},
]);
});
it('should apply model override when hook returns only model field', () => {
const baseRequest: GenerateContentParameters = {
model: 'gemini-2.5-flash-lite',
contents: [
{
role: 'user',
parts: [{ text: 'Hello' }],
},
],
} as unknown as GenerateContentParameters;
// Simulate a hook that only overrides the model — no messages field
const hookRequest = {
model: 'gemini-2.5-flash',
} as unknown as LLMRequest;
const sdkRequest = translator.fromHookLLMRequest(
hookRequest,
baseRequest,
);
// Model should be overridden
expect(sdkRequest.model).toBe('gemini-2.5-flash');
// Original conversation contents should be preserved
expect(sdkRequest.contents).toEqual(baseRequest.contents);
});
it('should preserve base request contents when hook messages is undefined', () => {
const baseRequest: GenerateContentParameters = {
model: 'gemini-1.5-flash',
contents: [
{ role: 'user', parts: [{ text: 'original message' }] },
{ role: 'model', parts: [{ text: 'original reply' }] },
],
} as unknown as GenerateContentParameters;
const hookRequest = {
model: 'gemini-1.5-pro',
// messages intentionally omitted
} as unknown as LLMRequest;
const sdkRequest = translator.fromHookLLMRequest(
hookRequest,
baseRequest,
);
expect(sdkRequest.model).toBe('gemini-1.5-pro');
expect(sdkRequest.contents).toEqual(baseRequest.contents);
});
});
// Regression tests for https://github.com/google-gemini/gemini-cli/issues/25558
// BeforeModel hooks that modify text in conversations containing tool calls
// were destroying functionCall/functionResponse parts because
// fromHookLLMRequest rebuilt contents text-only. The fix merges hook text
// edits back into baseRequest.contents in place, preserving non-text parts.
describe('fromHookLLMRequest with baseRequest (non-text part preservation)', () => {
it('should preserve functionCall parts when merging hook text back', () => {
const baseRequest = {
model: 'gemini-2.0-flash',
contents: [
{
role: 'user',
parts: [{ text: 'Hello' }],
},
{
role: 'model',
parts: [
{ text: 'Let me check that.' },
{ functionCall: { name: 'search', args: { q: 'test' } } },
],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'search',
response: { results: [] },
},
},
],
},
{
role: 'model',
parts: [{ text: 'No results found.' }],
},
],
} as unknown as GenerateContentParameters;
const hookRequest: LLMRequest = {
model: 'gemini-2.0-flash',
messages: [
{ role: 'user', content: 'Hello [MODIFIED]' },
{ role: 'model', content: 'Let me check that.' },
// contents[2] (functionResponse only) was skipped by toHookLLMRequest
{ role: 'model', content: 'No results found.' },
],
};
const result = translator.fromHookLLMRequest(hookRequest, baseRequest);
const contents = result.contents as Array<{
role: string;
parts: Array<Record<string, unknown>>;
}>;
expect(contents).toHaveLength(4);
// First content: text updated
expect(contents[0].parts[0]['text']).toBe('Hello [MODIFIED]');
// Second content: text updated AND functionCall preserved
expect(contents[1].parts).toHaveLength(2);
expect(contents[1].parts[0]['text']).toBe('Let me check that.');
expect(contents[1].parts[1]['functionCall']).toBeDefined();
// Third content: functionResponse preserved as-is (was skipped)
expect(contents[2].parts[0]['functionResponse']).toBeDefined();
expect(contents[2].parts).toHaveLength(1);
// Fourth content: text updated
expect(contents[3].parts[0]['text']).toBe('No results found.');
});
it('should handle text-only entries interleaved with function-only entries', () => {
const baseRequest = {
model: 'gemini-2.0-flash',
contents: [
{ role: 'user', parts: [{ text: 'Q1' }] },
{
role: 'model',
parts: [{ functionCall: { name: 'tool1', args: {} } }],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'tool1',
response: { ok: true },
},
},
],
},
{ role: 'model', parts: [{ text: 'Answer' }] },
],
} as unknown as GenerateContentParameters;
const hookRequest: LLMRequest = {
model: 'gemini-2.0-flash',
messages: [
{ role: 'user', content: 'Q1-modified' },
// contents[1] and [2] skipped (no text)
{ role: 'model', content: 'Answer-modified' },
],
};
const result = translator.fromHookLLMRequest(hookRequest, baseRequest);
const contents = result.contents as Array<{
role: string;
parts: Array<Record<string, unknown>>;
}>;
expect(contents).toHaveLength(4);
expect(contents[0].parts[0]['text']).toBe('Q1-modified');
expect(contents[1].parts[0]['functionCall']).toBeDefined();
expect(contents[2].parts[0]['functionResponse']).toBeDefined();
expect(contents[3].parts[0]['text']).toBe('Answer-modified');
});
it('should collapse multiple text parts and preserve non-text parts', () => {
const baseRequest = {
model: 'gemini-2.0-flash',
contents: [
{
role: 'model',
parts: [
{ text: 'I will search' },
{ text: ' for you.' },
{ functionCall: { name: 'search', args: {} } },
],
},
],
} as unknown as GenerateContentParameters;
const hookRequest: LLMRequest = {
model: 'gemini-2.0-flash',
messages: [
{ role: 'model', content: 'I will search for you. [BLINDED]' },
],
};
const result = translator.fromHookLLMRequest(hookRequest, baseRequest);
const contents = result.contents as Array<{
role: string;
parts: Array<Record<string, unknown>>;
}>;
expect(contents).toHaveLength(1);
const parts = contents[0].parts;
// Multiple text parts collapsed to one, non-text preserved
expect(parts[0]['text']).toBe('I will search for you. [BLINDED]');
expect(parts[1]['functionCall']).toBeDefined();
expect(parts).toHaveLength(2);
});
it('should fall back to text-only when baseRequest is undefined', () => {
const hookRequest: LLMRequest = {
model: 'gemini-2.0-flash',
messages: [{ role: 'user', content: 'Hello' }],
};
const result = translator.fromHookLLMRequest(hookRequest);
expect(result.contents).toEqual([
{ role: 'user', parts: [{ text: 'Hello' }] },
]);
});
it('should fall back to text-only when baseRequest has no contents', () => {
const hookRequest: LLMRequest = {
model: 'gemini-2.0-flash',
messages: [{ role: 'user', content: 'Hello' }],
};
const baseRequest = {
model: 'gemini-2.0-flash',
} as GenerateContentParameters;
const result = translator.fromHookLLMRequest(hookRequest, baseRequest);
expect(result.contents).toEqual([
{ role: 'user', parts: [{ text: 'Hello' }] },
]);
});
it('should append extra hook messages beyond base contents', () => {
const baseRequest = {
model: 'gemini-2.0-flash',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
} as unknown as GenerateContentParameters;
const hookRequest: LLMRequest = {
model: 'gemini-2.0-flash',
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'model', content: 'Extra message added by hook' },
],
};
const result = translator.fromHookLLMRequest(hookRequest, baseRequest);
const contents = result.contents as Array<{
role: string;
parts: Array<Record<string, unknown>>;
}>;
expect(contents).toHaveLength(2);
expect(contents[1].parts[0]['text']).toBe('Extra message added by hook');
});
});
describe('LLM Response Translation', () => {
it('should convert SDK response to hook format', () => {
const sdkResponse: GenerateContentResponse = {
text: 'Hello response',
candidates: [
{
content: {
role: 'model',
parts: [{ text: 'Hello response' }],
},
finishReason: 'STOP',
index: 0,
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
},
} as unknown as GenerateContentResponse;
const hookResponse = translator.toHookLLMResponse(sdkResponse);
expect(hookResponse).toEqual({
text: 'Hello response',
candidates: [
{
content: {
role: 'model',
parts: ['Hello response'],
},
finishReason: 'STOP',
index: 0,
safetyRatings: undefined,
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
},
});
});
it('should convert hook response back to SDK format', () => {
const hookResponse: LLMResponse = {
text: 'Hello response',
candidates: [
{
content: {
role: 'model',
parts: ['Hello response'],
},
finishReason: 'STOP',
},
],
};
const sdkResponse = translator.fromHookLLMResponse(hookResponse);
expect(sdkResponse.text).toBe('Hello response');
expect(sdkResponse.candidates).toHaveLength(1);
expect(sdkResponse.candidates?.[0]?.content?.parts?.[0]?.text).toBe(
'Hello response',
);
});
});
describe('Tool Config Translation', () => {
it('should convert SDK tool config to hook format', () => {
const sdkToolConfig = {
functionCallingConfig: {
mode: 'ANY',
allowedFunctionNames: ['tool1', 'tool2'],
},
} as unknown as ToolConfig;
const hookToolConfig = translator.toHookToolConfig(sdkToolConfig);
expect(hookToolConfig).toEqual({
mode: 'ANY',
allowedFunctionNames: ['tool1', 'tool2'],
});
});
it('should convert hook tool config back to SDK format', () => {
const hookToolConfig: HookToolConfig = {
mode: 'AUTO',
allowedFunctionNames: ['tool1', 'tool2'],
};
const sdkToolConfig = translator.fromHookToolConfig(hookToolConfig);
expect(sdkToolConfig.functionCallingConfig).toEqual({
mode: 'AUTO',
allowedFunctionNames: ['tool1', 'tool2'],
});
});
it('should handle undefined tool config', () => {
const sdkToolConfig = {} as ToolConfig;
const hookToolConfig = translator.toHookToolConfig(sdkToolConfig);
expect(hookToolConfig).toEqual({
mode: undefined,
allowedFunctionNames: undefined,
});
});
});
});