Download packages/core/src/hooks/hookTranslator.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 15 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/hooks/hookTranslator.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/hooks/hookTranslator.test.ts
-
curl -L -o hookTranslator.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/hooks/hookTranslator.test.ts
15 kB
| /** | |
| * @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, | |
| }); | |
| }); | |
| }); | |
| }); | |