Merge main into adaptiveThinkingBudget and resolve conflicts

This commit is contained in:
Adam Weidman
2026-02-09 11:13:45 -05:00
1160 changed files with 122177 additions and 36309 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.24.0-nightly.20251227.37be16243",
"version": "0.29.0-nightly.20260203.71f46f116",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"dependencies": {
"@a2a-js/sdk": "^0.3.7",
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
@@ -349,6 +349,44 @@ describe('Task', () => {
}),
);
});
it.each([
{ eventType: GeminiEventType.Retry, eventName: 'Retry' },
{ eventType: GeminiEventType.InvalidStream, eventName: 'InvalidStream' },
])(
'should handle $eventName event without triggering error handling',
async ({ eventType }) => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const cancelPendingToolsSpy = vi.spyOn(task, 'cancelPendingTools');
const setTaskStateSpy = vi.spyOn(task, 'setTaskStateAndPublishUpdate');
const event = {
type: eventType,
};
await task.acceptAgentMessage(event);
expect(cancelPendingToolsSpy).not.toHaveBeenCalled();
expect(setTaskStateSpy).not.toHaveBeenCalled();
},
);
});
describe('_schedulerToolCallsUpdate', () => {
+11 -2
View File
@@ -412,7 +412,9 @@ export class Task {
toolCalls.forEach((tc: ToolCall) => {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
tc.confirmationDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
(tc.confirmationDetails).onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
this.pendingToolConfirmationDetails.delete(tc.request.callId);
}
});
@@ -573,7 +575,10 @@ export class Task {
EDIT_TOOL_NAMES.has(request.name),
);
if (restorableToolCalls.length > 0) {
if (
restorableToolCalls.length > 0 &&
this.config.getCheckpointingEnabled()
) {
const gitService = await this.config.getGitService();
if (gitService) {
const { checkpointsToWrite, toolCallToCheckpointMap, errors } =
@@ -707,6 +712,10 @@ export class Task {
case GeminiEventType.ModelInfo:
this.modelInfo = event.value;
break;
case GeminiEventType.Retry:
case GeminiEventType.InvalidStream:
// An invalid stream should trigger a retry, which requires no action from the user.
break;
case GeminiEventType.Error:
default: {
// Block scope for lexical declaration
@@ -7,53 +7,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Command } from './types.js';
describe('CommandRegistry', () => {
const mockListExtensionsCommandInstance: Command = {
const {
mockExtensionsCommand,
mockListExtensionsCommand,
mockExtensionsCommandInstance,
mockListExtensionsCommandInstance,
} = vi.hoisted(() => {
const listInstance: Command = {
name: 'extensions list',
description: 'Lists all installed extensions.',
execute: vi.fn(),
};
const mockListExtensionsCommand = vi.fn(
() => mockListExtensionsCommandInstance,
);
const mockExtensionsCommandInstance: Command = {
const extInstance: Command = {
name: 'extensions',
description: 'Manage extensions.',
execute: vi.fn(),
subCommands: [mockListExtensionsCommandInstance],
subCommands: [listInstance],
};
const mockExtensionsCommand = vi.fn(() => mockExtensionsCommandInstance);
return {
mockListExtensionsCommandInstance: listInstance,
mockExtensionsCommandInstance: extInstance,
mockExtensionsCommand: vi.fn(() => extInstance),
mockListExtensionsCommand: vi.fn(() => listInstance),
};
});
vi.mock('./extensions.js', () => ({
ExtensionsCommand: mockExtensionsCommand,
ListExtensionsCommand: mockListExtensionsCommand,
}));
vi.mock('./init.js', () => ({
InitCommand: vi.fn(() => ({
name: 'init',
description: 'Initializes the server.',
execute: vi.fn(),
})),
}));
vi.mock('./restore.js', () => ({
RestoreCommand: vi.fn(() => ({
name: 'restore',
description: 'Restores the server.',
execute: vi.fn(),
})),
}));
import { commandRegistry } from './command-registry.js';
describe('CommandRegistry', () => {
beforeEach(async () => {
vi.resetModules();
vi.doMock('./extensions.js', () => ({
ExtensionsCommand: mockExtensionsCommand,
ListExtensionsCommand: mockListExtensionsCommand,
}));
vi.clearAllMocks();
commandRegistry.initialize();
});
it('should register ExtensionsCommand on initialization', async () => {
const { commandRegistry } = await import('./command-registry.js');
expect(mockExtensionsCommand).toHaveBeenCalled();
const command = commandRegistry.get('extensions');
expect(command).toBe(mockExtensionsCommandInstance);
});
}, 20000);
it('should register sub commands on initialization', async () => {
const { commandRegistry } = await import('./command-registry.js');
const command = commandRegistry.get('extensions list');
expect(command).toBe(mockListExtensionsCommandInstance);
});
it('get() should return undefined for a non-existent command', async () => {
const { commandRegistry } = await import('./command-registry.js');
const command = commandRegistry.get('non-existent');
expect(command).toBeUndefined();
});
it('register() should register a new command', async () => {
const { commandRegistry } = await import('./command-registry.js');
const mockCommand: Command = {
name: 'test-command',
description: '',
@@ -65,7 +91,6 @@ describe('CommandRegistry', () => {
});
it('register() should register a nested command', async () => {
const { commandRegistry } = await import('./command-registry.js');
const mockSubSubCommand: Command = {
name: 'test-command-sub-sub',
description: '',
@@ -95,8 +120,8 @@ describe('CommandRegistry', () => {
});
it('register() should not enter an infinite loop with a cyclic command', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { commandRegistry } = await import('./command-registry.js');
const { debugLogger } = await import('@google/gemini-cli-core');
const warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
const mockCommand: Command = {
name: 'cyclic-command',
description: '',
@@ -112,7 +137,6 @@ describe('CommandRegistry', () => {
expect(warnSpy).toHaveBeenCalledWith(
'Command cyclic-command already registered. Skipping.',
);
// If the test finishes, it means we didn't get into an infinite loop.
warnSpy.mockRestore();
});
});
@@ -4,19 +4,26 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { MemoryCommand } from './memory.js';
import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionsCommand } from './extensions.js';
import { InitCommand } from './init.js';
import { RestoreCommand } from './restore.js';
import type { Command } from './types.js';
class CommandRegistry {
export class CommandRegistry {
private readonly commands = new Map<string, Command>();
constructor() {
this.initialize();
}
initialize() {
this.commands.clear();
this.register(new ExtensionsCommand());
this.register(new RestoreCommand());
this.register(new InitCommand());
this.register(new MemoryCommand());
}
register(command: Command) {
@@ -26,10 +26,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
vi.mock('node:fs', () => ({
existsSync: vi.fn(),
writeFileSync: vi.fn(),
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(),
writeFileSync: vi.fn(),
};
});
vi.mock('../agent/executor.js', () => ({
CoderAgentExecutor: vi.fn().mockImplementation(() => ({
@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
addMemory,
listMemoryFiles,
refreshMemory,
showMemory,
} from '@google/gemini-cli-core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AddMemoryCommand,
ListMemoryCommand,
MemoryCommand,
RefreshMemoryCommand,
ShowMemoryCommand,
} from './memory.js';
import type { CommandContext } from './types.js';
import type {
AnyDeclarativeTool,
Config,
ToolRegistry,
} from '@google/gemini-cli-core';
// Mock the core functions
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
showMemory: vi.fn(),
refreshMemory: vi.fn(),
listMemoryFiles: vi.fn(),
addMemory: vi.fn(),
};
});
const mockShowMemory = vi.mocked(showMemory);
const mockRefreshMemory = vi.mocked(refreshMemory);
const mockListMemoryFiles = vi.mocked(listMemoryFiles);
const mockAddMemory = vi.mocked(addMemory);
describe('a2a-server memory commands', () => {
let mockContext: CommandContext;
let mockConfig: Config;
let mockToolRegistry: ToolRegistry;
let mockSaveMemoryTool: AnyDeclarativeTool;
beforeEach(() => {
mockSaveMemoryTool = {
name: 'save_memory',
description: 'Saves memory',
buildAndExecute: vi.fn().mockResolvedValue(undefined),
} as unknown as AnyDeclarativeTool;
mockToolRegistry = {
getTool: vi.fn(),
} as unknown as ToolRegistry;
mockConfig = {
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
} as unknown as Config;
mockContext = {
config: mockConfig,
};
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockSaveMemoryTool);
});
describe('MemoryCommand', () => {
it('delegates to ShowMemoryCommand', async () => {
const command = new MemoryCommand();
mockShowMemory.mockReturnValue({
type: 'message',
messageType: 'info',
content: 'showing memory',
});
const response = await command.execute(mockContext, []);
expect(response.data).toBe('showing memory');
expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config);
});
});
describe('ShowMemoryCommand', () => {
it('executes showMemory and returns the content', async () => {
const command = new ShowMemoryCommand();
mockShowMemory.mockReturnValue({
type: 'message',
messageType: 'info',
content: 'test memory content',
});
const response = await command.execute(mockContext, []);
expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config);
expect(response.name).toBe('memory show');
expect(response.data).toBe('test memory content');
});
});
describe('RefreshMemoryCommand', () => {
it('executes refreshMemory and returns the content', async () => {
const command = new RefreshMemoryCommand();
mockRefreshMemory.mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'memory refreshed',
});
const response = await command.execute(mockContext, []);
expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config);
expect(response.name).toBe('memory refresh');
expect(response.data).toBe('memory refreshed');
});
});
describe('ListMemoryCommand', () => {
it('executes listMemoryFiles and returns the content', async () => {
const command = new ListMemoryCommand();
mockListMemoryFiles.mockReturnValue({
type: 'message',
messageType: 'info',
content: 'file1.md\nfile2.md',
});
const response = await command.execute(mockContext, []);
expect(mockListMemoryFiles).toHaveBeenCalledWith(mockContext.config);
expect(response.name).toBe('memory list');
expect(response.data).toBe('file1.md\nfile2.md');
});
});
describe('AddMemoryCommand', () => {
it('returns message content if addMemory returns a message', async () => {
const command = new AddMemoryCommand();
mockAddMemory.mockReturnValue({
type: 'message',
messageType: 'error',
content: 'error message',
});
const response = await command.execute(mockContext, []);
expect(mockAddMemory).toHaveBeenCalledWith('');
expect(response.name).toBe('memory add');
expect(response.data).toBe('error message');
});
it('executes the save_memory tool if found', async () => {
const command = new AddMemoryCommand();
const fact = 'this is a new fact';
mockAddMemory.mockReturnValue({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact },
});
const response = await command.execute(mockContext, [
'this',
'is',
'a',
'new',
'fact',
]);
expect(mockAddMemory).toHaveBeenCalledWith(fact);
expect(mockConfig.getToolRegistry).toHaveBeenCalled();
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('save_memory');
expect(mockSaveMemoryTool.buildAndExecute).toHaveBeenCalledWith(
{ fact },
expect.any(AbortSignal),
undefined,
{
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
},
);
expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config);
expect(response.name).toBe('memory add');
expect(response.data).toBe(`Added memory: "${fact}"`);
});
it('returns an error if the tool is not found', async () => {
const command = new AddMemoryCommand();
const fact = 'another fact';
mockAddMemory.mockReturnValue({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact },
});
vi.mocked(mockToolRegistry.getTool).mockReturnValue(undefined);
const response = await command.execute(mockContext, ['another', 'fact']);
expect(response.name).toBe('memory add');
expect(response.data).toBe('Error: Tool save_memory not found.');
});
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
addMemory,
listMemoryFiles,
refreshMemory,
showMemory,
} from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
const DEFAULT_SANITIZATION_CONFIG = {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
};
export class MemoryCommand implements Command {
readonly name = 'memory';
readonly description = 'Manage memory.';
readonly subCommands = [
new ShowMemoryCommand(),
new RefreshMemoryCommand(),
new ListMemoryCommand(),
new AddMemoryCommand(),
];
readonly topLevel = true;
readonly requiresWorkspace = true;
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ShowMemoryCommand().execute(context, _);
}
}
export class ShowMemoryCommand implements Command {
readonly name = 'memory show';
readonly description = 'Shows the current memory contents.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = showMemory(context.config);
return { name: this.name, data: result.content };
}
}
export class RefreshMemoryCommand implements Command {
readonly name = 'memory refresh';
readonly description = 'Refreshes the memory from the source.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = await refreshMemory(context.config);
return { name: this.name, data: result.content };
}
}
export class ListMemoryCommand implements Command {
readonly name = 'memory list';
readonly description = 'Lists the paths of the GEMINI.md files in use.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = listMemoryFiles(context.config);
return { name: this.name, data: result.content };
}
}
export class AddMemoryCommand implements Command {
readonly name = 'memory add';
readonly description = 'Add content to the memory.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const textToAdd = args.join(' ').trim();
const result = addMemory(textToAdd);
if (result.type === 'message') {
return { name: this.name, data: result.content };
}
const toolRegistry = context.config.getToolRegistry();
const tool = toolRegistry.getTool(result.toolName);
if (tool) {
const abortController = new AbortController();
const signal = abortController.signal;
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
});
await refreshMemory(context.config);
return {
name: this.name,
data: `Added memory: "${textToAdd}"`,
};
} else {
return {
name: this.name,
data: `Error: Tool ${result.toolName} not found.`,
};
}
}
}
@@ -0,0 +1,268 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as path from 'node:path';
import { loadConfig } from './config.js';
import type { Settings } from './settings.js';
import {
type ExtensionLoader,
FileDiscoveryService,
getCodeAssistServer,
Config,
ExperimentFlags,
fetchAdminControlsOnce,
type FetchAdminControlsResponse,
} from '@google/gemini-cli-core';
// Mock dependencies
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
Config: vi.fn().mockImplementation((params) => {
const mockConfig = {
...params,
initialize: vi.fn(),
refreshAuth: vi.fn(),
getExperiments: vi.fn().mockReturnValue({
flags: {
[actual.ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
boolValue: false,
},
},
}),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
};
return mockConfig;
}),
loadServerHierarchicalMemory: vi
.fn()
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
startupProfiler: {
flush: vi.fn(),
},
FileDiscoveryService: vi.fn(),
getCodeAssistServer: vi.fn(),
fetchAdminControlsOnce: vi.fn(),
coreEvents: {
emitAdminSettingsChanged: vi.fn(),
},
};
});
vi.mock('../utils/logger.js', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
describe('loadConfig', () => {
const mockSettings = {} as Settings;
const mockExtensionLoader = {} as ExtensionLoader;
const taskId = 'test-task-id';
beforeEach(() => {
vi.clearAllMocks();
process.env['GEMINI_API_KEY'] = 'test-key';
});
afterEach(() => {
delete process.env['CUSTOM_IGNORE_FILE_PATHS'];
delete process.env['GEMINI_API_KEY'];
});
describe('admin settings overrides', () => {
it('should not fetch admin controls if experiment is disabled', async () => {
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(fetchAdminControlsOnce).not.toHaveBeenCalled();
});
describe('when admin controls experiment is enabled', () => {
beforeEach(() => {
// We need to cast to any here to modify the mock implementation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Config as any).mockImplementation((params: unknown) => {
const mockConfig = {
...(params as object),
initialize: vi.fn(),
refreshAuth: vi.fn(),
getExperiments: vi.fn().mockReturnValue({
flags: {
[ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
boolValue: true,
},
},
}),
getRemoteAdminSettings: vi.fn().mockReturnValue({}),
setRemoteAdminSettings: vi.fn(),
};
return mockConfig;
});
});
it('should fetch admin controls and apply them', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: false,
},
cliFeatureSetting: {
extensionsSetting: {
extensionsEnabled: false,
},
},
strictModeDisabled: false,
};
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenLastCalledWith(
expect.objectContaining({
disableYoloMode: !mockAdminSettings.strictModeDisabled,
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
extensionsEnabled:
mockAdminSettings.cliFeatureSetting?.extensionsSetting
?.extensionsEnabled,
}),
);
});
it('should treat unset admin settings as false when admin settings are passed', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: true,
},
};
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenLastCalledWith(
expect.objectContaining({
disableYoloMode: !false,
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
extensionsEnabled: undefined,
}),
);
});
it('should not pass default unset admin settings when no admin settings are present', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {};
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenLastCalledWith(expect.objectContaining({}));
});
it('should fetch admin controls using the code assist server when available', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: true,
},
strictModeDisabled: true,
};
const mockCodeAssistServer = { projectId: 'test-project' };
vi.mocked(getCodeAssistServer).mockReturnValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCodeAssistServer as any,
);
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(fetchAdminControlsOnce).toHaveBeenCalledWith(
mockCodeAssistServer,
true,
);
expect(Config).toHaveBeenLastCalledWith(
expect.objectContaining({
disableYoloMode: !mockAdminSettings.strictModeDisabled,
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
extensionsEnabled: undefined,
}),
);
});
});
});
it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
const testPath = '/tmp/ignore';
process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
testPath,
]);
});
it('should set customIgnoreFilePaths when settings.fileFiltering.customIgnoreFilePaths is present', async () => {
const testPath = '/settings/ignore';
const settings: Settings = {
fileFiltering: {
customIgnoreFilePaths: [testPath],
},
};
const config = await loadConfig(settings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
testPath,
]);
});
it('should merge customIgnoreFilePaths from settings and env var', async () => {
const envPath = '/env/ignore';
const settingsPath = '/settings/ignore';
process.env['CUSTOM_IGNORE_FILE_PATHS'] = envPath;
const settings: Settings = {
fileFiltering: {
customIgnoreFilePaths: [settingsPath],
},
};
const config = await loadConfig(settings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
settingsPath,
envPath,
]);
});
it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => {
const paths = ['/path/one', '/path/two'];
process.env['CUSTOM_IGNORE_FILE_PATHS'] = paths.join(path.delimiter);
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths);
});
it('should have empty customIgnoreFilePaths when both are missing', async () => {
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
});
it('should initialize FileDiscoveryService with correct options', async () => {
const testPath = '/tmp/ignore';
process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
const settings: Settings = {
fileFiltering: {
respectGitIgnore: false,
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(FileDiscoveryService).toHaveBeenCalledWith(expect.any(String), {
respectGitIgnore: false,
respectGeminiIgnore: undefined,
customIgnoreFilePaths: [testPath],
});
});
});
+118 -44
View File
@@ -6,7 +6,6 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { homedir } from 'node:os';
import * as dotenv from 'dotenv';
import type { TelemetryTarget } from '@google/gemini-cli-core';
@@ -19,10 +18,14 @@ import {
loadServerHierarchicalMemory,
GEMINI_DIR,
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_GEMINI_MODEL,
type ExtensionLoader,
startupProfiler,
PREVIEW_GEMINI_MODEL,
homedir,
GitService,
fetchAdminControlsOnce,
getCodeAssistServer,
ExperimentFlags,
} from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
@@ -37,11 +40,26 @@ export async function loadConfig(
const workspaceDir = process.cwd();
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
const folderTrust =
settings.folderTrust === true ||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
let checkpointing = process.env['CHECKPOINTING']
? process.env['CHECKPOINTING'] === 'true'
: settings.checkpointing?.enabled;
if (checkpointing) {
if (!(await GitService.verifyGitAvailability())) {
logger.warn(
'[Config] Checkpointing is enabled but git is not installed. Disabling checkpointing.',
);
checkpointing = false;
}
}
const configParams: ConfigParameters = {
sessionId: taskId,
model: settings.general?.previewFeatures
? PREVIEW_GEMINI_MODEL
: DEFAULT_GEMINI_MODEL,
model: PREVIEW_GEMINI_MODEL,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
targetDir: workspaceDir, // Or a specific directory the agent operates on
@@ -68,61 +86,87 @@ export async function loadConfig(
// Git-aware file filtering settings
fileFiltering: {
respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore,
enableRecursiveFileSearch:
settings.fileFiltering?.enableRecursiveFileSearch,
customIgnoreFilePaths: [
...(settings.fileFiltering?.customIgnoreFilePaths || []),
...(process.env['CUSTOM_IGNORE_FILE_PATHS']
? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter)
: []),
],
},
ideMode: false,
folderTrust: settings.folderTrust === true,
folderTrust,
trustedFolder: true,
extensionLoader,
checkpointing: process.env['CHECKPOINTING']
? process.env['CHECKPOINTING'] === 'true'
: settings.checkpointing?.enabled,
previewFeatures: settings.general?.previewFeatures,
checkpointing,
interactive: true,
enableInteractiveShell: true,
ptyInfo: 'auto',
};
const fileService = new FileDiscoveryService(workspaceDir);
const { memoryContent, fileCount } = await loadServerHierarchicalMemory(
workspaceDir,
[workspaceDir],
false,
fileService,
extensionLoader,
settings.folderTrust === true,
);
const fileService = new FileDiscoveryService(workspaceDir, {
respectGitIgnore: configParams?.fileFiltering?.respectGitIgnore,
respectGeminiIgnore: configParams?.fileFiltering?.respectGeminiIgnore,
customIgnoreFilePaths: configParams?.fileFiltering?.customIgnoreFilePaths,
});
const { memoryContent, fileCount, filePaths } =
await loadServerHierarchicalMemory(
workspaceDir,
[workspaceDir],
false,
fileService,
extensionLoader,
folderTrust,
);
configParams.userMemory = memoryContent;
configParams.geminiMdFileCount = fileCount;
const config = new Config({
configParams.geminiMdFilePaths = filePaths;
// Set an initial config to use to get a code assist server.
// This is needed to fetch admin controls.
const initialConfig = new Config({
...configParams,
});
const codeAssistServer = getCodeAssistServer(initialConfig);
const adminControlsEnabled =
initialConfig.getExperiments()?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]
?.boolValue ?? false;
// Initialize final config parameters to the previous parameters.
// If no admin controls are needed, these will be used as-is for the final
// config.
const finalConfigParams = { ...configParams };
if (adminControlsEnabled) {
const adminSettings = await fetchAdminControlsOnce(
codeAssistServer,
adminControlsEnabled,
);
// Admin settings are able to be undefined if unset, but if any are present,
// we should initialize them all.
// If any are present, undefined settings should be treated as if they were
// set to false.
// If NONE are present, disregard admin settings entirely, and pass the
// final config as is.
if (Object.keys(adminSettings).length !== 0) {
finalConfigParams.disableYoloMode = !adminSettings.strictModeDisabled;
finalConfigParams.mcpEnabled = adminSettings.mcpSetting?.mcpEnabled;
finalConfigParams.extensionsEnabled =
adminSettings.cliFeatureSetting?.extensionsSetting?.extensionsEnabled;
}
}
const config = new Config(finalConfigParams);
// Needed to initialize ToolRegistry, and git checkpointing if enabled
await config.initialize();
startupProfiler.flush(config);
if (process.env['USE_CCPA']) {
logger.info('[Config] Using CCPA Auth:');
try {
if (adcFilePath) {
path.resolve(adcFilePath);
}
} catch (e) {
logger.error(
`[Config] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
);
}
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
logger.info(
`[Config] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
} else if (process.env['GEMINI_API_KEY']) {
logger.info('[Config] Using Gemini API Key');
await config.refreshAuth(AuthType.USE_GEMINI);
} else {
const errorMessage =
'[Config] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.';
logger.error(errorMessage);
throw new Error(errorMessage);
}
await refreshAuthentication(config, adcFilePath, 'Config');
return config;
}
@@ -190,3 +234,33 @@ function findEnvFile(startDir: string): string | null {
currentDir = parentDir;
}
}
async function refreshAuthentication(
config: Config,
adcFilePath: string | undefined,
logPrefix: string,
): Promise<void> {
if (process.env['USE_CCPA']) {
logger.info(`[${logPrefix}] Using CCPA Auth:`);
try {
if (adcFilePath) {
path.resolve(adcFilePath);
}
} catch (e) {
logger.error(
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
);
}
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
} else if (process.env['GEMINI_API_KEY']) {
logger.info(`[${logPrefix}] Using Gemini API Key`);
await config.refreshAuth(AuthType.USE_GEMINI);
} else {
const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
}
+2 -2
View File
@@ -11,10 +11,10 @@ import {
type MCPServerConfig,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
homedir,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { logger } from '../utils/logger.js';
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
@@ -39,7 +39,7 @@ interface ExtensionConfig {
export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] {
const allExtensions = [
...loadExtensionsFromDir(workspaceDir),
...loadExtensionsFromDir(os.homedir()),
...loadExtensionsFromDir(homedir()),
];
const uniqueExtensions: GeminiCLIExtension[] = [];
+15 -68
View File
@@ -27,13 +27,21 @@ vi.mock('node:os', async (importOriginal) => {
};
});
vi.mock('@google/gemini-cli-core', () => ({
GEMINI_DIR: '.gemini',
debugLogger: {
error: vi.fn(),
},
getErrorMessage: (error: unknown) => String(error),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const path = await import('node:path');
const os = await import('node:os');
return {
...actual,
GEMINI_DIR: '.gemini',
debugLogger: {
error: vi.fn(),
},
getErrorMessage: (error: unknown) => String(error),
homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`),
};
});
describe('loadSettings', () => {
const mockHomeDir = path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`);
@@ -81,67 +89,6 @@ describe('loadSettings', () => {
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,
+3 -4
View File
@@ -6,7 +6,6 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { homedir } from 'node:os';
import type { MCPServerConfig } from '@google/gemini-cli-core';
import {
@@ -14,6 +13,7 @@ import {
GEMINI_DIR,
getErrorMessage,
type TelemetrySettings,
homedir,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
@@ -31,14 +31,13 @@ export interface Settings {
showMemoryUsage?: boolean;
checkpointing?: CheckpointingSettings;
folderTrust?: boolean;
general?: {
previewFeatures?: boolean;
};
// Git-aware file filtering settings
fileFiltering?: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
enableRecursiveFileSearch?: boolean;
customIgnoreFilePaths?: string[];
};
}
+41 -2
View File
@@ -14,7 +14,7 @@ import type {
TaskStatusUpdateEvent,
SendStreamingMessageSuccessResponse,
} from '@a2a-js/sdk';
import type express from 'express';
import express from 'express';
import type { Server } from 'node:http';
import request from 'supertest';
import {
@@ -27,7 +27,7 @@ import {
it,
vi,
} from 'vitest';
import { createApp } from './app.js';
import { createApp, main } from './app.js';
import { commandRegistry } from '../commands/command-registry.js';
import {
assertUniqueFinalEventIsLast,
@@ -1176,4 +1176,43 @@ describe('E2E Tests', () => {
});
});
});
describe('main', () => {
it('should listen on localhost only', async () => {
const listenSpy = vi
.spyOn(express.application, 'listen')
.mockImplementation((...args: unknown[]) => {
// Trigger the callback passed to listen
const callback = args.find(
(arg): arg is () => void => typeof arg === 'function',
);
if (callback) {
callback();
}
return {
address: () => ({ port: 1234 }),
on: vi.fn(),
once: vi.fn(),
emit: vi.fn(),
} as unknown as Server;
});
// Avoid process.exit if possible, or mock it if main might fail
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
await main();
expect(listenSpy).toHaveBeenCalledWith(
expect.any(Number),
'localhost',
expect.any(Function),
);
listenSpy.mockRestore();
exitSpy.mockRestore();
});
});
});
+2 -2
View File
@@ -326,9 +326,9 @@ export async function createApp() {
export async function main() {
try {
const expressApp = await createApp();
const port = process.env['CODER_AGENT_PORT'] || 0;
const port = Number(process.env['CODER_AGENT_PORT'] || 0);
const server = expressApp.listen(port, () => {
const server = expressApp.listen(port, 'localhost', () => {
const address = server.address();
let actualPort;
if (process.env['CODER_AGENT_PORT']) {
+1 -1
View File
@@ -9,7 +9,7 @@ import { gzipSync, gunzipSync } from 'node:zlib';
import * as tar from 'tar';
import * as fse from 'fs-extra';
import { promises as fsPromises, createReadStream } from 'node:fs';
import { tmpdir } from 'node:os';
import { tmpdir } from '@google/gemini-cli-core';
import { join } from 'node:path';
import type { Task as SDKTask } from '@a2a-js/sdk';
import type { TaskStore } from '@a2a-js/sdk/server';
+12 -2
View File
@@ -12,10 +12,10 @@ import type {
import {
ApprovalMode,
DEFAULT_GEMINI_MODEL,
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
GeminiClient,
HookSystem,
PolicyDecision,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import type { Config, Storage } from '@google/gemini-cli-core';
@@ -46,7 +46,6 @@ export function createMockConfig(
} as Storage,
getTruncateToolOutputThreshold: () =>
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL),
getDebugMode: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }),
@@ -77,6 +76,17 @@ export function createMockConfig(
mockConfig.getGeminiClient = vi
.fn()
.mockReturnValue(new GeminiClient(mockConfig));
mockConfig.getPolicyEngine = vi.fn().mockReturnValue({
check: async () => {
const mode = mockConfig.getApprovalMode();
if (mode === ApprovalMode.YOLO) {
return { decision: PolicyDecision.ALLOW };
}
return { decision: PolicyDecision.ASK_USER };
},
});
return mockConfig;
}