Reduce coupling between core and cli packages (#961)

Co-authored-by: Marat Boshernitsan <maratb@google.com>
This commit is contained in:
Marat Boshernitsan 2025-06-12 17:17:29 -07:00 committed by GitHub
parent 3c3da655b0
commit 181abde2ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 310 additions and 56 deletions

2
package-lock.json generated
View File

@ -10814,7 +10814,7 @@
"ignore": "^7.0.0", "ignore": "^7.0.0",
"open": "^10.1.2", "open": "^10.1.2",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
"simple-git": "^3.27.0", "simple-git": "^3.28.0",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"undici": "^7.10.0" "undici": "^7.10.0"
}, },

View File

@ -53,6 +53,7 @@ describe('Configuration Integration Tests', () => {
describe('File Filtering Configuration', () => { describe('File Filtering Configuration', () => {
it('should load default file filtering settings', async () => { it('should load default file filtering settings', async () => {
const configParams: ConfigParameters = { const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG, contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model', embeddingModel: 'test-embedding-model',
sandbox: false, sandbox: false,
@ -70,6 +71,7 @@ describe('Configuration Integration Tests', () => {
it('should load custom file filtering settings from configuration', async () => { it('should load custom file filtering settings from configuration', async () => {
const configParams: ConfigParameters = { const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG, contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model', embeddingModel: 'test-embedding-model',
sandbox: false, sandbox: false,
@ -87,6 +89,7 @@ describe('Configuration Integration Tests', () => {
it('should merge user and workspace file filtering settings', async () => { it('should merge user and workspace file filtering settings', async () => {
const configParams: ConfigParameters = { const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG, contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model', embeddingModel: 'test-embedding-model',
sandbox: false, sandbox: false,
@ -106,6 +109,7 @@ describe('Configuration Integration Tests', () => {
describe('Configuration Integration', () => { describe('Configuration Integration', () => {
it('should handle partial configuration objects gracefully', async () => { it('should handle partial configuration objects gracefully', async () => {
const configParams: ConfigParameters = { const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG, contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model', embeddingModel: 'test-embedding-model',
sandbox: false, sandbox: false,
@ -126,6 +130,7 @@ describe('Configuration Integration Tests', () => {
it('should handle empty configuration objects gracefully', async () => { it('should handle empty configuration objects gracefully', async () => {
const configParams: ConfigParameters = { const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG, contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model', embeddingModel: 'test-embedding-model',
sandbox: false, sandbox: false,
@ -144,6 +149,7 @@ describe('Configuration Integration Tests', () => {
it('should handle missing configuration sections gracefully', async () => { it('should handle missing configuration sections gracefully', async () => {
const configParams: ConfigParameters = { const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG, contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model', embeddingModel: 'test-embedding-model',
sandbox: false, sandbox: false,
@ -163,6 +169,7 @@ describe('Configuration Integration Tests', () => {
describe('Real-world Configuration Scenarios', () => { describe('Real-world Configuration Scenarios', () => {
it('should handle a security-focused configuration', async () => { it('should handle a security-focused configuration', async () => {
const configParams: ConfigParameters = { const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG, contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model', embeddingModel: 'test-embedding-model',
sandbox: false, sandbox: false,
@ -180,6 +187,7 @@ describe('Configuration Integration Tests', () => {
it('should handle a development-focused configuration', async () => { it('should handle a development-focused configuration', async () => {
const configParams: ConfigParameters = { const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG, contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model', embeddingModel: 'test-embedding-model',
sandbox: false, sandbox: false,
@ -196,6 +204,7 @@ describe('Configuration Integration Tests', () => {
it('should handle a CI/CD environment configuration', async () => { it('should handle a CI/CD environment configuration', async () => {
const configParams: ConfigParameters = { const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG, contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model', embeddingModel: 'test-embedding-model',
sandbox: false, sandbox: false,

View File

@ -9,16 +9,20 @@ import { hideBin } from 'yargs/helpers';
import process from 'node:process'; import process from 'node:process';
import { import {
Config, Config,
loadEnvironment,
loadServerHierarchicalMemory, loadServerHierarchicalMemory,
setGeminiMdFilename as setServerGeminiMdFilename, setGeminiMdFilename as setServerGeminiMdFilename,
getCurrentGeminiMdFilename, getCurrentGeminiMdFilename,
ApprovalMode, ApprovalMode,
ContentGeneratorConfig, ContentGeneratorConfig,
GEMINI_CONFIG_DIR as GEMINI_DIR,
} from '@gemini-cli/core'; } from '@gemini-cli/core';
import { Settings } from './settings.js'; import { Settings } from './settings.js';
import { getEffectiveModel } from '../utils/modelCheck.js'; import { getEffectiveModel } from '../utils/modelCheck.js';
import { ExtensionConfig } from './extension.js'; import { ExtensionConfig } from './extension.js';
import * as dotenv from 'dotenv';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
// Simple console logger for now - replace with actual logger if available // Simple console logger for now - replace with actual logger if available
const logger = { const logger = {
@ -196,6 +200,13 @@ export async function loadCliConfig(
fileFilteringAllowBuildArtifacts: fileFilteringAllowBuildArtifacts:
settings.fileFiltering?.allowBuildArtifacts, settings.fileFiltering?.allowBuildArtifacts,
checkpoint: argv.checkpoint, checkpoint: argv.checkpoint,
proxy:
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.HTTP_PROXY ||
process.env.http_proxy,
cwd: process.cwd(),
telemetryOtlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
}); });
} }
@ -259,3 +270,39 @@ async function createContentGeneratorConfig(
return config; return config;
} }
function findEnvFile(startDir: string): string | null {
let currentDir = path.resolve(startDir);
while (true) {
// prefer gemini-specific .env under GEMINI_DIR
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
return geminiEnvPath;
}
const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
return envPath;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(os.homedir(), GEMINI_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
const homeEnvPath = path.join(os.homedir(), '.env');
if (fs.existsSync(homeEnvPath)) {
return homeEnvPath;
}
return null;
}
currentDir = parentDir;
}
}
export function loadEnvironment(): void {
const envFilePath = findEnvFile(process.cwd());
if (envFilePath) {
dotenv.config({ path: envFilePath });
}
}

View File

@ -194,6 +194,7 @@ describe('App UI', () => {
geminiMdFileCount: 0, geminiMdFileCount: 0,
showMemoryUsage: false, showMemoryUsage: false,
sessionId: 'test-session-id', sessionId: 'test-session-id',
cwd: '/tmp',
// Provide other required fields for ConfigParameters if necessary // Provide other required fields for ConfigParameters if necessary
}) as unknown as MockServerConfig; }) as unknown as MockServerConfig;

View File

@ -16,8 +16,10 @@ export default defineConfig({
junit: 'junit.xml', junit: 'junit.xml',
}, },
coverage: { coverage: {
enabled: true,
provider: 'v8', provider: 'v8',
reportsDirectory: './coverage', reportsDirectory: './coverage',
include: ['src/**/*'],
reporter: [ reporter: [
['text', { file: 'full-text-summary.txt' }], ['text', { file: 'full-text-summary.txt' }],
'html', 'html',

View File

@ -37,7 +37,7 @@
"ignore": "^7.0.0", "ignore": "^7.0.0",
"open": "^10.1.2", "open": "^10.1.2",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
"simple-git": "^3.27.0", "simple-git": "^3.28.0",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"undici": "^7.10.0" "undici": "^7.10.0"
}, },

View File

@ -51,6 +51,7 @@ describe('Server Config (config.ts)', () => {
const EMBEDDING_MODEL = 'gemini-embedding'; const EMBEDDING_MODEL = 'gemini-embedding';
const SESSION_ID = 'test-session-id'; const SESSION_ID = 'test-session-id';
const baseParams: ConfigParameters = { const baseParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: { contentGeneratorConfig: {
apiKey: API_KEY, apiKey: API_KEY,
model: MODEL, model: MODEL,

View File

@ -4,11 +4,8 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import * as dotenv from 'dotenv';
import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import * as os from 'node:os';
import { ContentGeneratorConfig } from '../core/contentGenerator.js'; import { ContentGeneratorConfig } from '../core/contentGenerator.js';
import { ToolRegistry } from '../tools/tool-registry.js'; import { ToolRegistry } from '../tools/tool-registry.js';
import { LSTool } from '../tools/ls.js'; import { LSTool } from '../tools/ls.js';
@ -79,9 +76,12 @@ export interface ConfigParameters {
accessibility?: AccessibilitySettings; accessibility?: AccessibilitySettings;
telemetry?: boolean; telemetry?: boolean;
telemetryLogUserPromptsEnabled?: boolean; telemetryLogUserPromptsEnabled?: boolean;
telemetryOtlpEndpoint?: string;
fileFilteringRespectGitIgnore?: boolean; fileFilteringRespectGitIgnore?: boolean;
fileFilteringAllowBuildArtifacts?: boolean; fileFilteringAllowBuildArtifacts?: boolean;
checkpoint?: boolean; checkpoint?: boolean;
proxy?: string;
cwd: string;
} }
export class Config { export class Config {
@ -115,6 +115,8 @@ export class Config {
private fileDiscoveryService: FileDiscoveryService | null = null; private fileDiscoveryService: FileDiscoveryService | null = null;
private gitService: GitService | undefined = undefined; private gitService: GitService | undefined = undefined;
private readonly checkpoint: boolean; private readonly checkpoint: boolean;
private readonly proxy: string | undefined;
private readonly cwd: string;
constructor(params: ConfigParameters) { constructor(params: ConfigParameters) {
this.sessionId = params.sessionId; this.sessionId = params.sessionId;
@ -140,12 +142,14 @@ export class Config {
this.telemetryLogUserPromptsEnabled = this.telemetryLogUserPromptsEnabled =
params.telemetryLogUserPromptsEnabled ?? true; params.telemetryLogUserPromptsEnabled ?? true;
this.telemetryOtlpEndpoint = this.telemetryOtlpEndpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4317'; params.telemetryOtlpEndpoint ?? 'http://localhost:4317';
this.fileFilteringRespectGitIgnore = this.fileFilteringRespectGitIgnore =
params.fileFilteringRespectGitIgnore ?? true; params.fileFilteringRespectGitIgnore ?? true;
this.fileFilteringAllowBuildArtifacts = this.fileFilteringAllowBuildArtifacts =
params.fileFilteringAllowBuildArtifacts ?? false; params.fileFilteringAllowBuildArtifacts ?? false;
this.checkpoint = params.checkpoint ?? false; this.checkpoint = params.checkpoint ?? false;
this.proxy = params.proxy;
this.cwd = params.cwd ?? process.cwd();
if (params.contextFileName) { if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName); setGeminiMdFilename(params.contextFileName);
@ -297,6 +301,14 @@ export class Config {
return this.checkpoint; return this.checkpoint;
} }
getProxy(): string | undefined {
return this.proxy;
}
getWorkingDir(): string {
return this.cwd;
}
async getFileService(): Promise<FileDiscoveryService> { async getFileService(): Promise<FileDiscoveryService> {
if (!this.fileDiscoveryService) { if (!this.fileDiscoveryService) {
this.fileDiscoveryService = new FileDiscoveryService(this.targetDir); this.fileDiscoveryService = new FileDiscoveryService(this.targetDir);
@ -317,42 +329,6 @@ export class Config {
} }
} }
function findEnvFile(startDir: string): string | null {
let currentDir = path.resolve(startDir);
while (true) {
// prefer gemini-specific .env under GEMINI_DIR
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
return geminiEnvPath;
}
const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
return envPath;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(os.homedir(), GEMINI_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
const homeEnvPath = path.join(os.homedir(), '.env');
if (fs.existsSync(homeEnvPath)) {
return homeEnvPath;
}
return null;
}
currentDir = parentDir;
}
}
export function loadEnvironment(): void {
const envFilePath = findEnvFile(process.cwd());
if (envFilePath) {
dotenv.config({ path: envFilePath });
}
}
export function createToolRegistry(config: Config): Promise<ToolRegistry> { export function createToolRegistry(config: Config): Promise<ToolRegistry> {
const registry = new ToolRegistry(config); const registry = new ToolRegistry(config);
const targetDir = config.getTargetDir(); const targetDir = config.getTargetDir();

View File

@ -17,6 +17,7 @@ import { ContentGenerator } from './contentGenerator.js';
import { GeminiChat } from './geminiChat.js'; import { GeminiChat } from './geminiChat.js';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { Turn } from './turn.js'; import { Turn } from './turn.js';
import { getCoreSystemPrompt } from './prompts.js';
// --- Mocks --- // --- Mocks ---
const mockChatCreateFn = vi.fn(); const mockChatCreateFn = vi.fn();
@ -54,6 +55,11 @@ vi.mock('../utils/generateContentResponseUtilities', () => ({
result.candidates?.[0]?.content?.parts?.map((part) => part.text).join('') || result.candidates?.[0]?.content?.parts?.map((part) => part.text).join('') ||
undefined, undefined,
})); }));
vi.mock('../telemetry/index.js', () => ({
logApiRequest: vi.fn(),
logApiResponse: vi.fn(),
logApiError: vi.fn(),
}));
describe('Gemini Client (client.ts)', () => { describe('Gemini Client (client.ts)', () => {
let client: GeminiClient; let client: GeminiClient;
@ -109,6 +115,8 @@ describe('Gemini Client (client.ts)', () => {
getUserMemory: vi.fn().mockReturnValue(''), getUserMemory: vi.fn().mockReturnValue(''),
getFullContext: vi.fn().mockReturnValue(false), getFullContext: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session-id'), getSessionId: vi.fn().mockReturnValue('test-session-id'),
getProxy: vi.fn().mockReturnValue(undefined),
getWorkingDir: vi.fn().mockReturnValue('/test/dir'),
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return mock as any; return mock as any;
@ -239,6 +247,68 @@ describe('Gemini Client (client.ts)', () => {
}); });
}); });
describe('generateContent', () => {
it('should call generateContent with the correct parameters', async () => {
const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
const generationConfig = { temperature: 0.5 };
const abortSignal = new AbortController().signal;
// Mock countTokens
const mockGenerator: Partial<ContentGenerator> = {
countTokens: vi.fn().mockResolvedValue({ totalTokens: 1 }),
generateContent: mockGenerateContentFn,
};
client['contentGenerator'] = Promise.resolve(
mockGenerator as ContentGenerator,
);
await client.generateContent(contents, generationConfig, abortSignal);
expect(mockGenerateContentFn).toHaveBeenCalledWith({
model: 'test-model',
config: {
abortSignal,
systemInstruction: getCoreSystemPrompt(''),
temperature: 0.5,
topP: 1,
},
contents,
});
});
});
describe('generateJson', () => {
it('should call generateContent with the correct parameters', async () => {
const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
const schema = { type: 'string' };
const abortSignal = new AbortController().signal;
// Mock countTokens
const mockGenerator: Partial<ContentGenerator> = {
countTokens: vi.fn().mockResolvedValue({ totalTokens: 1 }),
generateContent: mockGenerateContentFn,
};
client['contentGenerator'] = Promise.resolve(
mockGenerator as ContentGenerator,
);
await client.generateJson(contents, schema, abortSignal);
expect(mockGenerateContentFn).toHaveBeenCalledWith({
model: 'gemini-2.0-flash',
config: {
abortSignal,
systemInstruction: getCoreSystemPrompt(''),
temperature: 0,
topP: 1,
responseSchema: schema,
responseMimeType: 'application/json',
},
contents,
});
});
});
describe('addHistory', () => { describe('addHistory', () => {
it('should call chat.addHistory with the provided content', async () => { it('should call chat.addHistory with the provided content', async () => {
const mockChat = { const mockChat = {

View File

@ -14,7 +14,6 @@ import {
Tool, Tool,
GenerateContentResponse, GenerateContentResponse,
} from '@google/genai'; } from '@google/genai';
import process from 'node:process';
import { getFolderStructure } from '../utils/getFolderStructure.js'; import { getFolderStructure } from '../utils/getFolderStructure.js';
import { Turn, ServerGeminiStreamEvent, GeminiEventType } from './turn.js'; import { Turn, ServerGeminiStreamEvent, GeminiEventType } from './turn.js';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
@ -33,16 +32,6 @@ import {
} from './contentGenerator.js'; } from './contentGenerator.js';
import { ProxyAgent, setGlobalDispatcher } from 'undici'; import { ProxyAgent, setGlobalDispatcher } from 'undici';
const proxy =
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.HTTP_PROXY ||
process.env.http_proxy;
if (proxy) {
setGlobalDispatcher(new ProxyAgent(proxy));
}
export class GeminiClient { export class GeminiClient {
private chat: Promise<GeminiChat>; private chat: Promise<GeminiChat>;
private contentGenerator: Promise<ContentGenerator>; private contentGenerator: Promise<ContentGenerator>;
@ -55,6 +44,10 @@ export class GeminiClient {
private readonly MAX_TURNS = 100; private readonly MAX_TURNS = 100;
constructor(private config: Config) { constructor(private config: Config) {
if (config.getProxy()) {
setGlobalDispatcher(new ProxyAgent(config.getProxy() as string));
}
this.contentGenerator = createContentGenerator( this.contentGenerator = createContentGenerator(
this.config.getContentGeneratorConfig(), this.config.getContentGeneratorConfig(),
); );
@ -83,7 +76,7 @@ export class GeminiClient {
} }
private async getEnvironment(): Promise<Part[]> { private async getEnvironment(): Promise<Part[]> {
const cwd = process.cwd(); const cwd = this.config.getWorkingDir();
const today = new Date().toLocaleDateString(undefined, { const today = new Date().toLocaleDateString(undefined, {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',

View File

@ -5,7 +5,13 @@
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Content, Models, GenerateContentConfig, Part } from '@google/genai'; import {
Content,
Models,
GenerateContentConfig,
Part,
GenerateContentResponse,
} from '@google/genai';
import { GeminiChat } from './geminiChat.js'; import { GeminiChat } from './geminiChat.js';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
@ -20,6 +26,7 @@ const mockModelsModule = {
const mockConfig = { const mockConfig = {
getSessionId: () => 'test-session-id', getSessionId: () => 'test-session-id',
getTelemetryLogUserPromptsEnabled: () => true,
} as unknown as Config; } as unknown as Config;
describe('GeminiChat', () => { describe('GeminiChat', () => {
@ -37,6 +44,66 @@ describe('GeminiChat', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe('sendMessage', () => {
it('should call generateContent with the correct parameters', async () => {
const response = {
candidates: [
{
content: {
parts: [{ text: 'response' }],
role: 'model',
},
finishReason: 'STOP',
index: 0,
safetyRatings: [],
},
],
text: () => 'response',
} as unknown as GenerateContentResponse;
vi.mocked(mockModelsModule.generateContent).mockResolvedValue(response);
await chat.sendMessage({ message: 'hello' });
expect(mockModelsModule.generateContent).toHaveBeenCalledWith({
model: 'gemini-pro',
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
config: {},
});
});
});
describe('sendMessageStream', () => {
it('should call generateContentStream with the correct parameters', async () => {
const response = (async function* () {
yield {
candidates: [
{
content: {
parts: [{ text: 'response' }],
role: 'model',
},
finishReason: 'STOP',
index: 0,
safetyRatings: [],
},
],
text: () => 'response',
} as unknown as GenerateContentResponse;
})();
vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue(
response,
);
await chat.sendMessageStream({ message: 'hello' });
expect(mockModelsModule.generateContentStream).toHaveBeenCalledWith({
model: 'gemini-pro',
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
config: {},
});
});
});
describe('recordHistory', () => { describe('recordHistory', () => {
const userInput: Content = { const userInput: Content = {
role: 'user', role: 'user',

View File

@ -0,0 +1,85 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { partListUnionToString } from './geminiRequest.js';
import { type Part } from '@google/genai';
describe('partListUnionToString', () => {
it('should return the string value if the input is a string', () => {
const result = partListUnionToString('hello');
expect(result).toBe('hello');
});
it('should return a concatenated string if the input is an array of strings', () => {
const result = partListUnionToString(['hello', ' ', 'world']);
expect(result).toBe('hello world');
});
it('should handle videoMetadata', () => {
const part: Part = { videoMetadata: {} };
const result = partListUnionToString(part);
expect(result).toBe('[Video Metadata]');
});
it('should handle thought', () => {
const part: Part = { thought: true };
const result = partListUnionToString(part);
expect(result).toBe('[Thought: true]');
});
it('should handle codeExecutionResult', () => {
const part: Part = { codeExecutionResult: {} };
const result = partListUnionToString(part);
expect(result).toBe('[Code Execution Result]');
});
it('should handle executableCode', () => {
const part: Part = { executableCode: {} };
const result = partListUnionToString(part);
expect(result).toBe('[Executable Code]');
});
it('should handle fileData', () => {
const part: Part = {
fileData: { mimeType: 'text/plain', fileUri: 'file.txt' },
};
const result = partListUnionToString(part);
expect(result).toBe('[File Data]');
});
it('should handle functionCall', () => {
const part: Part = { functionCall: { name: 'myFunction' } };
const result = partListUnionToString(part);
expect(result).toBe('[Function Call: myFunction]');
});
it('should handle functionResponse', () => {
const part: Part = {
functionResponse: { name: 'myFunction', response: {} },
};
const result = partListUnionToString(part);
expect(result).toBe('[Function Response: myFunction]');
});
it('should handle inlineData', () => {
const part: Part = { inlineData: { mimeType: 'image/png', data: '...' } };
const result = partListUnionToString(part);
expect(result).toBe('<image/png>');
});
it('should handle text', () => {
const part: Part = { text: 'hello' };
const result = partListUnionToString(part);
expect(result).toBe('hello');
});
it('should return an empty string for an unknown part type', () => {
const part: Part = {};
const result = partListUnionToString(part);
expect(result).toBe('');
});
});

View File

@ -124,6 +124,7 @@ class MockTool extends BaseTool<{ param: string }, ToolResult> {
} }
const baseConfigParams: ConfigParameters = { const baseConfigParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: { contentGeneratorConfig: {
model: 'test-model', model: 'test-model',
apiKey: 'test-api-key', apiKey: 'test-api-key',

View File

@ -13,8 +13,10 @@ export default defineConfig({
junit: 'junit.xml', junit: 'junit.xml',
}, },
coverage: { coverage: {
enabled: true,
provider: 'v8', provider: 'v8',
reportsDirectory: './coverage', reportsDirectory: './coverage',
include: ['src/**/*'],
reporter: [ reporter: [
['text', { file: 'full-text-summary.txt' }], ['text', { file: 'full-text-summary.txt' }],
'html', 'html',