Compare commits

...

14 Commits

Author SHA1 Message Date
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
31 changed files with 691 additions and 106 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'],
},
+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",
@@ -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>
@@ -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');
};
+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(
@@ -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
*/