mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 09:10:59 -07:00
Compare commits
14 Commits
ept-debugging
...
v0.23.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 2519a7850a | |||
| 3ff055840e | |||
| 42a36294a8 | |||
| ecbab46394 | |||
| 17fb758664 | |||
| 518cc1ab63 | |||
| b7ad7e1035 | |||
| dbcad90661 | |||
| 703d2e0dcc | |||
| 7d8ab08adb | |||
| bc40695ce4 | |||
| 646dc3154b | |||
| 7b772e9dfb | |||
| ef1e18a85a |
+17
-10
@@ -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'],
|
||||
},
|
||||
|
||||
Generated
+21
-28
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface UpdatePolicy {
|
||||
toolName: string;
|
||||
persist?: boolean;
|
||||
argsPattern?: string;
|
||||
commandPrefix?: string;
|
||||
commandPrefix?: string | string[];
|
||||
mcpName?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user