Compare commits

...

26 Commits

Author SHA1 Message Date
gemini-cli-robot 504ce50511 chore(release): v0.27.4 2026-02-10 20:11:44 +00:00
gemini-cli-robot d621f40241 fix(patch): cherry-pick 37f128a to release/v0.27.3-pr-18478(#18743)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2026-02-10 13:45:29 -06:00
gemini-cli-robot 563c83c4a3 chore(release): v0.27.3 2026-02-06 20:26:51 +00:00
gemini-cli-robot d5a135b144 fix(patch): cherry-pick 1cae5ab to release/v0.27.2-pr-18376 to patch version v0.27.2 and create version 0.27.3 (#18464)
Co-authored-by: Peter Friese <peter@peterfriese.de>
2026-02-06 19:49:17 +00:00
gemini-cli-robot f7a4e7281b chore(release): v0.27.2 2026-02-05 21:16:11 +00:00
gemini-cli-robot 3d2d44d74d fix(patch): cherry-pick 9ca7300 to release/v0.27.1-pr-18395 to patch version v0.27.1 and create version 0.27.2 (#18399)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-05 20:56:52 +00:00
gemini-cli-robot 9c7a329cea chore(release): v0.27.1 2026-02-05 19:49:30 +00:00
gemini-cli-robot 7f91d7501b fix(patch): cherry-pick a8b4c38 to release/v0.27.0-pr-18161 to patch version v0.27.0 and create version 0.27.1 (#18393)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-05 13:25:27 -06:00
gemini-cli-robot f38a2ce0cb chore(release): v0.27.0 2026-02-04 00:19:05 +00:00
gemini-cli-robot ec10a76923 chore(release): v0.27.0-preview.8 2026-02-03 22:04:03 +00:00
gemini-cli-robot 57c7f6faeb fix(patch): cherry-pick 8cae90f to release/v0.27.0-preview.7-pr-18108 [CONFLICTS] (#18230)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-03 21:41:47 +00:00
gemini-cli-robot 93c2f0cabb chore(release): v0.27.0-preview.7 2026-02-03 20:53:41 +00:00
gemini-cli-robot 7fe17b5dad fix(patch): cherry-pick e1bd1d2 to release/v0.27.0-preview.6-pr-18209 to patch version v0.27.0-preview.6 and create version 0.27.0-preview.7 (#18226)
Co-authored-by: Sehoon Shon <sshon@google.com>
2026-02-03 20:21:12 +00:00
gemini-cli-robot 3384a73b00 chore(release): v0.27.0-preview.6 2026-02-03 19:50:36 +00:00
gemini-cli-robot 88b0b0fe8b fix(patch): cherry-pick 2905503 to release/v0.27.0-preview.5-pr-18215 to patch version v0.27.0-preview.5 and create version 0.27.0-preview.6 (#18217)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-02-03 19:23:59 +00:00
gemini-cli-robot 8349cc0d10 chore(release): v0.27.0-preview.5 2026-02-03 18:09:43 +00:00
gemini-cli-robot f55b52ab58 fix(patch): cherry-pick 1b274b0 to release/v0.27.0-preview.4-pr-17852 to patch version v0.27.0-preview.4 and create version 0.27.0-preview.5 (#18207)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
2026-02-03 17:46:40 +00:00
gemini-cli-robot fd85363d13 chore(release): v0.27.0-preview.4 2026-02-02 19:07:03 +00:00
gemini-cli-robot 7ce9aa518e fix(patch): cherry-pick a362b7b to release/v0.27.0-preview.3-pr-17975 to patch version v0.27.0-preview.3 and create version 0.27.0-preview.4 (#18116)
Co-authored-by: Sehoon Shon <sshon@google.com>
2026-02-02 13:43:45 -05:00
gemini-cli-robot 68bde280d0 chore(release): v0.27.0-preview.3 2026-01-29 22:35:08 +00:00
gemini-cli-robot 09800ea74e fix(patch): cherry-pick 42eedc9 to release/v0.27.0-preview.2-pr-17826 to patch version v0.27.0-preview.2 and create version 0.27.0-preview.3 (#17907)
Co-authored-by: christine betts <chrstn@uw.edu>
2026-01-29 22:10:22 +00:00
gemini-cli-robot 9cf06a3e00 chore(release): v0.27.0-preview.2 2026-01-29 17:47:54 +00:00
gemini-cli-robot 3c33fcb69c fix(patch): cherry-pick 6d36219 to release/v0.27.0-preview.1-pr-17810 to patch version v0.27.0-preview.1 and create version 0.27.0-preview.2 (#17874)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2026-01-29 09:27:22 -08:00
gemini-cli-robot 6f4a2a2c2a chore(release): v0.27.0-preview.1 2026-01-28 23:01:31 +00:00
gemini-cli-robot 9954615542 fix(patch): cherry-pick 4d4e16c to release/v0.27.0-preview.0-pr-17785 to patch version v0.27.0-preview.0 and create version 0.27.0-preview.1 (#17823)
Co-authored-by: christine betts <chrstn@uw.edu>
2026-01-28 22:34:10 +00:00
gemini-cli-robot c7a044b091 chore(release): v0.27.0-preview.0 2026-01-28 01:44:42 +00:00
44 changed files with 777 additions and 181 deletions
+4 -3
View File
@@ -129,8 +129,9 @@ they appear in the UI.
### HooksConfig
| UI Label | Setting | Description | Default |
| ------------------ | --------------------------- | ------------------------------------------------ | ------- |
| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` |
| UI Label | Setting | Description | Default |
| ------------------ | --------------------------- | -------------------------------------------------------------------------------- | ------- |
| Enable Hooks | `hooksConfig.enabled` | Canonical toggle for the hooks system. When disabled, no hooks will be executed. | `true` |
| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` |
<!-- SETTINGS-AUTOGEN:END -->
+1
View File
@@ -902,6 +902,7 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Canonical toggle for the hooks system. When disabled, no
hooks will be executed.
- **Default:** `true`
- **Requires restart:** Yes
- **`hooksConfig.disabled`** (array):
- **Description:** List of hook names (commands) that should be disabled.
+8 -31
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"workspaces": [
"packages/*"
],
@@ -2251,7 +2251,6 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2432,7 +2431,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2466,7 +2464,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2835,7 +2832,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2869,7 +2865,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
@@ -2922,7 +2917,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
@@ -4128,7 +4122,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4406,7 +4399,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5399,7 +5391,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8409,7 +8400,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8950,7 +8940,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -10552,7 +10541,6 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -14311,7 +14299,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14322,7 +14309,6 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16559,7 +16545,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16783,8 +16768,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16792,7 +16776,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16965,7 +16948,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -17173,7 +17155,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -17287,7 +17268,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17300,7 +17280,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -18005,7 +17984,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18021,7 +17999,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -18077,7 +18055,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -18164,7 +18142,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -18300,7 +18278,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -18323,7 +18300,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18340,7 +18317,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"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.27.0-nightly.20260121.97aac696f"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.27.4"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"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.27.0-nightly.20260121.97aac696f"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.27.4"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -22,6 +22,7 @@ import {
type ExtensionSetting,
} from '../../config/extensions/extensionSettings.js';
import prompts from 'prompts';
import * as fs from 'node:fs';
const {
mockExtensionManager,
@@ -79,11 +80,15 @@ vi.mock('../../config/settings.js', () => ({
}));
describe('extensions configure command', () => {
let tempWorkspaceDir: string;
beforeEach(() => {
vi.spyOn(debugLogger, 'log');
vi.spyOn(debugLogger, 'error');
vi.clearAllMocks();
tempWorkspaceDir = fs.mkdtempSync('gemini-cli-test-workspace');
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
// Default behaviors
mockLoadSettings.mockReturnValue({ merged: {} });
mockGetExtensionAndManager.mockResolvedValue({
@@ -141,6 +146,7 @@ describe('extensions configure command', () => {
'TEST_VAR',
promptForSetting,
'user',
tempWorkspaceDir,
);
});
@@ -186,6 +192,7 @@ describe('extensions configure command', () => {
'VAR_1',
promptForSetting,
'user',
tempWorkspaceDir,
);
});
@@ -111,6 +111,7 @@ async function configureSpecificSetting(
settingKey,
promptForSetting,
scope,
process.cwd(),
);
}
@@ -218,6 +219,7 @@ async function configureExtensionSettings(
setting.envVar,
promptForSetting,
scope,
process.cwd(),
);
}
}
@@ -306,6 +306,7 @@ System using model: \${MODEL_NAME}
'MY_VALUE',
mockRequestSetting,
ExtensionSettingScope.USER,
process.cwd(),
);
await extensionManager.restartExtension(extension);
@@ -398,6 +398,35 @@ describe('extensionSettings', () => {
expect(actualContent).toBe('VAR1="a value with spaces"\n');
});
it('should not set sensitive settings if the value is empty during initial setup', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{
name: 's1',
description: 'd1',
envVar: 'SENSITIVE_VAR',
sensitive: true,
},
],
};
mockRequestSetting.mockResolvedValue('');
await maybePromptForSettings(
config,
'12345',
mockRequestSetting,
undefined,
undefined,
);
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
});
it('should not attempt to clear secrets if keychain is unavailable', async () => {
// Arrange
const mockIsAvailable = vi.fn().mockResolvedValue(false);
@@ -738,5 +767,42 @@ describe('extensionSettings', () => {
const lines = actualContent.split('\n').filter((line) => line.length > 0);
expect(lines).toHaveLength(3); // Should only have the three variables
});
it('should delete a sensitive setting if the new value is empty', async () => {
mockRequestSetting.mockResolvedValue('');
await updateSetting(
config,
'12345',
'VAR2',
mockRequestSetting,
ExtensionSettingScope.USER,
tempWorkspaceDir,
);
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
expect(await userKeychain.getSecret('VAR2')).toBeNull();
});
it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
mockRequestSetting.mockResolvedValue('');
// Ensure it doesn't exist first
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345`,
);
await userKeychain.deleteSecret('VAR2');
await updateSetting(
config,
'12345',
'VAR2',
mockRequestSetting,
ExtensionSettingScope.USER,
tempWorkspaceDir,
);
// Should complete without error
});
});
});
@@ -112,7 +112,7 @@ export async function maybePromptForSettings(
const nonSensitiveSettings: Record<string, string> = {};
for (const setting of settings) {
const value = allSettings[setting.envVar];
if (value === undefined) {
if (value === undefined || value === '') {
continue;
}
if (setting.sensitive) {
@@ -207,7 +207,7 @@ export async function updateSetting(
settingKey: string,
requestSetting: (setting: ExtensionSetting) => Promise<string>,
scope: ExtensionSettingScope,
workspaceDir?: string,
workspaceDir: string,
): Promise<void> {
const { name: extensionName, settings } = extensionConfig;
if (!settings || settings.length === 0) {
@@ -230,7 +230,15 @@ export async function updateSetting(
);
if (settingToUpdate.sensitive) {
await keychain.setSecret(settingToUpdate.envVar, newValue);
if (newValue) {
await keychain.setSecret(settingToUpdate.envVar, newValue);
} else {
try {
await keychain.deleteSecret(settingToUpdate.envVar);
} catch {
// Ignore if secret does not exist
}
}
return;
}
+2 -2
View File
@@ -1602,11 +1602,11 @@ const SETTINGS_SCHEMA = {
type: 'boolean',
label: 'Enable Hooks',
category: 'Advanced',
requiresRestart: false,
requiresRestart: true,
default: true,
description:
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
showInDialog: false,
showInDialog: true,
},
disabled: {
type: 'array',
@@ -1384,6 +1384,57 @@ describe('useTextBuffer', () => {
expect(state.visualCursor).toEqual([0, 1]);
});
it('move: up/down should work on wrapped lines (regression test)', () => {
// Line that wraps into two visual lines
// Viewport width 10. "0123456789ABCDE" (15 chars)
// Visual Line 0: "0123456789"
// Visual Line 1: "ABCDE"
const { result } = renderHook(() =>
useTextBuffer({
viewport: { width: 10, height: 5 },
isValidPath: () => false,
}),
);
act(() => {
result.current.setText('0123456789ABCDE');
});
// Cursor should be at the end: logical [0, 15], visual [1, 5]
expect(getBufferState(result).cursor).toEqual([0, 15]);
expect(getBufferState(result).visualCursor).toEqual([1, 5]);
// Press Up arrow - should move to first visual line
// This currently fails because handleInput returns false if cursorRow === 0
act(() => {
result.current.handleInput({
name: 'up',
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x1b[A',
});
});
expect(getBufferState(result).visualCursor[0]).toBe(0);
// Press Down arrow - should move back to second visual line
// This would also fail if cursorRow is the last logical row
act(() => {
result.current.handleInput({
name: 'down',
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x1b[B',
});
});
expect(getBufferState(result).visualCursor[0]).toBe(1);
});
it('moveToVisualPosition: should correctly handle wide characters (Chinese)', () => {
const { result } = renderHook(() =>
useTextBuffer({
@@ -2905,12 +2905,12 @@ export function useTextBuffer({
return true;
}
if (keyMatchers[Command.MOVE_UP](key)) {
if (cursorRow === 0) return false;
if (visualCursor[0] === 0) return false;
move('up');
return true;
}
if (keyMatchers[Command.MOVE_DOWN](key)) {
if (cursorRow === lines.length - 1) return false;
if (visualCursor[0] === visualLines.length - 1) return false;
move('down');
return true;
}
@@ -2974,6 +2974,8 @@ export function useTextBuffer({
cursorCol,
lines,
singleLine,
visualCursor,
visualLines,
],
);
@@ -305,6 +305,9 @@ describe('clipboardUtils', () => {
});
it('should return null if tool is not yet detected', async () => {
// Unset session type to ensure no tool is detected automatically
delete process.env['XDG_SESSION_TYPE'];
// Don't prime the tool
const result = await clipboardUtils.saveClipboardImage(mockTargetDir);
expect(result).toBe(null);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -312,6 +312,32 @@ describe('setupUser for new user', () => {
userTierName: 'paid',
});
});
it('should throw ineligible tier error when onboarding fails and ineligible tiers exist', async () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT', '');
mockLoad.mockResolvedValue({
allowedTiers: [mockPaidTier],
ineligibleTiers: [
{
reasonCode: 'UNSUPPORTED_LOCATION',
reasonMessage:
'Your current account is not eligible for Gemini Code Assist for individuals because it is not currently available in your location.',
tierId: 'free-tier',
tierName: 'Gemini Code Assist for individuals',
},
],
});
mockOnboardUser.mockResolvedValue({
done: true,
response: {
cloudaicompanionProject: {},
},
});
await expect(setupUser({} as OAuth2Client)).rejects.toThrow(
'Your current account is not eligible for Gemini Code Assist for individuals because it is not currently available in your location.',
);
});
});
describe('setupUser validation', () => {
+21 -8
View File
@@ -7,6 +7,7 @@
import type {
ClientMetadata,
GeminiUserTier,
IneligibleTier,
LoadCodeAssistResponse,
OnboardUserRequest,
} from './types.js';
@@ -35,6 +36,16 @@ export class ValidationCancelledError extends Error {
}
}
export class IneligibleTierError extends Error {
readonly ineligibleTiers: IneligibleTier[];
constructor(ineligibleTiers: IneligibleTier[]) {
const reasons = ineligibleTiers.map((t) => t.reasonMessage).join(', ');
super(reasons);
this.ineligibleTiers = ineligibleTiers;
}
}
export interface UserData {
projectId: string;
userTier: UserTierId;
@@ -127,13 +138,7 @@ export async function setupUser(
}
// If user is not setup for standard tier, inform them about all other tiers they are ineligible for.
if (loadRes.ineligibleTiers && loadRes.ineligibleTiers.length > 0) {
const reasons = loadRes.ineligibleTiers
.map((t) => t.reasonMessage)
.join(', ');
throw new Error(reasons);
}
throw new ProjectIdRequiredError();
throwIneligibleOrProjectIdError(loadRes);
}
return {
projectId: loadRes.cloudaicompanionProject,
@@ -180,7 +185,8 @@ export async function setupUser(
userTierName: tier.name,
};
}
throw new ProjectIdRequiredError();
throwIneligibleOrProjectIdError(loadRes);
}
return {
@@ -190,6 +196,13 @@ export async function setupUser(
};
}
function throwIneligibleOrProjectIdError(res: LoadCodeAssistResponse): never {
if (res.ineligibleTiers && res.ineligibleTiers.length > 0) {
throw new IneligibleTierError(res.ineligibleTiers);
}
throw new ProjectIdRequiredError();
}
function getOnboardTier(res: LoadCodeAssistResponse): GeminiUserTier {
for (const tier of res.allowedTiers || []) {
if (tier.isDefault) {
-6
View File
@@ -33,7 +33,6 @@ import { WriteFileTool } from '../tools/write-file.js';
import { WebFetchTool } from '../tools/web-fetch.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
import { WebSearchTool } from '../tools/web-search.js';
import { AskUserTool } from '../tools/ask-user.js';
import { GeminiClient } from '../core/client.js';
import { BaseLlmClient } from '../core/baseLlmClient.js';
import type { HookDefinition, HookEventName } from '../hooks/types.js';
@@ -1712,10 +1711,6 @@ export class Config {
* @returns true if the path is allowed, false otherwise.
*/
isPathAllowed(absolutePath: string): boolean {
if (this.interactive && path.isAbsolute(absolutePath)) {
return true;
}
const realpath = (p: string) => {
let resolved: string;
try {
@@ -2073,7 +2068,6 @@ export class Config {
registerCoreTool(ShellTool, this);
registerCoreTool(MemoryTool);
registerCoreTool(WebSearchTool, this);
registerCoreTool(AskUserTool);
if (this.getUseWriteTodos()) {
registerCoreTool(WriteTodosTool);
}
+24
View File
@@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest';
import {
resolveModel,
resolveClassifierModel,
isGemini3Model,
isGemini2Model,
isAutoModel,
getDisplayString,
@@ -25,6 +26,29 @@ import {
DEFAULT_GEMINI_MODEL_AUTO,
} from './models.js';
describe('isGemini3Model', () => {
it('should return true for gemini-3 models', () => {
expect(isGemini3Model('gemini-3-pro-preview')).toBe(true);
expect(isGemini3Model('gemini-3-flash-preview')).toBe(true);
});
it('should return true for aliases that resolve to Gemini 3', () => {
expect(isGemini3Model(GEMINI_MODEL_ALIAS_AUTO, true)).toBe(true);
expect(isGemini3Model(GEMINI_MODEL_ALIAS_PRO, true)).toBe(true);
expect(isGemini3Model(PREVIEW_GEMINI_MODEL_AUTO)).toBe(true);
});
it('should return false for Gemini 2 models', () => {
expect(isGemini3Model('gemini-2.5-pro')).toBe(false);
expect(isGemini3Model('gemini-2.5-flash')).toBe(false);
expect(isGemini3Model(DEFAULT_GEMINI_MODEL_AUTO)).toBe(false);
});
it('should return false for arbitrary strings', () => {
expect(isGemini3Model('gpt-4')).toBe(false);
});
});
describe('getDisplayString', () => {
it('should return Auto (Gemini 3) for preview auto model', () => {
expect(getDisplayString(PREVIEW_GEMINI_MODEL_AUTO)).toBe('Auto (Gemini 3)');
+15
View File
@@ -137,6 +137,21 @@ export function isPreviewModel(model: string): boolean {
);
}
/**
* Checks if the model is a Gemini 3 model.
*
* @param model The model name to check.
* @param previewFeaturesEnabled A boolean indicating if preview features are enabled.
* @returns True if the model is a Gemini 3 model.
*/
export function isGemini3Model(
model: string,
previewFeaturesEnabled: boolean = false,
): boolean {
const resolved = resolveModel(model, previewFeaturesEnabled);
return /^gemini-3(\.|-|$)/.test(resolved);
}
/**
* Checks if the model is a Gemini 2.x model.
*
@@ -185,8 +185,6 @@ The following read-only tools are available in Plan Mode:
### Phase 1: Requirements Understanding
- Analyze the user's request to identify core requirements and constraints
- If critical information is missing or ambiguous, ask clarifying questions using the \`ask_user\` tool
- When using \`ask_user\`, prefer providing multiple-choice options for the user to select from when possible
- Do NOT explore the project or create a plan yet
### Phase 2: Project Exploration
+6 -2
View File
@@ -18,7 +18,11 @@ import type {
} from '@google/genai';
import { toParts } from '../code_assist/converter.js';
import { createUserContent, FinishReason } from '@google/genai';
import { retryWithBackoff, isRetryableError } from '../utils/retry.js';
import {
retryWithBackoff,
isRetryableError,
DEFAULT_MAX_ATTEMPTS,
} from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import type { Config } from '../config/config.js';
import {
@@ -606,7 +610,7 @@ export class GeminiChat {
onRetry: (attempt, error, delayMs) => {
coreEvents.emitRetryAttempt({
attempt,
maxAttempts: availabilityMaxAttempts ?? 10,
maxAttempts: availabilityMaxAttempts ?? DEFAULT_MAX_ATTEMPTS,
delayMs,
error: error instanceof Error ? error.message : String(error),
model: lastModelToUse,
+1 -3
View File
@@ -274,11 +274,10 @@ describe('Core System Prompt (prompts.ts)', () => {
it('should only list available tools in PLAN mode', () => {
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
// Only enable a subset of tools, including ask_user
// Only enable a subset of tools
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([
'glob',
'read_file',
'ask_user',
]);
const prompt = getCoreSystemPrompt(mockConfig);
@@ -286,7 +285,6 @@ describe('Core System Prompt (prompts.ts)', () => {
// Should include enabled tools
expect(prompt).toContain('`glob`');
expect(prompt).toContain('`read_file`');
expect(prompt).toContain('`ask_user`');
// Should NOT include disabled tools
expect(prompt).not.toContain('`google_web_search`');
@@ -64,12 +64,6 @@ decision = "allow"
priority = 50
modes = ["plan"]
[[rule]]
toolName = "ask_user"
decision = "allow"
priority = 50
modes = ["plan"]
# Allow write_file for .md files in plans directory
[[rule]]
toolName = "write_file"
-3
View File
@@ -6,7 +6,6 @@
import {
ACTIVATE_SKILL_TOOL_NAME,
ASK_USER_TOOL_NAME,
EDIT_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
@@ -313,8 +312,6 @@ ${options.planModeToolsList}
### Phase 1: Requirements Understanding
- Analyze the user's request to identify core requirements and constraints
- If critical information is missing or ambiguous, ask clarifying questions using the \`${ASK_USER_TOOL_NAME}\` tool
- When using \`${ASK_USER_TOOL_NAME}\`, prefer providing multiple-choice options for the user to select from when possible
- Do NOT explore the project or create a plan yet
### Phase 2: Project Exploration
@@ -4,19 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { ClassifierStrategy } from './classifierStrategy.js';
import type { RoutingContext } from '../routingStrategy.js';
import type { Config } from '../../config/config.js';
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
import {
isFunctionCall,
isFunctionResponse,
} from '../../utils/messageInspectors.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
} from '../../config/models.js';
import { promptIdContext } from '../../utils/promptIdContext.js';
import type { Content } from '@google/genai';
@@ -31,6 +28,9 @@ describe('ClassifierStrategy', () => {
let mockConfig: Config;
let mockBaseLlmClient: BaseLlmClient;
let mockResolvedConfig: ResolvedModelConfig;
let mockGetModel: Mock;
let mockGetNumericalRoutingEnabled: Mock;
let mockGenerateJson: Mock;
beforeEach(() => {
vi.clearAllMocks();
@@ -46,23 +46,30 @@ describe('ClassifierStrategy', () => {
model: 'classifier',
generateContentConfig: {},
} as unknown as ResolvedModelConfig;
mockGetModel = vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
mockGetNumericalRoutingEnabled = vi.fn().mockResolvedValue(false);
mockGenerateJson = vi.fn();
mockConfig = {
modelConfigService: {
getResolvedConfig: vi.fn().mockReturnValue(mockResolvedConfig),
},
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
getModel: mockGetModel,
getPreviewFeatures: () => false,
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
getNumericalRoutingEnabled: mockGetNumericalRoutingEnabled,
} as unknown as Config;
mockBaseLlmClient = {
generateJson: vi.fn(),
generateJson: mockGenerateJson,
} as unknown as BaseLlmClient;
vi.spyOn(promptIdContext, 'getStore').mockReturnValue('test-prompt-id');
});
it('should return null if numerical routing is enabled', async () => {
vi.mocked(mockConfig.getNumericalRoutingEnabled).mockResolvedValue(true);
it('should return null if numerical routing is enabled and model is Gemini 3', async () => {
mockGetNumericalRoutingEnabled.mockResolvedValue(true);
mockGetModel.mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
const decision = await strategy.route(
mockContext,
@@ -71,7 +78,25 @@ describe('ClassifierStrategy', () => {
);
expect(decision).toBeNull();
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
expect(mockGenerateJson).not.toHaveBeenCalled();
});
it('should NOT return null if numerical routing is enabled but model is NOT Gemini 3', async () => {
mockGetNumericalRoutingEnabled.mockResolvedValue(true);
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
mockGenerateJson.mockResolvedValue({
reasoning: 'test',
model_choice: 'flash',
});
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision).not.toBeNull();
expect(mockGenerateJson).toHaveBeenCalled();
});
it('should call generateJson with the correct parameters', async () => {
@@ -79,13 +104,11 @@ describe('ClassifierStrategy', () => {
reasoning: 'Simple task',
model_choice: 'flash',
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
mockGenerateJson.mockResolvedValue(mockApiResponse);
await strategy.route(mockContext, mockConfig, mockBaseLlmClient);
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledWith(
expect(mockGenerateJson).toHaveBeenCalledWith(
expect.objectContaining({
modelConfigKey: { model: mockResolvedConfig.model },
promptId: 'test-prompt-id',
@@ -98,9 +121,7 @@ describe('ClassifierStrategy', () => {
reasoning: 'This is a simple task.',
model_choice: 'flash',
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
mockGenerateJson.mockResolvedValue(mockApiResponse);
const decision = await strategy.route(
mockContext,
@@ -108,7 +129,7 @@ describe('ClassifierStrategy', () => {
mockBaseLlmClient,
);
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledOnce();
expect(mockGenerateJson).toHaveBeenCalledOnce();
expect(decision).toEqual({
model: DEFAULT_GEMINI_FLASH_MODEL,
metadata: {
@@ -124,9 +145,7 @@ describe('ClassifierStrategy', () => {
reasoning: 'This is a complex task.',
model_choice: 'pro',
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
mockGenerateJson.mockResolvedValue(mockApiResponse);
mockContext.request = [{ text: 'how do I build a spaceship?' }];
const decision = await strategy.route(
@@ -135,7 +154,7 @@ describe('ClassifierStrategy', () => {
mockBaseLlmClient,
);
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledOnce();
expect(mockGenerateJson).toHaveBeenCalledOnce();
expect(decision).toEqual({
model: DEFAULT_GEMINI_MODEL,
metadata: {
@@ -151,7 +170,7 @@ describe('ClassifierStrategy', () => {
.spyOn(debugLogger, 'warn')
.mockImplementation(() => {});
const testError = new Error('API Failure');
vi.mocked(mockBaseLlmClient.generateJson).mockRejectedValue(testError);
mockGenerateJson.mockRejectedValue(testError);
const decision = await strategy.route(
mockContext,
@@ -172,9 +191,7 @@ describe('ClassifierStrategy', () => {
reasoning: 'This is a simple task.',
// model_choice is missing, which will cause a Zod parsing error.
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
malformedApiResponse,
);
mockGenerateJson.mockResolvedValue(malformedApiResponse);
const decision = await strategy.route(
mockContext,
@@ -203,14 +220,11 @@ describe('ClassifierStrategy', () => {
reasoning: 'Simple.',
model_choice: 'flash',
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
mockGenerateJson.mockResolvedValue(mockApiResponse);
await strategy.route(mockContext, mockConfig, mockBaseLlmClient);
const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock
.calls[0][0];
const generateJsonCall = mockGenerateJson.mock.calls[0][0];
const contents = generateJsonCall.contents;
const expectedContents = [
@@ -239,14 +253,11 @@ describe('ClassifierStrategy', () => {
reasoning: 'Simple.',
model_choice: 'flash',
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
mockGenerateJson.mockResolvedValue(mockApiResponse);
await strategy.route(mockContext, mockConfig, mockBaseLlmClient);
const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock
.calls[0][0];
const generateJsonCall = mockGenerateJson.mock.calls[0][0];
const contents = generateJsonCall.contents;
// Manually calculate what the history should be
@@ -254,7 +265,10 @@ describe('ClassifierStrategy', () => {
const HISTORY_TURNS_FOR_CONTEXT = 4;
const historySlice = longHistory.slice(-HISTORY_SEARCH_WINDOW);
const cleanHistory = historySlice.filter(
(content) => !isFunctionCall(content) && !isFunctionResponse(content),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(content: any) =>
!content.parts?.[0]?.functionCall &&
!content.parts?.[0]?.functionResponse,
);
const finalHistory = cleanHistory.slice(-HISTORY_TURNS_FOR_CONTEXT);
@@ -275,14 +289,11 @@ describe('ClassifierStrategy', () => {
reasoning: 'Simple.',
model_choice: 'flash',
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
mockGenerateJson.mockResolvedValue(mockApiResponse);
await strategy.route(mockContext, mockConfig, mockBaseLlmClient);
const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock
.calls[0][0];
const generateJsonCall = mockGenerateJson.mock.calls[0][0];
expect(generateJsonCall.promptId).toMatch(
/^classifier-router-fallback-\d+-\w+$/,
@@ -301,9 +312,7 @@ describe('ClassifierStrategy', () => {
reasoning: 'Choice is flash',
model_choice: 'flash',
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
mockGenerateJson.mockResolvedValue(mockApiResponse);
const contextWithRequestedModel = {
...mockContext,
@@ -12,7 +12,7 @@ import type {
RoutingDecision,
RoutingStrategy,
} from '../routingStrategy.js';
import { resolveClassifierModel } from '../../config/models.js';
import { resolveClassifierModel, isGemini3Model } from '../../config/models.js';
import { createUserContent, Type } from '@google/genai';
import type { Config } from '../../config/config.js';
import {
@@ -133,7 +133,12 @@ export class ClassifierStrategy implements RoutingStrategy {
): Promise<RoutingDecision | null> {
const startTime = Date.now();
try {
if (await config.getNumericalRoutingEnabled()) {
const model = context.requestedModel ?? config.getModel();
const previewFeaturesEnabled = config.getPreviewFeatures();
if (
(await config.getNumericalRoutingEnabled()) &&
isGemini3Model(model, previewFeaturesEnabled)
) {
return null;
}
@@ -164,7 +169,7 @@ export class ClassifierStrategy implements RoutingStrategy {
const reasoning = routerResponse.reasoning;
const latencyMs = Date.now() - startTime;
const selectedModel = resolveClassifierModel(
context.requestedModel ?? config.getModel(),
model,
routerResponse.model_choice,
config.getPreviewFeatures(),
);
@@ -10,9 +10,12 @@ import type { RoutingContext } from '../routingStrategy.js';
import type { Config } from '../../config/config.js';
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_AUTO,
} from '../../config/models.js';
import { promptIdContext } from '../../utils/promptIdContext.js';
import type { Content } from '@google/genai';
@@ -46,7 +49,7 @@ describe('NumericalClassifierStrategy', () => {
modelConfigService: {
getResolvedConfig: vi.fn().mockReturnValue(mockResolvedConfig),
},
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
getModel: vi.fn().mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO),
getPreviewFeatures: () => false,
getSessionId: vi.fn().mockReturnValue('control-group-id'), // Default to Control Group (Hash 71 >= 50)
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(true),
@@ -76,6 +79,54 @@ describe('NumericalClassifierStrategy', () => {
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
});
it('should return null if the model is not a Gemini 3 model', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision).toBeNull();
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
});
it('should return null if the model is explicitly a Gemini 2 model', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision).toBeNull();
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
});
it('should return a decision if model is auto and preview features are enabled (resolves to Gemini 3)', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue(GEMINI_MODEL_ALIAS_AUTO);
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
const mockApiResponse = {
complexity_reasoning: 'Simple task',
complexity_score: 10,
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision).not.toBeNull();
expect(mockBaseLlmClient.generateJson).toHaveBeenCalled();
});
it('should call generateJson with the correct parameters and wrapped user content', async () => {
const mockApiResponse = {
complexity_reasoning: 'Simple task',
@@ -120,9 +171,9 @@ describe('NumericalClassifierStrategy', () => {
);
expect(decision).toEqual({
model: DEFAULT_GEMINI_FLASH_MODEL,
model: PREVIEW_GEMINI_FLASH_MODEL,
metadata: {
source: 'Classifier (Control)',
source: 'NumericalClassifier (Control)',
latencyMs: expect.any(Number),
reasoning: expect.stringContaining('Score: 40 / Threshold: 50'),
},
@@ -146,9 +197,9 @@ describe('NumericalClassifierStrategy', () => {
);
expect(decision).toEqual({
model: DEFAULT_GEMINI_MODEL,
model: PREVIEW_GEMINI_MODEL,
metadata: {
source: 'Classifier (Control)',
source: 'NumericalClassifier (Control)',
latencyMs: expect.any(Number),
reasoning: expect.stringContaining('Score: 60 / Threshold: 50'),
},
@@ -172,9 +223,9 @@ describe('NumericalClassifierStrategy', () => {
);
expect(decision).toEqual({
model: DEFAULT_GEMINI_FLASH_MODEL, // Routed to Flash because 60 < 80
model: PREVIEW_GEMINI_FLASH_MODEL, // Routed to Flash because 60 < 80
metadata: {
source: 'Classifier (Strict)',
source: 'NumericalClassifier (Strict)',
latencyMs: expect.any(Number),
reasoning: expect.stringContaining('Score: 60 / Threshold: 80'),
},
@@ -198,9 +249,9 @@ describe('NumericalClassifierStrategy', () => {
);
expect(decision).toEqual({
model: DEFAULT_GEMINI_MODEL,
model: PREVIEW_GEMINI_MODEL,
metadata: {
source: 'Classifier (Strict)',
source: 'NumericalClassifier (Strict)',
latencyMs: expect.any(Number),
reasoning: expect.stringContaining('Score: 90 / Threshold: 80'),
},
@@ -226,9 +277,9 @@ describe('NumericalClassifierStrategy', () => {
);
expect(decision).toEqual({
model: DEFAULT_GEMINI_FLASH_MODEL, // Score 60 < Threshold 70
model: PREVIEW_GEMINI_FLASH_MODEL, // Score 60 < Threshold 70
metadata: {
source: 'Classifier (Remote)',
source: 'NumericalClassifier (Remote)',
latencyMs: expect.any(Number),
reasoning: expect.stringContaining('Score: 60 / Threshold: 70'),
},
@@ -252,9 +303,9 @@ describe('NumericalClassifierStrategy', () => {
);
expect(decision).toEqual({
model: DEFAULT_GEMINI_FLASH_MODEL, // Score 40 < Threshold 45.5
model: PREVIEW_GEMINI_FLASH_MODEL, // Score 40 < Threshold 45.5
metadata: {
source: 'Classifier (Remote)',
source: 'NumericalClassifier (Remote)',
latencyMs: expect.any(Number),
reasoning: expect.stringContaining('Score: 40 / Threshold: 45.5'),
},
@@ -278,9 +329,9 @@ describe('NumericalClassifierStrategy', () => {
);
expect(decision).toEqual({
model: DEFAULT_GEMINI_MODEL, // Score 35 >= Threshold 30
model: PREVIEW_GEMINI_MODEL, // Score 35 >= Threshold 30
metadata: {
source: 'Classifier (Remote)',
source: 'NumericalClassifier (Remote)',
latencyMs: expect.any(Number),
reasoning: expect.stringContaining('Score: 35 / Threshold: 30'),
},
@@ -306,9 +357,9 @@ describe('NumericalClassifierStrategy', () => {
);
expect(decision).toEqual({
model: DEFAULT_GEMINI_FLASH_MODEL, // Score 40 < Default A/B Threshold 50
model: PREVIEW_GEMINI_FLASH_MODEL, // Score 40 < Default A/B Threshold 50
metadata: {
source: 'Classifier (Control)',
source: 'NumericalClassifier (Control)',
latencyMs: expect.any(Number),
reasoning: expect.stringContaining('Score: 40 / Threshold: 50'),
},
@@ -333,9 +384,9 @@ describe('NumericalClassifierStrategy', () => {
);
expect(decision).toEqual({
model: DEFAULT_GEMINI_FLASH_MODEL,
model: PREVIEW_GEMINI_FLASH_MODEL,
metadata: {
source: 'Classifier (Control)',
source: 'NumericalClassifier (Control)',
latencyMs: expect.any(Number),
reasoning: expect.stringContaining('Score: 40 / Threshold: 50'),
},
@@ -360,9 +411,9 @@ describe('NumericalClassifierStrategy', () => {
);
expect(decision).toEqual({
model: DEFAULT_GEMINI_MODEL,
model: PREVIEW_GEMINI_MODEL,
metadata: {
source: 'Classifier (Control)',
source: 'NumericalClassifier (Control)',
latencyMs: expect.any(Number),
reasoning: expect.stringContaining('Score: 60 / Threshold: 50'),
},
@@ -12,7 +12,7 @@ import type {
RoutingDecision,
RoutingStrategy,
} from '../routingStrategy.js';
import { resolveClassifierModel } from '../../config/models.js';
import { resolveClassifierModel, isGemini3Model } from '../../config/models.js';
import { createUserContent, Type } from '@google/genai';
import type { Config } from '../../config/config.js';
import { debugLogger } from '../../utils/debugLogger.js';
@@ -134,10 +134,16 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
): Promise<RoutingDecision | null> {
const startTime = Date.now();
try {
const model = context.requestedModel ?? config.getModel();
const previewFeaturesEnabled = config.getPreviewFeatures();
if (!(await config.getNumericalRoutingEnabled())) {
return null;
}
if (!isGemini3Model(model, previewFeaturesEnabled)) {
return null;
}
const promptId = getPromptIdWithFallback('classifier-router');
const finalHistory = context.history.slice(-HISTORY_TURNS_FOR_CONTEXT);
@@ -177,7 +183,7 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
);
const selectedModel = resolveClassifierModel(
config.getModel(),
model,
modelAlias,
config.getPreviewFeatures(),
);
@@ -187,7 +193,7 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
return {
model: selectedModel,
metadata: {
source: `Classifier (${groupLabel})`,
source: `NumericalClassifier (${groupLabel})`,
latencyMs,
reasoning: `[Score: ${score} / Threshold: ${threshold}] ${routerResponse.complexity_reasoning}`,
},
@@ -930,6 +930,38 @@ describe('ClearcutLogger', () => {
'Something went wrong',
]);
});
it('logs a successful routing event with numerical routing fields', () => {
const { logger } = setup();
const event = new ModelRoutingEvent(
'gemini-pro',
'NumericalClassifier (Strict)',
123,
'[Score: 90 / Threshold: 80] reasoning',
false,
undefined,
true,
'80',
);
logger?.logModelRoutingEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.MODEL_ROUTING);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_ROUTING_REASONING,
'[Score: 90 / Threshold: 80] reasoning',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_ROUTING_NUMERICAL_ENABLED,
'true',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_ROUTING_CLASSIFIER_THRESHOLD,
'80',
]);
});
});
describe('logAgentStartEvent', () => {
@@ -1234,6 +1234,28 @@ export class ClearcutLogger {
});
}
if (event.reasoning && this.config?.getTelemetryLogPromptsEnabled()) {
data.push({
gemini_cli_key: EventMetadataKey.GEMINI_CLI_ROUTING_REASONING,
value: event.reasoning,
});
}
if (event.enable_numerical_routing !== undefined) {
data.push({
gemini_cli_key: EventMetadataKey.GEMINI_CLI_ROUTING_NUMERICAL_ENABLED,
value: event.enable_numerical_routing.toString(),
});
}
if (event.classifier_threshold) {
data.push({
gemini_cli_key:
EventMetadataKey.GEMINI_CLI_ROUTING_CLASSIFIER_THRESHOLD,
value: event.classifier_threshold,
});
}
this.enqueueLogEvent(this.createLogEvent(EventNames.MODEL_ROUTING, data));
this.flushIfNeeded();
}
@@ -7,7 +7,7 @@
// Defines valid event metadata keys for Clearcut logging.
export enum EventMetadataKey {
// Deleted enums: 24
// Next ID: 144
// Next ID: 148
GEMINI_CLI_KEY_UNKNOWN = 0,
@@ -542,4 +542,17 @@ export enum EventMetadataKey {
// Logs the duration spent in an approval mode in milliseconds.
GEMINI_CLI_APPROVAL_MODE_DURATION_MS = 143,
// ==========================================================================
// Model Routing Event Keys (Cont.)
// ==========================================================================
// Logs the reasoning for the routing decision.
GEMINI_CLI_ROUTING_REASONING = 145,
// Logs whether numerical routing was enabled.
GEMINI_CLI_ROUTING_NUMERICAL_ENABLED = 146,
// Logs the classifier threshold used.
GEMINI_CLI_ROUTING_CLASSIFIER_THRESHOLD = 147,
}
@@ -1734,6 +1734,37 @@ describe('loggers', () => {
);
});
it('should log the event with numerical routing fields', () => {
const event = new ModelRoutingEvent(
'gemini-pro',
'NumericalClassifier (Strict)',
150,
'[Score: 90 / Threshold: 80] reasoning',
false,
undefined,
true,
'80',
);
logModelRouting(mockConfig, event);
expect(
ClearcutLogger.prototype.logModelRoutingEvent,
).toHaveBeenCalledWith(event);
expect(mockLogger.emit).toHaveBeenCalledWith({
body: 'Model routing decision. Model: gemini-pro, Source: NumericalClassifier (Strict)',
attributes: {
'session.id': 'test-session-id',
'user.email': 'test-user@example.com',
'installation.id': 'test-installation-id',
...event,
'event.name': EVENT_MODEL_ROUTING,
interactive: false,
},
});
});
it('should only log to Clearcut if OTEL SDK is not initialized', () => {
vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(false);
vi.spyOn(sdk, 'bufferTelemetryEvent').mockImplementation(() => {});
+3 -3
View File
@@ -489,7 +489,7 @@ describe('Telemetry Metrics', () => {
initializeMetricsModule(mockConfig);
const event = new ModelRoutingEvent(
'gemini-pro',
'classifier',
'Classifier',
200,
'test-reason',
true,
@@ -502,7 +502,7 @@ describe('Telemetry Metrics', () => {
'installation.id': 'test-installation-id',
'user.email': 'test@example.com',
'routing.decision_model': 'gemini-pro',
'routing.decision_source': 'classifier',
'routing.decision_source': 'Classifier',
'routing.failed': true,
'routing.reasoning': 'test-reason',
});
@@ -513,7 +513,7 @@ describe('Telemetry Metrics', () => {
'installation.id': 'test-installation-id',
'user.email': 'test@example.com',
'routing.decision_model': 'gemini-pro',
'routing.decision_source': 'classifier',
'routing.decision_source': 'Classifier',
'routing.failed': true,
'routing.reasoning': 'test-reason',
'routing.error_message': 'test-error',
+33 -8
View File
@@ -38,6 +38,7 @@ import { AuthProviderType } from '../config/config.js';
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import { XcodeMcpBridgeFixTransport } from './xcode-mcp-fix-transport.js';
import type { CallableTool, FunctionCall, Part, Tool } from '@google/genai';
import { basename } from 'node:path';
@@ -1867,7 +1868,7 @@ export async function createTransport(
}
if (mcpServerConfig.command) {
const transport = new StdioClientTransport({
let transport: Transport = new StdioClientTransport({
command: mcpServerConfig.command,
args: mcpServerConfig.args || [],
env: {
@@ -1877,14 +1878,38 @@ export async function createTransport(
cwd: mcpServerConfig.cwd,
stderr: 'pipe',
});
// Fix for Xcode 26.3 mcpbridge non-compliant responses
// It returns JSON in `content` instead of `structuredContent`
if (
mcpServerConfig.command === 'xcrun' &&
mcpServerConfig.args?.includes('mcpbridge')
) {
transport = new XcodeMcpBridgeFixTransport(transport);
}
if (debugMode) {
transport.stderr!.on('data', (data) => {
const stderrStr = data.toString().trim();
debugLogger.debug(
`[DEBUG] [MCP STDERR (${mcpServerName})]: `,
stderrStr,
);
});
// The `XcodeMcpBridgeFixTransport` wrapper hides the underlying `StdioClientTransport`,
// which exposes `stderr` for debug logging. We need to unwrap it to attach the listener.
const underlyingTransport =
transport instanceof XcodeMcpBridgeFixTransport
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(transport as any).transport
: transport;
if (
underlyingTransport instanceof StdioClientTransport &&
underlyingTransport.stderr
) {
underlyingTransport.stderr.on('data', (data) => {
const stderrStr = data.toString().trim();
debugLogger.debug(
`[DEBUG] [MCP STDERR (${mcpServerName})]: `,
stderrStr,
);
});
}
}
return transport;
}
-2
View File
@@ -46,7 +46,6 @@ export const ALL_BUILTIN_TOOL_NAMES = [
LS_TOOL_NAME,
MEMORY_TOOL_NAME,
ACTIVATE_SKILL_TOOL_NAME,
ASK_USER_TOOL_NAME,
] as const;
/**
@@ -60,7 +59,6 @@ export const PLAN_MODE_TOOLS = [
READ_FILE_TOOL_NAME,
LS_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
ASK_USER_TOOL_NAME,
] as const;
/**
@@ -0,0 +1,120 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { EventEmitter } from 'node:events';
import { XcodeMcpBridgeFixTransport } from './xcode-mcp-fix-transport.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
// Mock Transport that simulates the mcpbridge behavior
class MockBadMcpBridgeTransport extends EventEmitter implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
async start() {}
async close() {}
async send(_message: JSONRPCMessage) {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emitMessage(msg: any) {
this.onmessage?.(msg);
}
}
describe('Xcode MCP Bridge Fix', () => {
it('intercepts and fixes the non-compliant mcpbridge response', async () => {
const mockTransport = new MockBadMcpBridgeTransport();
const fixTransport = new XcodeMcpBridgeFixTransport(mockTransport);
// We need to capture what the fixTransport emits to its listeners
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages: any[] = [];
fixTransport.onmessage = (msg) => {
messages.push(msg);
};
await fixTransport.start();
// SCENARIO 1: Bad response from Xcode
// It has `content` stringified JSON, but misses `structuredContent`
const badPayload = {
jsonrpc: '2.0',
id: 1,
result: {
content: [
{
type: 'text',
text: JSON.stringify({
windows: [{ title: 'HelloWorld', path: '/path/to/project' }],
}),
},
],
// Missing: structuredContent
},
};
mockTransport.emitMessage(badPayload);
// Verify the message received by the client (listener of fixTransport)
const fixedMsg = messages.find((m) => m.id === 1);
expect(fixedMsg).toBeDefined();
expect(fixedMsg.result.structuredContent).toBeDefined();
expect(fixedMsg.result.structuredContent.windows[0].title).toBe(
'HelloWorld',
);
// SCENARIO 2: Good response (should be untouched)
const goodPayload = {
jsonrpc: '2.0',
id: 2,
result: {
content: [{ type: 'text', text: 'normal text' }],
structuredContent: { some: 'data' },
},
};
mockTransport.emitMessage(goodPayload);
const goodMsg = messages.find((m) => m.id === 2);
expect(goodMsg).toBeDefined();
expect(goodMsg.result.structuredContent).toEqual({ some: 'data' });
});
it('ignores responses that cannot be parsed as JSON', async () => {
const mockTransport = new MockBadMcpBridgeTransport();
const fixTransport = new XcodeMcpBridgeFixTransport(mockTransport);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages: any[] = [];
fixTransport.onmessage = (msg) => {
messages.push(msg);
};
await fixTransport.start();
const nonJsonPayload = {
jsonrpc: '2.0',
id: 3,
result: {
content: [
{
type: 'text',
text: "Just some plain text that isn't JSON",
},
],
},
};
mockTransport.emitMessage(nonJsonPayload);
const msg = messages.find((m) => m.id === 3);
expect(msg).toBeDefined();
expect(msg.result.structuredContent).toBeUndefined();
expect(msg.result.content[0].text).toBe(
"Just some plain text that isn't JSON",
);
});
});
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type {
JSONRPCMessage,
JSONRPCResponse,
} from '@modelcontextprotocol/sdk/types.js';
import { EventEmitter } from 'node:events';
/**
* A wrapper transport that intercepts messages from Xcode's mcpbridge and fixes
* non-compliant responses.
*
* Issue: Xcode 26.3's mcpbridge returns tool results in `content` but misses
* `structuredContent` when the tool has an output schema.
*
* Fix: Parse the text content as JSON and populate `structuredContent`.
*/
export class XcodeMcpBridgeFixTransport
extends EventEmitter
implements Transport
{
constructor(private readonly transport: Transport) {
super();
// Forward messages from the underlying transport
this.transport.onmessage = (message) => {
this.handleMessage(message);
};
this.transport.onclose = () => {
this.onclose?.();
};
this.transport.onerror = (error) => {
this.onerror?.(error);
};
}
// Transport interface implementation
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
async start(): Promise<void> {
await this.transport.start();
}
async close(): Promise<void> {
await this.transport.close();
}
async send(message: JSONRPCMessage): Promise<void> {
await this.transport.send(message);
}
private handleMessage(message: JSONRPCMessage) {
if (this.isJsonResponse(message)) {
this.fixStructuredContent(message);
}
this.onmessage?.(message);
}
private isJsonResponse(message: JSONRPCMessage): message is JSONRPCResponse {
return 'result' in message || 'error' in message;
}
private fixStructuredContent(response: JSONRPCResponse) {
if (!('result' in response)) return;
// We can cast because we verified 'result' is in response,
// but TS might still be picky if the type is a strict union.
// Let's treat it safely.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = response.result as any;
// Check if we have content but missing structuredContent
if (
result.content &&
Array.isArray(result.content) &&
result.content.length > 0 &&
!result.structuredContent
) {
const firstItem = result.content[0];
if (firstItem.type === 'text' && typeof firstItem.text === 'string') {
try {
// Attempt to parse the text as JSON
const parsed = JSON.parse(firstItem.text);
// If successful, populate structuredContent
result.structuredContent = parsed;
} catch (_) {
// Ignored: Content is likely plain text, not JSON.
}
}
}
}
}
+11 -11
View File
@@ -101,33 +101,33 @@ describe('retryWithBackoff', () => {
expect(mockFn).toHaveBeenCalledTimes(3);
});
it('should default to 10 maxAttempts if no options are provided', async () => {
// This function will fail more than 10 times to ensure all retries are used.
const mockFn = createFailingFunction(15);
it('should default to 3 maxAttempts if no options are provided', async () => {
// This function will fail more than 3 times to ensure all retries are used.
const mockFn = createFailingFunction(5);
const promise = retryWithBackoff(mockFn);
await Promise.all([
expect(promise).rejects.toThrow('Simulated error attempt 10'),
expect(promise).rejects.toThrow('Simulated error attempt 3'),
vi.runAllTimersAsync(),
]);
expect(mockFn).toHaveBeenCalledTimes(10);
expect(mockFn).toHaveBeenCalledTimes(3);
});
it('should default to 10 maxAttempts if options.maxAttempts is undefined', async () => {
// This function will fail more than 10 times to ensure all retries are used.
const mockFn = createFailingFunction(15);
it('should default to 3 maxAttempts if options.maxAttempts is undefined', async () => {
// This function will fail more than 3 times to ensure all retries are used.
const mockFn = createFailingFunction(5);
const promise = retryWithBackoff(mockFn, { maxAttempts: undefined });
// Expect it to fail with the error from the 10th attempt.
// Expect it to fail with the error from the 3rd attempt.
await Promise.all([
expect(promise).rejects.toThrow('Simulated error attempt 10'),
expect(promise).rejects.toThrow('Simulated error attempt 3'),
vi.runAllTimersAsync(),
]);
expect(mockFn).toHaveBeenCalledTimes(10);
expect(mockFn).toHaveBeenCalledTimes(3);
});
it('should not retry if shouldRetry returns false', async () => {
+2 -1
View File
@@ -18,6 +18,7 @@ import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
import type { RetryAvailabilityContext } from '../availability/modelPolicy.js';
export type { RetryAvailabilityContext };
export const DEFAULT_MAX_ATTEMPTS = 3;
export interface RetryOptions {
maxAttempts: number;
@@ -40,7 +41,7 @@ export interface RetryOptions {
}
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
maxAttempts: 10,
maxAttempts: DEFAULT_MAX_ATTEMPTS,
initialDelayMs: 5000,
maxDelayMs: 30000, // 30 seconds
shouldRetryOnError: isRetryableError,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"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.27.0-nightly.20260121.97aac696f",
"version": "0.27.4",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+1 -1
View File
@@ -1537,7 +1537,7 @@
"enabled": {
"title": "Enable Hooks",
"description": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.",
"markdownDescription": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `true`",
"markdownDescription": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
},