mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 13:34:15 -07:00
Increase code coverage for core packages (#12872)
This commit is contained in:
@@ -4,8 +4,40 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HookEventName, HookType } from './types.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
createHookOutput,
|
||||
DefaultHookOutput,
|
||||
BeforeToolHookOutput,
|
||||
BeforeModelHookOutput,
|
||||
BeforeToolSelectionHookOutput,
|
||||
AfterModelHookOutput,
|
||||
HookEventName,
|
||||
HookType,
|
||||
} from './types.js';
|
||||
import { defaultHookTranslator } from './hookTranslator.js';
|
||||
import type {
|
||||
GenerateContentParameters,
|
||||
GenerateContentResponse,
|
||||
ToolConfig,
|
||||
} from '@google/genai';
|
||||
import type { LLMRequest, LLMResponse } from './hookTranslator.js';
|
||||
import type { HookDecision } from './types.js';
|
||||
|
||||
vi.mock('./hookTranslator.js', () => ({
|
||||
defaultHookTranslator: {
|
||||
fromHookLLMResponse: vi.fn(
|
||||
(response: LLMResponse) => response as unknown as GenerateContentResponse,
|
||||
),
|
||||
fromHookLLMRequest: vi.fn(
|
||||
(request: LLMRequest, target: GenerateContentParameters) => ({
|
||||
...target,
|
||||
...request,
|
||||
}),
|
||||
),
|
||||
fromHookToolConfig: vi.fn((config: ToolConfig) => config),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Hook Types', () => {
|
||||
describe('HookEventName', () => {
|
||||
@@ -36,3 +68,318 @@ describe('Hook Types', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Output Classes', () => {
|
||||
describe('createHookOutput', () => {
|
||||
it('should return DefaultHookOutput for unknown event names', () => {
|
||||
const output = createHookOutput('UnknownEvent', {});
|
||||
expect(output).toBeInstanceOf(DefaultHookOutput);
|
||||
expect(output).not.toBeInstanceOf(BeforeModelHookOutput);
|
||||
expect(output).not.toBeInstanceOf(AfterModelHookOutput);
|
||||
expect(output).not.toBeInstanceOf(BeforeToolSelectionHookOutput);
|
||||
});
|
||||
|
||||
it('should return BeforeModelHookOutput for BeforeModel event', () => {
|
||||
const output = createHookOutput(HookEventName.BeforeModel, {});
|
||||
expect(output).toBeInstanceOf(BeforeModelHookOutput);
|
||||
});
|
||||
|
||||
it('should return AfterModelHookOutput for AfterModel event', () => {
|
||||
const output = createHookOutput(HookEventName.AfterModel, {});
|
||||
expect(output).toBeInstanceOf(AfterModelHookOutput);
|
||||
});
|
||||
|
||||
it('should return BeforeToolSelectionHookOutput for BeforeToolSelection event', () => {
|
||||
const output = createHookOutput(HookEventName.BeforeToolSelection, {});
|
||||
expect(output).toBeInstanceOf(BeforeToolSelectionHookOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DefaultHookOutput', () => {
|
||||
it('should construct with provided data', () => {
|
||||
const data = {
|
||||
continue: false,
|
||||
stopReason: 'test stop',
|
||||
suppressOutput: true,
|
||||
systemMessage: 'test system message',
|
||||
decision: 'block' as HookDecision,
|
||||
reason: 'test reason',
|
||||
hookSpecificOutput: { key: 'value' },
|
||||
};
|
||||
const output = new DefaultHookOutput(data);
|
||||
expect(output.continue).toBe(data.continue);
|
||||
expect(output.stopReason).toBe(data.stopReason);
|
||||
expect(output.suppressOutput).toBe(data.suppressOutput);
|
||||
expect(output.systemMessage).toBe(data.systemMessage);
|
||||
expect(output.decision).toBe(data.decision);
|
||||
expect(output.reason).toBe(data.reason);
|
||||
expect(output.hookSpecificOutput).toEqual(data.hookSpecificOutput);
|
||||
});
|
||||
|
||||
it('should return false for isBlockingDecision if decision is not block or deny', () => {
|
||||
const output1 = new DefaultHookOutput({ decision: 'approve' });
|
||||
expect(output1.isBlockingDecision()).toBe(false);
|
||||
const output2 = new DefaultHookOutput({ decision: undefined });
|
||||
expect(output2.isBlockingDecision()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for isBlockingDecision if decision is block or deny', () => {
|
||||
const output1 = new DefaultHookOutput({ decision: 'block' });
|
||||
expect(output1.isBlockingDecision()).toBe(true);
|
||||
const output2 = new DefaultHookOutput({ decision: 'deny' });
|
||||
expect(output2.isBlockingDecision()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for shouldStopExecution if continue is false', () => {
|
||||
const output = new DefaultHookOutput({ continue: false });
|
||||
expect(output.shouldStopExecution()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for shouldStopExecution if continue is true or undefined', () => {
|
||||
const output1 = new DefaultHookOutput({ continue: true });
|
||||
expect(output1.shouldStopExecution()).toBe(false);
|
||||
const output2 = new DefaultHookOutput({});
|
||||
expect(output2.shouldStopExecution()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return reason if available', () => {
|
||||
const output = new DefaultHookOutput({ reason: 'specific reason' });
|
||||
expect(output.getEffectiveReason()).toBe('specific reason');
|
||||
});
|
||||
|
||||
it('should return stopReason if reason is not available', () => {
|
||||
const output = new DefaultHookOutput({ stopReason: 'stop reason' });
|
||||
expect(output.getEffectiveReason()).toBe('stop reason');
|
||||
});
|
||||
|
||||
it('should return "No reason provided" if neither reason nor stopReason are available', () => {
|
||||
const output = new DefaultHookOutput({});
|
||||
expect(output.getEffectiveReason()).toBe('No reason provided');
|
||||
});
|
||||
|
||||
it('applyLLMRequestModifications should return target unchanged', () => {
|
||||
const target: GenerateContentParameters = {
|
||||
model: 'gemini-pro',
|
||||
contents: [],
|
||||
};
|
||||
const output = new DefaultHookOutput({});
|
||||
expect(output.applyLLMRequestModifications(target)).toBe(target);
|
||||
});
|
||||
|
||||
it('applyToolConfigModifications should return target unchanged', () => {
|
||||
const target = { toolConfig: {}, tools: [] };
|
||||
const output = new DefaultHookOutput({});
|
||||
expect(output.applyToolConfigModifications(target)).toBe(target);
|
||||
});
|
||||
|
||||
it('getAdditionalContext should return additionalContext if present', () => {
|
||||
const output = new DefaultHookOutput({
|
||||
hookSpecificOutput: { additionalContext: 'some context' },
|
||||
});
|
||||
expect(output.getAdditionalContext()).toBe('some context');
|
||||
});
|
||||
|
||||
it('getAdditionalContext should return undefined if additionalContext is not present', () => {
|
||||
const output = new DefaultHookOutput({
|
||||
hookSpecificOutput: { other: 'value' },
|
||||
});
|
||||
expect(output.getAdditionalContext()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getAdditionalContext should return undefined if hookSpecificOutput is undefined', () => {
|
||||
const output = new DefaultHookOutput({});
|
||||
expect(output.getAdditionalContext()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getBlockingError should return blocked: true and reason if blocking decision', () => {
|
||||
const output = new DefaultHookOutput({
|
||||
decision: 'block',
|
||||
reason: 'blocked by hook',
|
||||
});
|
||||
expect(output.getBlockingError()).toEqual({
|
||||
blocked: true,
|
||||
reason: 'blocked by hook',
|
||||
});
|
||||
});
|
||||
|
||||
it('getBlockingError should return blocked: false if not blocking decision', () => {
|
||||
const output = new DefaultHookOutput({ decision: 'approve' });
|
||||
expect(output.getBlockingError()).toEqual({ blocked: false, reason: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('BeforeToolHookOutput', () => {
|
||||
it('isBlockingDecision should use permissionDecision from hookSpecificOutput', () => {
|
||||
const output1 = new BeforeToolHookOutput({
|
||||
hookSpecificOutput: { permissionDecision: 'block' },
|
||||
});
|
||||
expect(output1.isBlockingDecision()).toBe(true);
|
||||
|
||||
const output2 = new BeforeToolHookOutput({
|
||||
hookSpecificOutput: { permissionDecision: 'approve' },
|
||||
});
|
||||
expect(output2.isBlockingDecision()).toBe(false);
|
||||
});
|
||||
|
||||
it('getEffectiveReason should use permissionDecisionReason from hookSpecificOutput', () => {
|
||||
const output1 = new BeforeToolHookOutput({
|
||||
hookSpecificOutput: { permissionDecisionReason: 'compat reason' },
|
||||
});
|
||||
expect(output1.getEffectiveReason()).toBe('compat reason');
|
||||
|
||||
const output2 = new BeforeToolHookOutput({
|
||||
reason: 'default reason',
|
||||
hookSpecificOutput: { other: 'value' },
|
||||
});
|
||||
expect(output2.getEffectiveReason()).toBe('default reason');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BeforeModelHookOutput', () => {
|
||||
it('getSyntheticResponse should return synthetic response if llm_response is present', () => {
|
||||
const mockResponse: LLMResponse = { candidates: [] };
|
||||
const output = new BeforeModelHookOutput({
|
||||
hookSpecificOutput: { llm_response: mockResponse },
|
||||
});
|
||||
expect(output.getSyntheticResponse()).toEqual(mockResponse);
|
||||
expect(defaultHookTranslator.fromHookLLMResponse).toHaveBeenCalledWith(
|
||||
mockResponse,
|
||||
);
|
||||
});
|
||||
|
||||
it('getSyntheticResponse should return undefined if llm_response is not present', () => {
|
||||
const output = new BeforeModelHookOutput({});
|
||||
expect(output.getSyntheticResponse()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applyLLMRequestModifications should apply modifications if llm_request is present', () => {
|
||||
const target: GenerateContentParameters = {
|
||||
model: 'gemini-pro',
|
||||
contents: [{ parts: [{ text: 'original' }] }],
|
||||
};
|
||||
const mockRequest: Partial<LLMRequest> = {
|
||||
messages: [{ role: 'user', content: 'modified' }],
|
||||
};
|
||||
const output = new BeforeModelHookOutput({
|
||||
hookSpecificOutput: { llm_request: mockRequest },
|
||||
});
|
||||
const result = output.applyLLMRequestModifications(target);
|
||||
expect(result).toEqual({ ...target, ...mockRequest });
|
||||
expect(defaultHookTranslator.fromHookLLMRequest).toHaveBeenCalledWith(
|
||||
mockRequest,
|
||||
target,
|
||||
);
|
||||
});
|
||||
|
||||
it('applyLLMRequestModifications should return target unchanged if llm_request is not present', () => {
|
||||
const target: GenerateContentParameters = {
|
||||
model: 'gemini-pro',
|
||||
contents: [],
|
||||
};
|
||||
const output = new BeforeModelHookOutput({});
|
||||
expect(output.applyLLMRequestModifications(target)).toBe(target);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BeforeToolSelectionHookOutput', () => {
|
||||
it('applyToolConfigModifications should apply modifications if toolConfig is present', () => {
|
||||
const target = { tools: [{ functionDeclarations: [] }] };
|
||||
const mockToolConfig = { functionCallingConfig: { mode: 'ANY' } };
|
||||
const output = new BeforeToolSelectionHookOutput({
|
||||
hookSpecificOutput: { toolConfig: mockToolConfig },
|
||||
});
|
||||
const result = output.applyToolConfigModifications(target);
|
||||
expect(result).toEqual({ ...target, toolConfig: mockToolConfig });
|
||||
expect(defaultHookTranslator.fromHookToolConfig).toHaveBeenCalledWith(
|
||||
mockToolConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('applyToolConfigModifications should return target unchanged if toolConfig is not present', () => {
|
||||
const target = { toolConfig: {}, tools: [] };
|
||||
const output = new BeforeToolSelectionHookOutput({});
|
||||
expect(output.applyToolConfigModifications(target)).toBe(target);
|
||||
});
|
||||
|
||||
it('applyToolConfigModifications should initialize tools array if not present', () => {
|
||||
const target = {};
|
||||
const mockToolConfig = { functionCallingConfig: { mode: 'ANY' } };
|
||||
const output = new BeforeToolSelectionHookOutput({
|
||||
hookSpecificOutput: { toolConfig: mockToolConfig },
|
||||
});
|
||||
const result = output.applyToolConfigModifications(target);
|
||||
expect(result).toEqual({ tools: [], toolConfig: mockToolConfig });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AfterModelHookOutput', () => {
|
||||
it('getModifiedResponse should return modified response if llm_response is present and has content', () => {
|
||||
const mockResponse: LLMResponse = {
|
||||
candidates: [{ content: { role: 'model', parts: ['modified'] } }],
|
||||
};
|
||||
const output = new AfterModelHookOutput({
|
||||
hookSpecificOutput: { llm_response: mockResponse },
|
||||
});
|
||||
expect(output.getModifiedResponse()).toEqual(mockResponse);
|
||||
expect(defaultHookTranslator.fromHookLLMResponse).toHaveBeenCalledWith(
|
||||
mockResponse,
|
||||
);
|
||||
});
|
||||
|
||||
it('getModifiedResponse should return undefined if llm_response is present but no content', () => {
|
||||
const mockResponse: LLMResponse = {
|
||||
candidates: [{ content: { role: 'model', parts: [] } }],
|
||||
};
|
||||
const output = new AfterModelHookOutput({
|
||||
hookSpecificOutput: { llm_response: mockResponse },
|
||||
});
|
||||
expect(output.getModifiedResponse()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getModifiedResponse should return undefined if llm_response is not present', () => {
|
||||
const output = new AfterModelHookOutput({});
|
||||
expect(output.getModifiedResponse()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getModifiedResponse should return a synthetic stop response if shouldStopExecution is true', () => {
|
||||
const output = new AfterModelHookOutput({
|
||||
continue: false,
|
||||
stopReason: 'stopped by hook',
|
||||
});
|
||||
const expectedResponse: LLMResponse = {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: ['stopped by hook'],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(output.getModifiedResponse()).toEqual(expectedResponse);
|
||||
expect(defaultHookTranslator.fromHookLLMResponse).toHaveBeenCalledWith(
|
||||
expectedResponse,
|
||||
);
|
||||
});
|
||||
|
||||
it('getModifiedResponse should return a synthetic stop response with default reason if shouldStopExecution is true and no stopReason', () => {
|
||||
const output = new AfterModelHookOutput({ continue: false });
|
||||
const expectedResponse: LLMResponse = {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: ['No reason provided'],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(output.getModifiedResponse()).toEqual(expectedResponse);
|
||||
expect(defaultHookTranslator.fromHookLLMResponse).toHaveBeenCalledWith(
|
||||
expectedResponse,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,7 +340,7 @@ export class AfterModelHookOutput extends DefaultHookOutput {
|
||||
const hookResponse = this.hookSpecificOutput[
|
||||
'llm_response'
|
||||
] as Partial<LLMResponse>;
|
||||
if (hookResponse?.candidates?.[0]?.content) {
|
||||
if (hookResponse?.candidates?.[0]?.content?.parts?.length) {
|
||||
// Convert hook format to SDK format
|
||||
return defaultHookTranslator.fromHookLLMResponse(
|
||||
hookResponse as LLMResponse,
|
||||
|
||||
Reference in New Issue
Block a user