Compare commits

...

15 Commits

Author SHA1 Message Date
gemini-cli-robot 40dfe27ed0 fix(patch): cherry-pick 356f76e to release/v0.23.0-pr-16252 [CONFLICTS] (#16551)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: galz10 <galzahavi@google.com>
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2026-01-13 22:50:18 +00:00
gemini-cli-robot 2519a7850a chore(release): v0.23.0 2026-01-07 01:57:32 +00:00
gemini-cli-robot 3ff055840e chore(release): v0.23.0-preview.6 2026-01-07 01:10:50 +00:00
gemini-cli-robot 42a36294a8 fix(patch): cherry-pick 788bb04 to release/v0.23.0-preview.5-pr-15817 [CONFLICTS] (#16038)
Co-authored-by: Sehoon Shon <sshon@google.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: galz10 <galzahavi@google.com>
2026-01-06 16:51:22 -08:00
gemini-cli-robot ecbab46394 chore(release): v0.23.0-preview.5 2026-01-06 23:37:05 +00:00
gemini-cli-robot 17fb758664 fix(patch): cherry-pick c31f053 to release/v0.23.0-preview.4-pr-16004 to patch version v0.23.0-preview.4 and create version 0.23.0-preview.5 (#16027) 2026-01-06 23:13:30 +00:00
gemini-cli-robot 518cc1ab63 chore(release): v0.23.0-preview.4 2025-12-30 23:12:24 +00:00
gemini-cli-robot b7ad7e1035 fix(patch): cherry-pick 07e597d to release/v0.23.0-preview.3-pr-15684 [CONFLICTS] (#15734)
Co-authored-by: Sehoon Shon <sshon@google.com>
2025-12-30 21:23:29 +00:00
gemini-cli-robot dbcad90661 chore(release): v0.23.0-preview.3 2025-12-27 00:40:19 +00:00
gemini-cli-robot 703d2e0dcc fix(patch): cherry-pick 37be162 to release/v0.23.0-preview.2-pr-15601 to patch version v0.23.0-preview.2 and create version 0.23.0-preview.3 (#15603)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-12-27 00:16:57 +00:00
gemini-cli-robot 7d8ab08adb chore(release): v0.23.0-preview.2 2025-12-26 18:30:44 +00:00
gemini-cli-robot bc40695ce4 fix(patch): cherry-pick 9cdb267 to release/v0.23.0-preview.1-pr-15494 to patch version v0.23.0-preview.1 and create version 0.23.0-preview.2 (#15592)
Co-authored-by: Sehoon Shon <sshon@google.com>
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2025-12-26 18:05:28 +00:00
gemini-cli-robot 646dc3154b chore(release): v0.23.0-preview.1 2025-12-22 22:12:03 +00:00
gemini-cli-robot 7b772e9dfb fix(patch): cherry-pick 0843d9a to release/v0.23.0-preview.0-pr-15443 to patch version v0.23.0-preview.0 and create version 0.23.0-preview.1 (#15445)
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
2025-12-22 21:36:54 +00:00
gemini-cli-robot ef1e18a85a chore(release): v0.23.0-preview.0 2025-12-22 16:19:52 +00:00
42 changed files with 747 additions and 1260 deletions
+17 -10
View File
@@ -12,7 +12,7 @@ import prettierConfig from 'eslint-config-prettier';
import importPlugin from 'eslint-plugin-import';
import vitest from '@vitest/eslint-plugin';
import globals from 'globals';
import licenseHeader from 'eslint-plugin-license-header';
import headers from 'eslint-plugin-headers';
import path from 'node:path';
import url from 'node:url';
@@ -209,19 +209,26 @@ export default tseslint.config(
{
files: ['./**/*.{tsx,ts,js}'],
plugins: {
'license-header': licenseHeader,
headers,
import: importPlugin,
},
rules: {
'license-header/header': [
'headers/header-format': [
'error',
[
'/**',
' * @license',
' * Copyright 2025 Google LLC',
' * SPDX-License-Identifier: Apache-2.0',
' */',
],
{
source: 'string',
content: [
'@license',
'Copyright (year) Google LLC',
'SPDX-License-Identifier: Apache-2.0',
].join('\n'),
patterns: {
year: {
pattern: '202[5-6]',
defaultValue: '2026',
},
},
},
],
'import/enforce-node-protocol-usage': ['error', 'always'],
},
+3 -1
View File
@@ -323,7 +323,9 @@ export class TestRig {
ui: {
useAlternateBuffer: true,
},
model: DEFAULT_GEMINI_MODEL,
model: {
name: DEFAULT_GEMINI_MODEL,
},
sandbox:
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
// Don't show the IDE connection dialog when running from VsCode
+21 -28
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"workspaces": [
"packages/*"
],
@@ -36,8 +36,8 @@
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-headers": "^1.3.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-license-header": "^0.8.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^12.0.0",
@@ -8865,6 +8865,19 @@
"ms": "^2.1.1"
}
},
"node_modules/eslint-plugin-headers": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-headers/-/eslint-plugin-headers-1.3.3.tgz",
"integrity": "sha512-VzZY4+cGRoR5HpALLARH+ibIjB6a2w12/cFEayORHXMRHMzDnweSjpmvxyzX3rsSIVCg01zmvepB7Tnmaj4kGQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^16.0.0 || >= 18.0.0"
},
"peerDependencies": {
"eslint": ">=7"
}
},
"node_modules/eslint-plugin-import": {
"version": "2.32.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
@@ -8919,16 +8932,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/eslint-plugin-license-header": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-license-header/-/eslint-plugin-license-header-0.8.0.tgz",
"integrity": "sha512-khTCz6G3JdoQfwrtY4XKl98KW4PpnWUKuFx8v+twIRhJADEyYglMDC0td8It75C1MZ88gcvMusWuUlJsos7gYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"requireindex": "^1.2.0"
}
},
"node_modules/eslint-plugin-react": {
"version": "7.37.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
@@ -15069,16 +15072,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/requireindex": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz",
"integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.5"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -18473,7 +18466,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.7",
"@google-cloud/storage": "^7.16.0",
@@ -18783,7 +18776,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.11.0",
@@ -18886,7 +18879,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"license": "Apache-2.0",
"dependencies": {
"@google-cloud/logging": "^11.2.1",
@@ -19026,7 +19019,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -19037,7 +19030,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-nightly.20251220.41a1a3eed"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.23.0"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -94,8 +94,8 @@
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-headers": "^1.3.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-license-header": "^0.8.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^12.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-nightly.20251220.41a1a3eed"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.23.0"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.11.0",
+1 -817
View File
@@ -36,7 +36,7 @@ vi.mock('./trustedFolders.js', () => ({
}));
// NOW import everything else, including the (now effectively re-exported) settings.js
import path, * as pathActual from 'node:path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH
import * as pathActual from 'node:path'; // Restored for MOCK_WORKSPACE_SETTINGS_PATH
import {
describe,
it,
@@ -57,17 +57,11 @@ import {
USER_SETTINGS_PATH, // This IS the mocked path.
getSystemSettingsPath,
getSystemDefaultsPath,
migrateSettingsToV1,
needsMigration,
type Settings,
loadEnvironment,
migrateDeprecatedSettings,
SettingScope,
saveSettings,
type SettingsFile,
} from './settings.js';
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
import { ExtensionManager } from './extension-manager.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
@@ -283,169 +277,6 @@ describe('Settings Loading and Merging', () => {
});
});
it('should correctly migrate a complex legacy (v1) settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const legacySettingsContent = {
theme: 'legacy-dark',
vimMode: true,
contextFileName: 'LEGACY_CONTEXT.md',
model: 'gemini-2.5-pro',
mcpServers: {
'legacy-server-1': {
command: 'npm',
args: ['run', 'start:server1'],
description: 'Legacy Server 1',
},
'legacy-server-2': {
command: 'node',
args: ['server2.js'],
description: 'Legacy Server 2',
},
},
allowMCPServers: ['legacy-server-1'],
someUnrecognizedSetting: 'should-be-preserved',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacySettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged).toEqual({
ui: {
theme: 'legacy-dark',
},
general: {
vimMode: true,
},
context: {
fileName: 'LEGACY_CONTEXT.md',
},
model: {
name: 'gemini-2.5-pro',
},
mcpServers: {
'legacy-server-1': {
command: 'npm',
args: ['run', 'start:server1'],
description: 'Legacy Server 1',
},
'legacy-server-2': {
command: 'node',
args: ['server2.js'],
description: 'Legacy Server 2',
},
},
mcp: {
allowed: ['legacy-server-1'],
},
someUnrecognizedSetting: 'should-be-preserved',
});
});
it('should rewrite allowedTools to tools.allowed during migration', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const legacySettingsContent = {
allowedTools: ['fs', 'shell'],
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacySettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.tools?.allowed).toEqual(['fs', 'shell']);
expect((settings.merged as TestSettings)['allowedTools']).toBeUndefined();
});
it('should allow V2 settings to override V1 settings when both are present (zombie setting fix)', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const mixedSettingsContent = {
// V1 setting (migrates to ui.accessibility.screenReader = true)
accessibility: {
screenReader: true,
},
// V2 setting (explicitly set to false)
ui: {
accessibility: {
screenReader: false,
},
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(mixedSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// We expect the V2 setting (false) to win, NOT the migrated V1 setting (true)
expect(settings.merged.ui?.accessibility?.screenReader).toBe(false);
});
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const legacyUserSettings = {
includeDirectories: ['/user/dir'],
excludeTools: ['user-tool'],
excludedProjectEnvVars: ['USER_VAR'],
};
const legacyWorkspaceSettings = {
includeDirectories: ['/workspace/dir'],
excludeTools: ['workspace-tool'],
excludedProjectEnvVars: ['WORKSPACE_VAR', 'USER_VAR'],
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacyUserSettings);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(legacyWorkspaceSettings);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Verify includeDirectories are concatenated
expect(settings.merged.context?.includeDirectories).toEqual([
'/user/dir',
'/workspace/dir',
]);
// Verify excludeTools are concatenated and de-duped
expect(settings.merged.tools?.exclude).toEqual([
'user-tool',
'workspace-tool',
]);
// Verify excludedProjectEnvVars are concatenated and de-duped
expect(settings.merged.advanced?.excludedEnvVars).toEqual(
expect.arrayContaining(['USER_VAR', 'WORKSPACE_VAR']),
);
expect(settings.merged.advanced?.excludedEnvVars).toHaveLength(2);
});
it('should merge all settings files with the correct precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const systemDefaultsContent = {
@@ -1740,653 +1571,6 @@ describe('Settings Loading and Merging', () => {
});
});
describe('with workspace trust', () => {
it('should merge workspace settings when workspace is trusted', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
ui: { theme: 'dark' },
tools: { sandbox: false },
};
const workspaceSettingsContent = {
tools: { sandbox: true },
context: { fileName: 'WORKSPACE.md' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.tools?.sandbox).toBe(true);
expect(settings.merged.context?.fileName).toBe('WORKSPACE.md');
expect(settings.merged.ui?.theme).toBe('dark');
});
it('should NOT merge workspace settings when workspace is not trusted', () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
ui: { theme: 'dark' },
tools: { sandbox: false },
context: { fileName: 'USER.md' },
};
const workspaceSettingsContent = {
tools: { sandbox: true },
context: { fileName: 'WORKSPACE.md' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.tools?.sandbox).toBe(false); // User setting
expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting
expect(settings.merged.ui?.theme).toBe('dark'); // User setting
});
});
describe('migrateSettingsToV1', () => {
it('should handle an empty object', () => {
const v2Settings = {};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({});
});
it('should migrate a simple v2 settings object to v1', () => {
const v2Settings = {
general: {
preferredEditor: 'vscode',
vimMode: true,
},
ui: {
theme: 'dark',
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
preferredEditor: 'vscode',
vimMode: true,
theme: 'dark',
});
});
it('should handle nested properties correctly', () => {
const v2Settings = {
security: {
folderTrust: {
enabled: true,
},
auth: {
selectedType: 'oauth',
},
},
advanced: {
autoConfigureMemory: true,
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
folderTrust: true,
selectedAuthType: 'oauth',
autoConfigureMaxOldSpaceSize: true,
});
});
it('should preserve mcpServers at the top level', () => {
const v2Settings = {
general: {
preferredEditor: 'vscode',
},
mcpServers: {
'my-server': {
command: 'npm start',
},
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
preferredEditor: 'vscode',
mcpServers: {
'my-server': {
command: 'npm start',
},
},
});
});
it('should carry over unrecognized top-level properties', () => {
const v2Settings = {
general: {
vimMode: false,
},
unrecognized: 'value',
another: {
nested: true,
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
vimMode: false,
unrecognized: 'value',
another: {
nested: true,
},
});
});
it('should handle a complex object with mixed properties', () => {
const v2Settings = {
general: {
disableAutoUpdate: true,
},
ui: {
hideBanner: true,
customThemes: {
myTheme: {},
},
},
model: {
name: 'gemini-pro',
},
mcpServers: {
'server-1': {
command: 'node server.js',
},
},
unrecognized: {
should: 'be-preserved',
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
disableAutoUpdate: true,
hideBanner: true,
customThemes: {
myTheme: {},
},
model: 'gemini-pro',
mcpServers: {
'server-1': {
command: 'node server.js',
},
},
unrecognized: {
should: 'be-preserved',
},
});
});
it('should not migrate a v1 settings object', () => {
const v1Settings = {
preferredEditor: 'vscode',
vimMode: true,
theme: 'dark',
};
const migratedSettings = migrateSettingsToV1(v1Settings);
expect(migratedSettings).toEqual({
preferredEditor: 'vscode',
vimMode: true,
theme: 'dark',
});
});
it('should migrate a full v2 settings object to v1', () => {
const v2Settings: TestSettings = {
general: {
preferredEditor: 'code',
vimMode: true,
},
ui: {
theme: 'dark',
},
privacy: {
usageStatisticsEnabled: false,
},
model: {
name: 'gemini-2.5-pro',
},
context: {
fileName: 'CONTEXT.md',
includeDirectories: ['/src'],
},
tools: {
sandbox: true,
exclude: ['toolA'],
},
mcp: {
allowed: ['server1'],
},
security: {
folderTrust: {
enabled: true,
},
},
advanced: {
dnsResolutionOrder: 'ipv4first',
excludedEnvVars: ['SECRET'],
},
mcpServers: {
'my-server': {
command: 'npm start',
},
},
unrecognizedTopLevel: {
value: 'should be preserved',
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
preferredEditor: 'code',
vimMode: true,
theme: 'dark',
usageStatisticsEnabled: false,
model: 'gemini-2.5-pro',
contextFileName: 'CONTEXT.md',
includeDirectories: ['/src'],
sandbox: true,
excludeTools: ['toolA'],
allowMCPServers: ['server1'],
folderTrust: true,
dnsResolutionOrder: 'ipv4first',
excludedProjectEnvVars: ['SECRET'],
mcpServers: {
'my-server': {
command: 'npm start',
},
},
unrecognizedTopLevel: {
value: 'should be preserved',
},
});
});
it('should handle partial v2 settings', () => {
const v2Settings: TestSettings = {
general: {
vimMode: false,
},
ui: {},
model: {
name: 'gemini-2.5-pro',
},
unrecognized: 'value',
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
vimMode: false,
model: 'gemini-2.5-pro',
unrecognized: 'value',
});
});
it('should handle settings with different data types', () => {
const v2Settings: TestSettings = {
general: {
vimMode: false,
},
model: {
maxSessionTurns: -1,
},
context: {
includeDirectories: [],
},
security: {
folderTrust: {
enabled: undefined,
},
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
vimMode: false,
maxSessionTurns: -1,
includeDirectories: [],
security: {
folderTrust: {
enabled: undefined,
},
},
});
});
it('should preserve unrecognized top-level keys', () => {
const v2Settings: TestSettings = {
general: {
vimMode: true,
},
customTopLevel: {
a: 1,
b: [2],
},
anotherOne: 'hello',
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
vimMode: true,
customTopLevel: {
a: 1,
b: [2],
},
anotherOne: 'hello',
});
});
it('should handle an empty v2 settings object', () => {
const v2Settings = {};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({});
});
it('should correctly handle mcpServers at the top level', () => {
const v2Settings: TestSettings = {
mcpServers: {
serverA: { command: 'a' },
},
mcp: {
allowed: ['serverA'],
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
expect(v1Settings).toEqual({
mcpServers: {
serverA: { command: 'a' },
},
allowMCPServers: ['serverA'],
});
});
it('should correctly migrate customWittyPhrases', () => {
const v2Settings: Partial<Settings> = {
ui: {
customWittyPhrases: ['test phrase'],
},
};
const v1Settings = migrateSettingsToV1(v2Settings as Settings);
expect(v1Settings).toEqual({
customWittyPhrases: ['test phrase'],
});
});
});
describe('loadEnvironment', () => {
function setup({
isFolderTrustEnabled = true,
isWorkspaceTrustedValue = true,
}) {
delete process.env['TESTTEST']; // reset
const geminiEnvPath = path.resolve(path.join(GEMINI_DIR, '.env'));
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: isWorkspaceTrustedValue,
source: 'file',
});
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
);
const userSettingsContent: Settings = {
ui: {
theme: 'dark',
},
security: {
folderTrust: {
enabled: isFolderTrustEnabled,
},
},
context: {
fileName: 'USER_CONTEXT.md',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === geminiEnvPath) return 'TESTTEST=1234';
return '{}';
},
);
}
it('sets environment variables from .env files', () => {
setup({ isFolderTrustEnabled: false, isWorkspaceTrustedValue: true });
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
expect(process.env['TESTTEST']).toEqual('1234');
});
it('does not load env files from untrusted spaces', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
expect(process.env['TESTTEST']).not.toEqual('1234');
});
});
describe('needsMigration', () => {
it('should return false for an empty object', () => {
expect(needsMigration({})).toBe(false);
});
it('should return false for settings that are already in V2 format', () => {
const v2Settings: Partial<Settings> = {
ui: {
theme: 'dark',
},
tools: {
sandbox: true,
},
};
expect(needsMigration(v2Settings)).toBe(false);
});
it('should return true for settings with a V1 key that needs to be moved', () => {
const v1Settings = {
theme: 'dark', // v1 key
};
expect(needsMigration(v1Settings)).toBe(true);
});
it('should return true for settings with a mix of V1 and V2 keys', () => {
const mixedSettings = {
theme: 'dark', // v1 key
tools: {
sandbox: true, // v2 key
},
};
expect(needsMigration(mixedSettings)).toBe(true);
});
it('should return false for settings with only V1 keys that are the same in V2', () => {
const v1Settings = {
mcpServers: {},
telemetry: {},
extensions: [],
};
expect(needsMigration(v1Settings)).toBe(false);
});
it('should return true for settings with a mix of V1 keys that are the same in V2 and V1 keys that need moving', () => {
const v1Settings = {
mcpServers: {}, // same in v2
theme: 'dark', // needs moving
};
expect(needsMigration(v1Settings)).toBe(true);
});
it('should return false for settings with unrecognized keys', () => {
const settings = {
someUnrecognizedKey: 'value',
};
expect(needsMigration(settings)).toBe(false);
});
it('should return false for settings with v2 keys and unrecognized keys', () => {
const settings = {
ui: { theme: 'dark' },
someUnrecognizedKey: 'value',
};
expect(needsMigration(settings)).toBe(false);
});
});
describe('migrateDeprecatedSettings', () => {
let mockFsExistsSync: Mock;
let mockFsReadFileSync: Mock;
beforeEach(() => {
vi.resetAllMocks();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockFsExistsSync.mockReturnValue(true);
mockFsReadFileSync = vi.mocked(fs.readFileSync);
mockFsReadFileSync.mockReturnValue('{}');
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: undefined,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should migrate disabled extensions from user and workspace settings', () => {
const userSettingsContent = {
extensions: {
disabled: ['user-ext-1', 'shared-ext'],
},
};
const workspaceSettingsContent = {
extensions: {
disabled: ['workspace-ext-1', 'shared-ext'],
},
};
mockFsReadFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
});
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
const extensionManager = new ExtensionManager({
settings: loadedSettings.merged,
workspaceDir: MOCK_WORKSPACE_DIR,
requestConsent: vi.fn(),
requestSetting: vi.fn(),
});
const mockDisableExtension = vi.spyOn(
extensionManager,
'disableExtension',
);
mockDisableExtension.mockImplementation(async () => {});
migrateDeprecatedSettings(loadedSettings, extensionManager);
// Check user settings migration
expect(mockDisableExtension).toHaveBeenCalledWith(
'user-ext-1',
SettingScope.User,
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'shared-ext',
SettingScope.User,
);
// Check workspace settings migration
expect(mockDisableExtension).toHaveBeenCalledWith(
'workspace-ext-1',
SettingScope.Workspace,
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'shared-ext',
SettingScope.Workspace,
);
// Check that setValue was called to remove the deprecated setting
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'extensions',
{
disabled: undefined,
},
);
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.Workspace,
'extensions',
{
disabled: undefined,
},
);
});
it('should not do anything if there are no deprecated settings', () => {
const userSettingsContent = {
extensions: {
enabled: ['user-ext-1'],
},
};
const workspaceSettingsContent = {
someOtherSetting: 'value',
};
mockFsReadFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
});
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
const extensionManager = new ExtensionManager({
settings: loadedSettings.merged,
workspaceDir: MOCK_WORKSPACE_DIR,
requestConsent: vi.fn(),
requestSetting: vi.fn(),
});
const mockDisableExtension = vi.spyOn(
extensionManager,
'disableExtension',
);
mockDisableExtension.mockImplementation(async () => {});
migrateDeprecatedSettings(loadedSettings, extensionManager);
expect(mockDisableExtension).not.toHaveBeenCalled();
expect(setValueSpy).not.toHaveBeenCalled();
});
});
describe('saveSettings', () => {
it('should save settings using updateSettingsFilePreservingFormat', () => {
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
+26 -305
View File
@@ -10,7 +10,6 @@ import { homedir, platform } from 'node:os';
import * as dotenv from 'dotenv';
import process from 'node:process';
import {
debugLogger,
FatalConfigError,
GEMINI_DIR,
getErrorMessage,
@@ -30,14 +29,12 @@ import {
getSettingsSchema,
} from './settingsSchema.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
import { customDeepMerge } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import type { ExtensionManager } from './extension-manager.js';
import {
validateSettings,
formatValidationError,
} from './settings-validation.js';
import { SettingPaths } from './settingPaths.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
@@ -66,79 +63,6 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
const MIGRATE_V2_OVERWRITE = true;
const MIGRATION_MAP: Record<string, string> = {
accessibility: 'ui.accessibility',
allowedTools: 'tools.allowed',
allowMCPServers: 'mcp.allowed',
autoAccept: 'tools.autoAccept',
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
bugCommand: 'advanced.bugCommand',
chatCompression: 'model.compressionThreshold',
checkpointing: 'general.checkpointing',
coreTools: 'tools.core',
contextFileName: 'context.fileName',
customThemes: 'ui.customThemes',
customWittyPhrases: 'ui.customWittyPhrases',
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
disableAutoUpdate: 'general.disableAutoUpdate',
disableUpdateNag: 'general.disableUpdateNag',
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
enableMessageBusIntegration: 'tools.enableMessageBusIntegration',
enableHooks: 'tools.enableHooks',
enablePromptCompletion: 'general.enablePromptCompletion',
enforcedAuthType: 'security.auth.enforcedType',
excludeTools: 'tools.exclude',
excludeMCPServers: 'mcp.excluded',
excludedProjectEnvVars: 'advanced.excludedEnvVars',
extensionManagement: 'experimental.extensionManagement',
extensions: 'extensions',
fileFiltering: 'context.fileFiltering',
folderTrustFeature: 'security.folderTrust.featureEnabled',
folderTrust: 'security.folderTrust.enabled',
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
hideWindowTitle: 'ui.hideWindowTitle',
showStatusInTitle: 'ui.showStatusInTitle',
hideTips: 'ui.hideTips',
hideBanner: 'ui.hideBanner',
hideFooter: 'ui.hideFooter',
hideCWD: 'ui.footer.hideCWD',
hideSandboxStatus: 'ui.footer.hideSandboxStatus',
hideModelInfo: 'ui.footer.hideModelInfo',
hideContextSummary: 'ui.hideContextSummary',
showMemoryUsage: 'ui.showMemoryUsage',
showLineNumbers: 'ui.showLineNumbers',
showCitations: 'ui.showCitations',
ideMode: 'ide.enabled',
includeDirectories: 'context.includeDirectories',
loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories',
maxSessionTurns: 'model.maxSessionTurns',
mcpServers: 'mcpServers',
mcpServerCommand: 'mcp.serverCommand',
memoryImportFormat: 'context.importFormat',
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
model: 'model.name',
preferredEditor: SettingPaths.General.PreferredEditor,
retryFetchErrors: 'general.retryFetchErrors',
sandbox: 'tools.sandbox',
selectedAuthType: 'security.auth.selectedType',
enableInteractiveShell: 'tools.shell.enableInteractiveShell',
shellPager: 'tools.shell.pager',
shellShowColor: 'tools.shell.showColor',
shellInactivityTimeout: 'tools.shell.inactivityTimeout',
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
summarizeToolOutput: 'model.summarizeToolOutput',
telemetry: 'telemetry',
theme: 'ui.theme',
toolDiscoveryCommand: 'tools.discoveryCommand',
toolCallCommand: 'tools.callCommand',
usageStatisticsEnabled: 'privacy.usageStatisticsEnabled',
useExternalAuth: 'security.auth.useExternal',
useRipgrep: 'tools.useRipgrep',
vimMode: 'general.vimMode',
};
export function getSystemSettingsPath(): string {
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
@@ -267,160 +191,22 @@ function setNestedProperty(
current[lastKey] = value;
}
export function needsMigration(settings: Record<string, unknown>): boolean {
// A file needs migration if it contains any top-level key that is moved to a
// nested location in V2.
const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => {
if (v1Key === v2Path || !(v1Key in settings)) {
return false;
}
// If a key exists that is a V1 key and a V2 container (like 'model'),
// we need to check the type. If it's an object, it's a V2 container and not
// a V1 key that needs migration.
if (
KNOWN_V2_CONTAINERS.has(v1Key) &&
typeof settings[v1Key] === 'object' &&
settings[v1Key] !== null
) {
return false;
}
return true;
});
return hasV1Keys;
}
function migrateSettingsToV2(
flatSettings: Record<string, unknown>,
): Record<string, unknown> | null {
if (!needsMigration(flatSettings)) {
return null;
}
const v2Settings: Record<string, unknown> = {};
const flatKeys = new Set(Object.keys(flatSettings));
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
if (flatKeys.has(oldKey)) {
// If the key exists and is a V2 container (like 'model'), and the value is an object,
// it is likely already migrated or partially migrated. We should not move it
// to the mapped V2 path (e.g. 'model' -> 'model.name').
// Instead, let it fall through to the "Carry over" section to be merged.
if (
KNOWN_V2_CONTAINERS.has(oldKey) &&
typeof flatSettings[oldKey] === 'object' &&
flatSettings[oldKey] !== null &&
!Array.isArray(flatSettings[oldKey])
) {
continue;
export function getDefaultsFromSchema(
schema: SettingsSchema = getSettingsSchema(),
): Settings {
const defaults: Record<string, unknown> = {};
for (const key in schema) {
const definition = schema[key];
if (definition.properties) {
const childDefaults = getDefaultsFromSchema(definition.properties);
if (Object.keys(childDefaults).length > 0) {
defaults[key] = childDefaults;
}
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
flatKeys.delete(oldKey);
} else if (definition.default !== undefined) {
defaults[key] = definition.default;
}
}
// Preserve mcpServers at the top level
if (flatSettings['mcpServers']) {
v2Settings['mcpServers'] = flatSettings['mcpServers'];
flatKeys.delete('mcpServers');
}
// Carry over any unrecognized keys
for (const remainingKey of flatKeys) {
const existingValue = v2Settings[remainingKey];
const newValue = flatSettings[remainingKey];
if (
typeof existingValue === 'object' &&
existingValue !== null &&
!Array.isArray(existingValue) &&
typeof newValue === 'object' &&
newValue !== null &&
!Array.isArray(newValue)
) {
const pathAwareGetStrategy = (path: string[]) =>
getMergeStrategyForPath([remainingKey, ...path]);
v2Settings[remainingKey] = customDeepMerge(
pathAwareGetStrategy,
{},
existingValue as MergeableObject,
newValue as MergeableObject,
);
} else {
v2Settings[remainingKey] = newValue;
}
}
return v2Settings;
}
function getNestedProperty(
obj: Record<string, unknown>,
path: string,
): unknown {
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (typeof current !== 'object' || current === null || !(key in current)) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
const REVERSE_MIGRATION_MAP: Record<string, string> = Object.fromEntries(
Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]),
);
// Dynamically determine the top-level keys from the V2 settings structure.
const KNOWN_V2_CONTAINERS = new Set(
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
);
export function migrateSettingsToV1(
v2Settings: Record<string, unknown>,
): Record<string, unknown> {
const v1Settings: Record<string, unknown> = {};
const v2Keys = new Set(Object.keys(v2Settings));
for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) {
const value = getNestedProperty(v2Settings, newPath);
if (value !== undefined) {
v1Settings[oldKey] = value;
v2Keys.delete(newPath.split('.')[0]);
}
}
// Preserve mcpServers at the top level
if (v2Settings['mcpServers']) {
v1Settings['mcpServers'] = v2Settings['mcpServers'];
v2Keys.delete('mcpServers');
}
// Carry over any unrecognized keys
for (const remainingKey of v2Keys) {
const value = v2Settings[remainingKey];
if (value === undefined) {
continue;
}
// Don't carry over empty objects that were just containers for migrated settings.
if (
KNOWN_V2_CONTAINERS.has(remainingKey) &&
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length === 0
) {
continue;
}
v1Settings[remainingKey] = value;
}
return v1Settings;
return defaults as Settings;
}
function mergeSettings(
@@ -455,14 +241,14 @@ export class LoadedSettings {
user: SettingsFile,
workspace: SettingsFile,
isTrusted: boolean,
migratedInMemoryScopes: Set<SettingScope>,
errors: SettingsError[] = [],
) {
this.system = system;
this.systemDefaults = systemDefaults;
this.user = user;
this.workspace = workspace;
this.isTrusted = isTrusted;
this.migratedInMemoryScopes = migratedInMemoryScopes;
this.errors = errors;
this._merged = this.computeMergedSettings();
}
@@ -471,7 +257,7 @@ export class LoadedSettings {
readonly user: SettingsFile;
readonly workspace: SettingsFile;
readonly isTrusted: boolean;
readonly migratedInMemoryScopes: Set<SettingScope>;
readonly errors: SettingsError[];
private _merged: Settings;
@@ -620,7 +406,6 @@ export function loadSettings(
const settingsErrors: SettingsError[] = [];
const systemSettingsPath = getSystemSettingsPath();
const systemDefaultsPath = getSystemDefaultsPath();
const migratedInMemoryScopes = new Set<SettingScope>();
// Resolve paths to their canonical representation to handle symlinks
const resolvedWorkspaceDir = path.resolve(workspaceDir);
@@ -641,10 +426,7 @@ export function loadSettings(
workspaceDir,
).getWorkspaceSettingsPath();
const loadAndMigrate = (
filePath: string,
scope: SettingScope,
): { settings: Settings; rawJson?: string } => {
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
try {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
@@ -662,33 +444,9 @@ export function loadSettings(
return { settings: {} };
}
let settingsObject = rawSettings as Record<string, unknown>;
if (needsMigration(settingsObject)) {
const migratedSettings = migrateSettingsToV2(settingsObject);
if (migratedSettings) {
if (MIGRATE_V2_OVERWRITE) {
try {
fs.renameSync(filePath, `${filePath}.orig`);
fs.writeFileSync(
filePath,
JSON.stringify(migratedSettings, null, 2),
'utf-8',
);
} catch (e) {
coreEvents.emitFeedback(
'error',
'Failed to migrate settings file.',
e,
);
}
} else {
migratedInMemoryScopes.add(scope);
}
settingsObject = migratedSettings;
}
}
const settingsObject = rawSettings as Record<string, unknown>;
// Validate settings structure with Zod after migration
// Validate settings structure with Zod
const validationResult = validateSettings(settingsObject);
if (!validationResult.success && validationResult.error) {
const errorMessage = formatValidationError(
@@ -713,22 +471,16 @@ export function loadSettings(
return { settings: {} };
};
const systemResult = loadAndMigrate(systemSettingsPath, SettingScope.System);
const systemDefaultsResult = loadAndMigrate(
systemDefaultsPath,
SettingScope.SystemDefaults,
);
const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User);
const systemResult = load(systemSettingsPath);
const systemDefaultsResult = load(systemDefaultsPath);
const userResult = load(USER_SETTINGS_PATH);
let workspaceResult: { settings: Settings; rawJson?: string } = {
settings: {} as Settings,
rawJson: undefined,
};
if (realWorkspaceDir !== realHomeDir) {
workspaceResult = loadAndMigrate(
workspaceSettingsPath,
SettingScope.Workspace,
);
workspaceResult = load(workspaceSettingsPath);
}
const systemOriginalSettings = structuredClone(systemResult.settings);
@@ -816,36 +568,10 @@ export function loadSettings(
rawJson: workspaceResult.rawJson,
},
isTrusted,
migratedInMemoryScopes,
settingsErrors,
);
}
export function migrateDeprecatedSettings(
loadedSettings: LoadedSettings,
extensionManager: ExtensionManager,
): void {
const processScope = (scope: LoadableSettingScope) => {
const settings = loadedSettings.forScope(scope).settings;
if (settings.extensions?.disabled) {
debugLogger.log(
`Migrating deprecated extensions.disabled settings from ${scope} settings...`,
);
for (const extension of settings.extensions.disabled ?? []) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
extensionManager.disableExtension(extension, scope);
}
const newExtensionsValue = { ...settings.extensions };
newExtensionsValue.disabled = undefined;
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
}
};
processScope(SettingScope.User);
processScope(SettingScope.Workspace);
}
export function saveSettings(settingsFile: SettingsFile): void {
try {
// Ensure the directory exists
@@ -854,12 +580,7 @@ export function saveSettings(settingsFile: SettingsFile): void {
fs.mkdirSync(dirPath, { recursive: true });
}
let settingsToSave = settingsFile.originalSettings;
if (!MIGRATE_V2_OVERWRITE) {
settingsToSave = migrateSettingsToV1(
settingsToSave as Record<string, unknown>,
) as Settings;
}
const settingsToSave = settingsFile.originalSettings;
// Use the format-preserving update function
updateSettingsFilePreservingFormat(
+17 -19
View File
@@ -17,10 +17,10 @@ import dns from 'node:dns';
import { start_sandbox } from './utils/sandbox.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import {
loadSettings,
migrateDeprecatedSettings,
SettingScope,
} from './config/settings.js';
loadTrustedFolders,
type TrustedFoldersError,
} from './config/trustedFolders.js';
import { loadSettings, SettingScope } from './config/settings.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
@@ -90,9 +90,7 @@ import {
} from './utils/relaunch.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { ExtensionManager } from './config/extension-manager.js';
import { createPolicyUpdater } from './config/policy.js';
import { requestConsentNonInteractive } from './config/extensions/consent.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
@@ -299,19 +297,19 @@ export async function main() {
const settings = loadSettings();
loadSettingsHandle?.end();
const migrateHandle = startupProfiler.start('migrate_settings');
migrateDeprecatedSettings(
settings,
// Temporary extension manager only used during this non-interactive UI phase.
new ExtensionManager({
workspaceDir: process.cwd(),
settings: settings.merged,
enabledExtensionOverrides: [],
requestConsent: requestConsentNonInteractive,
requestSetting: null,
}),
);
migrateHandle?.end();
// Report settings errors once during startup
settings.errors.forEach((error) => {
coreEvents.emitFeedback('warning', error.message);
});
const trustedFolders = loadTrustedFolders();
trustedFolders.errors.forEach((error: TrustedFoldersError) => {
coreEvents.emitFeedback(
'warning',
`Error in ${error.path}: ${error.message}`,
);
});
await cleanupCheckpoints();
const parseArgsHandle = startupProfiler.start('parse_arguments');
+2 -2
View File
@@ -109,7 +109,7 @@ export const mockSettings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
[],
);
export const createMockSettings = (
@@ -122,7 +122,7 @@ export const createMockSettings = (
{ path: '', settings, originalSettings: settings },
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
[],
);
};
+2 -6
View File
@@ -14,11 +14,7 @@ import { StreamingState } from './types.js';
import { ConfigContext } from './contexts/ConfigContext.js';
import { AppContext, type AppState } from './contexts/AppContext.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import {
type SettingScope,
LoadedSettings,
type SettingsFile,
} from '../config/settings.js';
import { LoadedSettings, type SettingsFile } from '../config/settings.js';
vi.mock('ink', async (importOriginal) => {
const original = await importOriginal<typeof import('ink')>();
@@ -92,7 +88,7 @@ describe('App', () => {
mockSettingsFile,
mockSettingsFile,
true,
new Set<SettingScope>(),
[],
);
const mockAppState: AppState = {
@@ -32,6 +32,7 @@ describe('<AppHeader />', () => {
it('should render the banner with default text', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -52,6 +53,7 @@ describe('<AppHeader />', () => {
it('should render the banner with warning text', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: 'There are capacity issues',
@@ -72,6 +74,7 @@ describe('<AppHeader />', () => {
it('should not render the banner when no flags are set', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: '',
warningText: '',
@@ -91,6 +94,7 @@ describe('<AppHeader />', () => {
it('should render the banner when previewFeatures is disabled', () => {
const mockConfig = makeFakeConfig({ previewFeatures: false });
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -111,6 +115,7 @@ describe('<AppHeader />', () => {
it('should not render the banner when previewFeatures is enabled', () => {
const mockConfig = makeFakeConfig({ previewFeatures: true });
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -131,6 +136,7 @@ describe('<AppHeader />', () => {
persistentStateMock.get.mockReturnValue(5);
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -151,6 +157,7 @@ describe('<AppHeader />', () => {
persistentStateMock.get.mockReturnValue({});
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -177,6 +184,7 @@ describe('<AppHeader />', () => {
it('should render banner text with unescaped newlines', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'First line\\nSecond line',
warningText: '',
@@ -15,6 +15,9 @@ import { Text } from 'ink';
import type React from 'react';
vi.mock('../hooks/useTerminalSize.js');
vi.mock('../hooks/useSnowfall.js', () => ({
useSnowfall: vi.fn((art) => art),
}));
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: vi.fn(),
}));
@@ -159,7 +162,6 @@ describe('<Header />', () => {
render(<Header version="1.0.0" nightly={false} />);
expect(Gradient.default).not.toHaveBeenCalled();
const textCalls = (Text as Mock).mock.calls;
console.log(JSON.stringify(textCalls, null, 2));
expect(textCalls.length).toBe(1);
expect(textCalls[0][0]).toHaveProperty('color', singleColor);
});
+3 -1
View File
@@ -18,6 +18,7 @@ import {
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { getTerminalProgram } from '../utils/terminalSetup.js';
import { useSnowfall } from '../hooks/useSnowfall.js';
interface HeaderProps {
customAsciiArt?: string; // For user-defined ASCII art
@@ -47,6 +48,7 @@ export const Header: React.FC<HeaderProps> = ({
}
const artWidth = getAsciiArtWidth(displayTitle);
const title = useSnowfall(displayTitle);
return (
<Box
@@ -55,7 +57,7 @@ export const Header: React.FC<HeaderProps> = ({
flexShrink={0}
flexDirection="column"
>
<ThemedGradient>{displayTitle}</ThemedGradient>
<ThemedGradient>{title}</ThemedGradient>
{nightly && (
<Box width="100%" flexDirection="row" justifyContent="flex-end">
<ThemedGradient>v{version}</ThemedGradient>
@@ -104,7 +104,7 @@ const createMockSettings = (
path: '/workspace/settings.json',
},
true,
new Set(),
[],
);
vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
@@ -51,7 +51,7 @@ const createMockSettings = (
path: '/workspace/settings.json',
},
true,
new Set(),
[],
);
describe('ThemeDialog Snapshots', () => {
@@ -130,10 +130,10 @@ export function useQuotaAndFallback({
isDialogPending.current = false; // Reset the flag here
if (choice === 'retry_always') {
// Explicitly set the model to the fallback model to persist the user's choice.
// Set the model to the fallback model for the current session.
// This ensures the Footer updates and future turns use this model.
config.setModel(proQuotaRequest.fallbackModel);
// The change is not persisted, so the original model is restored on restart.
config.activateFallbackMode(proQuotaRequest.fallbackModel);
historyManager.addItem(
{
type: MessageType.INFO,
@@ -0,0 +1,108 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useSnowfall } from './useSnowfall.js';
import { themeManager } from '../themes/theme-manager.js';
import { renderHookWithProviders } from '../../test-utils/render.js';
import { act } from 'react';
import { debugState } from '../debug.js';
import type { Theme } from '../themes/theme.js';
import type { UIState } from '../contexts/UIStateContext.js';
vi.mock('../themes/theme-manager.js', () => ({
themeManager: {
getActiveTheme: vi.fn(),
},
}));
vi.mock('../themes/holiday.js', () => ({
Holiday: { name: 'Holiday' },
}));
vi.mock('./useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({ columns: 120, rows: 20 })),
}));
describe('useSnowfall', () => {
const mockArt = 'LOGO';
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.mocked(themeManager.getActiveTheme).mockReturnValue({
name: 'Holiday',
} as Theme);
vi.setSystemTime(new Date('2025-12-25'));
debugState.debugNumAnimatedComponents = 0;
});
afterEach(() => {
vi.useRealTimers();
});
it('initially enables animation during holiday season with Holiday theme', () => {
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
uiState: { history: [], historyRemountKey: 0 } as Partial<UIState>,
});
// Should contain holiday trees
expect(result.current).toContain('|_|');
// Should have started animation
expect(debugState.debugNumAnimatedComponents).toBeGreaterThan(0);
});
it('stops animation after 15 seconds', () => {
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
uiState: { history: [], historyRemountKey: 0 } as Partial<UIState>,
});
expect(debugState.debugNumAnimatedComponents).toBeGreaterThan(0);
act(() => {
vi.advanceTimersByTime(15001);
});
// Animation should be stopped
expect(debugState.debugNumAnimatedComponents).toBe(0);
// Should no longer contain trees
expect(result.current).toBe(mockArt);
});
it('does not enable animation if not holiday season', () => {
vi.setSystemTime(new Date('2025-06-15'));
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
uiState: { history: [], historyRemountKey: 0 } as Partial<UIState>,
});
expect(result.current).toBe(mockArt);
expect(debugState.debugNumAnimatedComponents).toBe(0);
});
it('does not enable animation if theme is not Holiday', () => {
vi.mocked(themeManager.getActiveTheme).mockReturnValue({
name: 'Default',
} as Theme);
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
uiState: { history: [], historyRemountKey: 0 } as Partial<UIState>,
});
expect(result.current).toBe(mockArt);
expect(debugState.debugNumAnimatedComponents).toBe(0);
});
it('does not enable animation if chat has started', () => {
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
uiState: {
history: [{ type: 'user', text: 'hello' }],
historyRemountKey: 0,
} as Partial<UIState>,
});
expect(result.current).toBe(mockArt);
expect(debugState.debugNumAnimatedComponents).toBe(0);
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { debugState } from '../debug.js';
import { themeManager } from '../themes/theme-manager.js';
import { Holiday } from '../themes/holiday.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useTerminalSize } from './useTerminalSize.js';
import { shortAsciiLogo } from '../components/AsciiArt.js';
interface Snowflake {
x: number;
y: number;
char: string;
}
const SNOW_CHARS = ['*', '.', '·', '+'];
const FRAME_RATE = 150; // ms
const addHolidayTrees = (art: string): string => {
const holidayTree = `
*
***
*****
*******
*********
|_|`;
const treeLines = holidayTree.split('\n').filter((l) => l.length > 0);
const treeWidth = getAsciiArtWidth(holidayTree);
const logoWidth = getAsciiArtWidth(art);
// Create three trees side by side
const treeSpacing = ' ';
const tripleTreeLines = treeLines.map((line) => {
const paddedLine = line.padEnd(treeWidth, ' ');
return `${paddedLine}${treeSpacing}${paddedLine}${treeSpacing}${paddedLine}`;
});
const tripleTreeWidth = treeWidth * 3 + treeSpacing.length * 2;
const paddingCount = Math.max(
0,
Math.floor((logoWidth - tripleTreeWidth) / 2),
);
const treePadding = ' '.repeat(paddingCount);
const centeredTripleTrees = tripleTreeLines
.map((line) => treePadding + line)
.join('\n');
// Add vertical padding and the trees below the logo
return `\n\n${art}\n${centeredTripleTrees}\n\n`;
};
export const useSnowfall = (displayTitle: string): string => {
const isHolidaySeason =
new Date().getMonth() === 11 || new Date().getMonth() === 0;
const currentTheme = themeManager.getActiveTheme();
const { columns: terminalWidth } = useTerminalSize();
const { history, historyRemountKey } = useUIState();
const hasStartedChat = history.some(
(item) => item.type === 'user' && item.text !== '/theme',
);
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
const [showSnow, setShowSnow] = useState(true);
useEffect(() => {
setShowSnow(true);
const timer = setTimeout(() => {
setShowSnow(false);
}, 15000);
return () => clearTimeout(timer);
}, [historyRemountKey]);
const showAnimation =
isHolidaySeason &&
currentTheme.name === Holiday.name &&
terminalWidth >= widthOfShortLogo &&
!hasStartedChat &&
showSnow;
const displayArt = useMemo(() => {
if (showAnimation) {
return addHolidayTrees(displayTitle);
}
return displayTitle;
}, [displayTitle, showAnimation]);
const [snowflakes, setSnowflakes] = useState<Snowflake[]>([]);
// We don't need 'frame' state if we just use functional updates for snowflakes,
// but we need a trigger. A simple interval is fine.
const lines = displayArt.split('\n');
const height = lines.length;
const width = getAsciiArtWidth(displayArt);
useEffect(() => {
if (!showAnimation) {
setSnowflakes([]);
return;
}
debugState.debugNumAnimatedComponents++;
const timer = setInterval(() => {
setSnowflakes((prev) => {
// Move existing flakes
const moved = prev
.map((flake) => ({ ...flake, y: flake.y + 1 }))
.filter((flake) => flake.y < height);
// Spawn new flakes
// Adjust spawn rate based on width to keep density consistent
const spawnChance = 0.3;
const newFlakes: Snowflake[] = [];
if (Math.random() < spawnChance) {
// Spawn 1 to 2 flakes
const count = Math.floor(Math.random() * 2) + 1;
for (let i = 0; i < count; i++) {
newFlakes.push({
x: Math.floor(Math.random() * width),
y: 0,
char: SNOW_CHARS[Math.floor(Math.random() * SNOW_CHARS.length)],
});
}
}
return [...moved, ...newFlakes];
});
}, FRAME_RATE);
return () => {
debugState.debugNumAnimatedComponents--;
clearInterval(timer);
};
}, [height, width, showAnimation]);
if (!showAnimation) return displayTitle;
// Render current frame
if (snowflakes.length === 0) return displayArt;
const grid = lines.map((line) => line.padEnd(width, ' ').split(''));
snowflakes.forEach((flake) => {
if (flake.y >= 0 && flake.y < height && flake.x >= 0 && flake.x < width) {
// Overwrite with snow character
// We check if the row exists just in case
if (grid[flake.y]) {
grid[flake.y][flake.x] = flake.char;
}
}
});
return grid.map((row) => row.join('')).join('\n');
};
@@ -24,7 +24,7 @@ describe('colorizeCode', () => {
},
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
[],
);
const result = colorizeCode({
@@ -198,7 +198,7 @@ Another paragraph.
},
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
[],
);
const { lastFrame } = renderWithProviders(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+13 -2
View File
@@ -60,8 +60,11 @@ import { ideContextStore } from '../ide/ideContext.js';
import { WriteTodosTool } from '../tools/write-todos.js';
import type { FileSystemService } from '../services/fileSystemService.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { logRipgrepFallback } from '../telemetry/loggers.js';
import { RipgrepFallbackEvent } from '../telemetry/types.js';
import { logRipgrepFallback, logFlashFallback } from '../telemetry/loggers.js';
import {
RipgrepFallbackEvent,
FlashFallbackEvent,
} from '../telemetry/types.js';
import type { FallbackModelHandler } from '../fallback/types.js';
import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
import { ModelRouterService } from '../routing/modelRouterService.js';
@@ -869,6 +872,14 @@ export class Config {
this.modelAvailabilityService.reset();
}
activateFallbackMode(model: string): void {
this.setModel(model);
const authType = this.getContentGeneratorConfig()?.authType;
if (authType) {
logFlashFallback(this, new FlashFallbackEvent(authType));
}
}
getActiveModel(): string {
return this._activeModel ?? this.model;
}
@@ -7,10 +7,16 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Config } from './config.js';
import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js';
import { logFlashFallback } from '../telemetry/loggers.js';
import { FlashFallbackEvent } from '../telemetry/types.js';
import fs from 'node:fs';
vi.mock('node:fs');
vi.mock('../telemetry/loggers.js', () => ({
logFlashFallback: vi.fn(),
logRipgrepFallback: vi.fn(),
}));
describe('Flash Model Fallback Configuration', () => {
let config: Config;
@@ -57,4 +63,15 @@ describe('Flash Model Fallback Configuration', () => {
expect(newConfig.getModel()).toBe('custom-model');
});
});
describe('activateFallbackMode', () => {
it('should set model to fallback and log event', () => {
config.activateFallbackMode(DEFAULT_GEMINI_FLASH_MODEL);
expect(config.getModel()).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(logFlashFallback).toHaveBeenCalledWith(
config,
expect.any(FlashFallbackEvent),
);
});
});
});
+1 -1
View File
@@ -41,7 +41,7 @@ export interface UpdatePolicy {
toolName: string;
persist?: boolean;
argsPattern?: string;
commandPrefix?: string;
commandPrefix?: string | string[];
mcpName?: string;
}
+2 -1
View File
@@ -57,6 +57,7 @@ import {
applyModelSelection,
createAvailabilityContextProvider,
} from '../availability/policyHelpers.js';
import { resolveModel } from '../config/models.js';
import type { RetryAvailabilityContext } from '../utils/retry.js';
const MAX_TURNS = 100;
@@ -397,7 +398,7 @@ export class GeminiClient {
// Availability logic: The configured model is the source of truth,
// including any permanent fallbacks (config.setModel) or manual overrides.
return this.config.getActiveModel();
return resolveModel(this.config.getActiveModel());
}
async *sendMessageStream(
+37 -18
View File
@@ -244,7 +244,7 @@ interface TomlRule {
mcpName?: string;
decision?: string;
priority?: number;
commandPrefix?: string;
commandPrefix?: string | string[];
argsPattern?: string;
// Index signature to satisfy Record type if needed for toml.stringify
[key: string]: unknown;
@@ -258,26 +258,45 @@ export function createPolicyUpdater(
MessageBusType.UPDATE_POLICY,
async (message: UpdatePolicy) => {
const toolName = message.toolName;
let argsPattern = message.argsPattern
? new RegExp(message.argsPattern)
: undefined;
if (message.commandPrefix) {
// Convert commandPrefix to argsPattern for in-memory rule
// This mimics what toml-loader does
const escapedPrefix = escapeRegex(message.commandPrefix);
argsPattern = new RegExp(`"command":"${escapedPrefix}`);
}
// Convert commandPrefix(es) to argsPatterns for in-memory rules
const prefixes = Array.isArray(message.commandPrefix)
? message.commandPrefix
: [message.commandPrefix];
policyEngine.addRule({
toolName,
decision: PolicyDecision.ALLOW,
// User tier (2) + high priority (950/1000) = 2.95
// This ensures user "always allow" selections are high priority
// but still lose to admin policies (3.xxx) and settings excludes (200)
priority: 2.95,
argsPattern,
});
for (const prefix of prefixes) {
const escapedPrefix = escapeRegex(prefix);
// Use robust regex to match whole words (e.g. "git" but not "github")
const argsPattern = new RegExp(
`"command":"${escapedPrefix}(?:[\\s"]|$)`,
);
policyEngine.addRule({
toolName,
decision: PolicyDecision.ALLOW,
// User tier (2) + high priority (950/1000) = 2.95
// This ensures user "always allow" selections are high priority
// but still lose to admin policies (3.xxx) and settings excludes (200)
priority: 2.95,
argsPattern,
});
}
} else {
const argsPattern = message.argsPattern
? new RegExp(message.argsPattern)
: undefined;
policyEngine.addRule({
toolName,
decision: PolicyDecision.ALLOW,
// User tier (2) + high priority (950/1000) = 2.95
// This ensures user "always allow" selections are high priority
// but still lose to admin policies (3.xxx) and settings excludes (200)
priority: 2.95,
argsPattern,
});
}
if (message.persist) {
try {
+3 -1
View File
@@ -121,7 +121,9 @@ describe('createPolicyUpdater', () => {
const addedRule = rules.find((r) => r.toolName === toolName);
expect(addedRule).toBeDefined();
expect(addedRule?.priority).toBe(2.95);
expect(addedRule?.argsPattern).toEqual(new RegExp(`"command":"git status`));
expect(addedRule?.argsPattern).toEqual(
new RegExp(`"command":"git status(?:[\\s"]|$)`),
);
// Verify file written
expect(fs.writeFile).toHaveBeenCalledWith(
@@ -29,3 +29,4 @@
decision = "allow"
priority = 999
modes = ["yolo"]
allow_redirection = true
@@ -0,0 +1,190 @@
/**
* @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/promises';
import { createPolicyUpdater } from './config.js';
import { PolicyEngine } from './policy-engine.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import { Storage } from '../config/storage.js';
import toml from '@iarna/toml';
import { ShellToolInvocation } from '../tools/shell.js';
import { type Config } from '../config/config.js';
import {
ToolConfirmationOutcome,
type PolicyUpdateOptions,
} from '../tools/tools.js';
import * as shellUtils from '../utils/shell-utils.js';
vi.mock('node:fs/promises');
vi.mock('../config/storage.js');
vi.mock('../utils/shell-utils.js', () => ({
getCommandRoots: vi.fn(),
stripShellWrapper: vi.fn(),
}));
interface ParsedPolicy {
rule?: Array<{
commandPrefix?: string | string[];
}>;
}
interface TestableShellToolInvocation {
getPolicyUpdateOptions(
outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined;
}
describe('createPolicyUpdater', () => {
let policyEngine: PolicyEngine;
let messageBus: MessageBus;
beforeEach(() => {
vi.resetAllMocks();
policyEngine = new PolicyEngine({});
vi.spyOn(policyEngine, 'addRule');
messageBus = new MessageBus(policyEngine);
vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(
'/mock/user/policies',
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should add multiple rules when commandPrefix is an array', async () => {
createPolicyUpdater(policyEngine, messageBus);
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: 'run_shell_command',
commandPrefix: ['echo', 'ls'],
persist: false,
});
expect(policyEngine.addRule).toHaveBeenCalledTimes(2);
expect(policyEngine.addRule).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
toolName: 'run_shell_command',
argsPattern: new RegExp('"command":"echo(?:[\\s"]|$)'),
}),
);
expect(policyEngine.addRule).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
toolName: 'run_shell_command',
argsPattern: new RegExp('"command":"ls(?:[\\s"]|$)'),
}),
);
});
it('should add a single rule when commandPrefix is a string', async () => {
createPolicyUpdater(policyEngine, messageBus);
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: 'run_shell_command',
commandPrefix: 'git',
persist: false,
});
expect(policyEngine.addRule).toHaveBeenCalledTimes(1);
expect(policyEngine.addRule).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'run_shell_command',
argsPattern: new RegExp('"command":"git(?:[\\s"]|$)'),
}),
);
});
it('should persist multiple rules correctly to TOML', async () => {
createPolicyUpdater(policyEngine, messageBus);
vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' });
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.rename).mockResolvedValue(undefined);
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: 'run_shell_command',
commandPrefix: ['echo', 'ls'],
persist: true,
});
// Wait for the async listener to complete
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fs.writeFile).toHaveBeenCalled();
const [_path, content] = vi.mocked(fs.writeFile).mock.calls[0] as [
string,
string,
];
const parsed = toml.parse(content) as unknown as ParsedPolicy;
expect(parsed.rule).toHaveLength(1);
expect(parsed.rule![0].commandPrefix).toEqual(['echo', 'ls']);
});
});
describe('ShellToolInvocation Policy Update', () => {
let mockConfig: Config;
let mockMessageBus: MessageBus;
beforeEach(() => {
vi.resetAllMocks();
mockConfig = {} as Config;
mockMessageBus = {} as MessageBus;
vi.mocked(shellUtils.stripShellWrapper).mockImplementation(
(c: string) => c,
);
});
it('should extract multiple root commands for chained commands', () => {
vi.mocked(shellUtils.getCommandRoots).mockReturnValue(['git', 'npm']);
const invocation = new ShellToolInvocation(
mockConfig,
{ command: 'git status && npm test' },
new Set(),
mockMessageBus,
'run_shell_command',
'Shell',
);
// Accessing protected method for testing
const options = (
invocation as unknown as TestableShellToolInvocation
).getPolicyUpdateOptions(ToolConfirmationOutcome.ProceedAlways);
expect(options!.commandPrefix).toEqual(['git', 'npm']);
expect(shellUtils.getCommandRoots).toHaveBeenCalledWith(
'git status && npm test',
);
});
it('should extract a single root command', () => {
vi.mocked(shellUtils.getCommandRoots).mockReturnValue(['ls']);
const invocation = new ShellToolInvocation(
mockConfig,
{ command: 'ls -la /tmp' },
new Set(),
mockMessageBus,
'run_shell_command',
'Shell',
);
// Accessing protected method for testing
const options = (
invocation as unknown as TestableShellToolInvocation
).getPolicyUpdateOptions(ToolConfirmationOutcome.ProceedAlways);
expect(options!.commandPrefix).toEqual(['ls']);
expect(shellUtils.getCommandRoots).toHaveBeenCalledWith('ls -la /tmp');
});
});
@@ -7,6 +7,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { StartupProfiler } from './startupProfiler.js';
import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js';
// Mock the metrics module
vi.mock('./metrics.js', () => ({
@@ -255,6 +256,19 @@ describe('StartupProfiler', () => {
}),
);
});
it('should use debug logging instead of standard logging', () => {
const logSpy = vi.spyOn(debugLogger, 'log');
const debugSpy = vi.spyOn(debugLogger, 'debug');
const handle = profiler.start('test_phase');
handle?.end();
profiler.flush(mockConfig);
expect(logSpy).not.toHaveBeenCalled();
expect(debugSpy).toHaveBeenCalled();
});
});
describe('integration scenarios', () => {
@@ -145,7 +145,7 @@ export class StartupProfiler {
* Flushes buffered metrics to the telemetry system.
*/
flush(config: Config): void {
debugLogger.log(
debugLogger.debug(
'[STARTUP] StartupProfiler.flush() called with',
this.phases.size,
'phases',
@@ -181,7 +181,7 @@ export class StartupProfiler {
...phase.details,
};
debugLogger.log(
debugLogger.debug(
'[STARTUP] Recording metric for phase:',
phase.name,
'duration:',
@@ -192,7 +192,7 @@ export class StartupProfiler {
details,
});
} else {
debugLogger.log(
debugLogger.debug(
'[STARTUP] Skipping phase without measure:',
phase.name,
);
+9 -1
View File
@@ -89,7 +89,15 @@ export class ShellToolInvocation extends BaseToolInvocation<
protected override getPolicyUpdateOptions(
outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
if (outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) {
if (
outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave ||
outcome === ToolConfirmationOutcome.ProceedAlways
) {
const command = stripShellWrapper(this.params.command);
const rootCommands = [...new Set(getCommandRoots(command))];
if (rootCommands.length > 0) {
return { commandPrefix: rootCommands };
}
return { commandPrefix: this.params.command };
}
return undefined;
+1 -1
View File
@@ -69,7 +69,7 @@ export interface ToolInvocation<
* Options for policy updates that can be customized by tool invocations.
*/
export interface PolicyUpdateOptions {
commandPrefix?: string;
commandPrefix?: string | string[];
mcpName?: string;
}
@@ -342,7 +342,7 @@ describe('classifyGoogleError', () => {
const result = classifyGoogleError(originalError);
expect(result).toBeInstanceOf(RetryableQuotaError);
if (result instanceof RetryableQuotaError) {
expect(result.retryDelayMs).toBe(5000);
expect(result.retryDelayMs).toBeUndefined();
}
});
@@ -393,7 +393,7 @@ describe('classifyGoogleError', () => {
}
});
it('should return RetryableQuotaError with 5s fallback for generic 429 without specific message', () => {
it('should return RetryableQuotaError without delay time for generic 429 without specific message', () => {
const generic429 = {
status: 429,
message: 'Resource exhausted. No specific retry info.',
@@ -403,11 +403,11 @@ describe('classifyGoogleError', () => {
expect(result).toBeInstanceOf(RetryableQuotaError);
if (result instanceof RetryableQuotaError) {
expect(result.retryDelayMs).toBe(5000);
expect(result.retryDelayMs).toBeUndefined();
}
});
it('should return RetryableQuotaError with 5s fallback for 429 with empty details and no regex match', () => {
it('should return RetryableQuotaError without delay time for 429 with empty details and no regex match', () => {
const errorWithEmptyDetails = {
error: {
code: 429,
@@ -420,11 +420,11 @@ describe('classifyGoogleError', () => {
expect(result).toBeInstanceOf(RetryableQuotaError);
if (result instanceof RetryableQuotaError) {
expect(result.retryDelayMs).toBe(5000);
expect(result.retryDelayMs).toBeUndefined();
}
});
it('should return RetryableQuotaError with 5s fallback for 429 with some detail', () => {
it('should return RetryableQuotaError without delay time for 429 with some detail', () => {
const errorWithEmptyDetails = {
error: {
code: 429,
@@ -446,7 +446,7 @@ describe('classifyGoogleError', () => {
expect(result).toBeInstanceOf(RetryableQuotaError);
if (result instanceof RetryableQuotaError) {
expect(result.retryDelayMs).toBe(5000);
expect(result.retryDelayMs).toBeUndefined();
}
});
});
+9 -9
View File
@@ -13,8 +13,6 @@ import type {
import { parseGoogleApiError } from './googleErrors.js';
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
const DEFAULT_RETRYABLE_DELAY_SECOND = 5;
/**
* A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit).
*/
@@ -24,11 +22,13 @@ export class TerminalQuotaError extends Error {
constructor(
message: string,
override readonly cause: GoogleApiError,
retryDelayMs?: number,
retryDelaySeconds?: number,
) {
super(message);
this.name = 'TerminalQuotaError';
this.retryDelayMs = retryDelayMs ? retryDelayMs * 1000 : undefined;
this.retryDelayMs = retryDelaySeconds
? retryDelaySeconds * 1000
: undefined;
}
}
@@ -36,16 +36,18 @@ export class TerminalQuotaError extends Error {
* A retryable error indicating a temporary quota issue (e.g., per-minute limit).
*/
export class RetryableQuotaError extends Error {
retryDelayMs: number;
retryDelayMs?: number;
constructor(
message: string,
override readonly cause: GoogleApiError,
retryDelaySeconds: number,
retryDelaySeconds?: number,
) {
super(message);
this.name = 'RetryableQuotaError';
this.retryDelayMs = retryDelaySeconds * 1000;
this.retryDelayMs = retryDelaySeconds
? retryDelaySeconds * 1000
: undefined;
}
}
@@ -124,7 +126,6 @@ export function classifyGoogleError(error: unknown): unknown {
message: errorMessage,
details: [],
},
DEFAULT_RETRYABLE_DELAY_SECOND,
);
}
@@ -259,7 +260,6 @@ export function classifyGoogleError(error: unknown): unknown {
message: errorMessage,
details: [],
},
DEFAULT_RETRYABLE_DELAY_SECOND,
);
}
return error; // Fallback to original error if no specific classification fits.
+10 -2
View File
@@ -220,6 +220,11 @@ export async function retryWithBackoff<T>(
if (classifiedError instanceof RetryableQuotaError || is500) {
if (attempt >= maxAttempts) {
const errorMessage =
classifiedError instanceof Error ? classifiedError.message : '';
debugLogger.warn(
`Attempt ${attempt} failed${errorMessage ? `: ${errorMessage}` : ''}. Max attempts reached`,
);
if (onPersistent429) {
try {
const fallbackModel = await onPersistent429(
@@ -240,8 +245,11 @@ export async function retryWithBackoff<T>(
: error;
}
if (classifiedError instanceof RetryableQuotaError) {
console.warn(
if (
classifiedError instanceof RetryableQuotaError &&
classifiedError.retryDelayMs !== undefined
) {
debugLogger.warn(
`Attempt ${attempt} failed: ${classifiedError.message}. Retrying after ${classifiedError.retryDelayMs}ms...`,
);
await delay(classifiedError.retryDelayMs, signal);
@@ -123,8 +123,26 @@ describe('calculateRequestTokenCount', () => {
// Should fallback to estimation:
// 'Hello': 5 chars * 0.25 = 1.25
// inlineData: JSON.stringify length / 4
expect(count).toBeGreaterThan(0);
// inlineData: 3000
// Total: 3001.25 -> 3001
expect(count).toBe(3001);
expect(mockContentGenerator.countTokens).toHaveBeenCalled();
});
it('should use fixed estimate for images in fallback', async () => {
vi.mocked(mockContentGenerator.countTokens).mockRejectedValue(
new Error('API error'),
);
const request = [
{ inlineData: { mimeType: 'image/png', data: 'large_data' } },
];
const count = await calculateRequestTokenCount(
request,
mockContentGenerator,
model,
);
expect(count).toBe(3000);
});
});
+20 -5
View File
@@ -6,6 +6,7 @@
import type { PartListUnion, Part } from '@google/genai';
import type { ContentGenerator } from '../core/contentGenerator.js';
import { debugLogger } from './debugLogger.js';
// Token estimation constants
// ASCII characters (0-127) are roughly 4 chars per token
@@ -13,6 +14,8 @@ const ASCII_TOKENS_PER_CHAR = 0.25;
// Non-ASCII characters (including CJK) are often 1-2 tokens per char.
// We use 1.3 as a conservative estimate to avoid underestimation.
const NON_ASCII_TOKENS_PER_CHAR = 1.3;
// Fixed token estimate for images
const IMAGE_TOKEN_ESTIMATE = 3000;
/**
* Estimates token count for parts synchronously using a heuristic.
@@ -31,10 +34,21 @@ export function estimateTokenCountSync(parts: Part[]): number {
}
}
} else {
// For non-text parts (functionCall, functionResponse, executableCode, etc.),
// we fallback to the JSON string length heuristic.
// Note: This is an approximation.
totalTokens += JSON.stringify(part).length / 4;
// For images, we use a fixed safe estimate (3,000 tokens) covering
// up to 4K resolution on Gemini 3.
// See: https://ai.google.dev/gemini-api/docs/vision#token_counting
const inlineData = 'inlineData' in part ? part.inlineData : undefined;
const fileData = 'fileData' in part ? part.fileData : undefined;
const mimeType = inlineData?.mimeType || fileData?.mimeType;
if (mimeType?.startsWith('image/')) {
totalTokens += IMAGE_TOKEN_ESTIMATE;
} else {
// For other non-text parts (functionCall, functionResponse, etc.),
// we fallback to the JSON string length heuristic.
// Note: This is an approximation.
totalTokens += JSON.stringify(part).length / 4;
}
}
}
return Math.floor(totalTokens);
@@ -69,8 +83,9 @@ export async function calculateRequestTokenCount(
contents: [{ role: 'user', parts }],
});
return response.totalTokens ?? 0;
} catch {
} catch (error) {
// Fallback to local estimation if the API call fails
debugLogger.debug('countTokens API failed:', error);
return estimateTokenCountSync(parts);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.0",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+1 -1
View File
@@ -57,7 +57,7 @@ try {
const fileContent = `/**
* @license
* Copyright ${new Date().getFullYear()} Google LLC
* Copyright ${new Date().getUTCFullYear()} Google LLC
* SPDX-License-Identifier: Apache-2.0
*/