mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-06 00:52:45 -07:00
496 lines
15 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|
|
});
|