diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 10f5a1b81c..999f6b95b2 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -71,6 +71,7 @@ export async function loadConfig( ideMode: false, folderTrust: settings.folderTrust === true, extensionLoader, + previewFeatures: settings.general?.previewFeatures, }; const fileService = new FileDiscoveryService(workspaceDir); diff --git a/packages/a2a-server/src/config/settings.test.ts b/packages/a2a-server/src/config/settings.test.ts new file mode 100644 index 0000000000..f9bc876c5a --- /dev/null +++ b/packages/a2a-server/src/config/settings.test.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { loadSettings, USER_SETTINGS_PATH } from './settings.js'; + +const mocks = vi.hoisted(() => { + const suffix = Math.random().toString(36).slice(2); + return { + suffix, + }; +}); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + const path = await import('node:path'); + return { + ...actual, + homedir: () => path.join(actual.tmpdir(), `gemini-home-${mocks.suffix}`), + }; +}); + +vi.mock('@google/gemini-cli-core', () => ({ + GEMINI_DIR: '.gemini', + debugLogger: { + error: vi.fn(), + }, + getErrorMessage: (error: unknown) => String(error), +})); + +describe('loadSettings', () => { + const mockHomeDir = path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`); + const mockWorkspaceDir = path.join( + os.tmpdir(), + `gemini-workspace-${mocks.suffix}`, + ); + const mockGeminiHomeDir = path.join(mockHomeDir, '.gemini'); + const mockGeminiWorkspaceDir = path.join(mockWorkspaceDir, '.gemini'); + + beforeEach(() => { + vi.clearAllMocks(); + // Create the directories using the real fs + if (!fs.existsSync(mockGeminiHomeDir)) { + fs.mkdirSync(mockGeminiHomeDir, { recursive: true }); + } + if (!fs.existsSync(mockGeminiWorkspaceDir)) { + fs.mkdirSync(mockGeminiWorkspaceDir, { recursive: true }); + } + + // Clean up settings files before each test + if (fs.existsSync(USER_SETTINGS_PATH)) { + fs.rmSync(USER_SETTINGS_PATH); + } + const workspaceSettingsPath = path.join( + mockGeminiWorkspaceDir, + 'settings.json', + ); + if (fs.existsSync(workspaceSettingsPath)) { + fs.rmSync(workspaceSettingsPath); + } + }); + + afterEach(() => { + try { + if (fs.existsSync(mockHomeDir)) { + fs.rmSync(mockHomeDir, { recursive: true, force: true }); + } + if (fs.existsSync(mockWorkspaceDir)) { + fs.rmSync(mockWorkspaceDir, { recursive: true, force: true }); + } + } catch (e) { + console.error('Failed to cleanup temp dirs', e); + } + vi.restoreAllMocks(); + }); + + it('should load nested previewFeatures from user settings', () => { + const settings = { + general: { + previewFeatures: true, + }, + }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); + + const result = loadSettings(mockWorkspaceDir); + expect(result.general?.previewFeatures).toBe(true); + }); + + it('should load nested previewFeatures from workspace settings', () => { + const settings = { + general: { + previewFeatures: true, + }, + }; + const workspaceSettingsPath = path.join( + mockGeminiWorkspaceDir, + 'settings.json', + ); + fs.writeFileSync(workspaceSettingsPath, JSON.stringify(settings)); + + const result = loadSettings(mockWorkspaceDir); + expect(result.general?.previewFeatures).toBe(true); + }); + + it('should prioritize workspace settings over user settings', () => { + const userSettings = { + general: { + previewFeatures: false, + }, + }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings)); + + const workspaceSettings = { + general: { + previewFeatures: true, + }, + }; + const workspaceSettingsPath = path.join( + mockGeminiWorkspaceDir, + 'settings.json', + ); + fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings)); + + const result = loadSettings(mockWorkspaceDir); + expect(result.general?.previewFeatures).toBe(true); + }); + + it('should handle missing previewFeatures', () => { + const settings = { + general: {}, + }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); + + const result = loadSettings(mockWorkspaceDir); + expect(result.general?.previewFeatures).toBeUndefined(); + }); + + it('should load other top-level settings correctly', () => { + const settings = { + showMemoryUsage: true, + coreTools: ['tool1', 'tool2'], + mcpServers: { + server1: { + command: 'cmd', + args: ['arg'], + }, + }, + fileFiltering: { + respectGitIgnore: true, + }, + }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); + + const result = loadSettings(mockWorkspaceDir); + expect(result.showMemoryUsage).toBe(true); + expect(result.coreTools).toEqual(['tool1', 'tool2']); + expect(result.mcpServers).toHaveProperty('server1'); + expect(result.fileFiltering?.respectGitIgnore).toBe(true); + }); + + it('should overwrite top-level settings from workspace (shallow merge)', () => { + const userSettings = { + showMemoryUsage: false, + fileFiltering: { + respectGitIgnore: true, + enableRecursiveFileSearch: true, + }, + }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings)); + + const workspaceSettings = { + showMemoryUsage: true, + fileFiltering: { + respectGitIgnore: false, + }, + }; + const workspaceSettingsPath = path.join( + mockGeminiWorkspaceDir, + 'settings.json', + ); + fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings)); + + const result = loadSettings(mockWorkspaceDir); + // Primitive value overwritten + expect(result.showMemoryUsage).toBe(true); + + // Object value completely replaced (shallow merge behavior) + expect(result.fileFiltering?.respectGitIgnore).toBe(false); + expect(result.fileFiltering?.enableRecursiveFileSearch).toBeUndefined(); + }); +}); diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index 4ecab9e34c..3fc9c8ee35 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -20,7 +20,9 @@ import stripJsonComments from 'strip-json-comments'; export const USER_SETTINGS_DIR = path.join(homedir(), GEMINI_DIR); export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json'); -// Reconcile with https://github.com/google-gemini/gemini-cli/blob/b09bc6656080d4d12e1d06734aae2ec33af5c1ed/packages/cli/src/config/settings.ts#L53 +// TODO: Ensure full compatibility with V2 nested settings structure (settings.schema.json). +// This involves updating the interface and implementing migration logic to support legacy V1 (flat) settings, +// similar to how packages/cli/src/config/settings.ts handles it. export interface Settings { mcpServers?: Record; coreTools?: string[]; @@ -29,6 +31,9 @@ export interface Settings { showMemoryUsage?: boolean; checkpointing?: CheckpointingSettings; folderTrust?: boolean; + general?: { + previewFeatures?: boolean; + }; // Git-aware file filtering settings fileFiltering?: {