mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 563c83c4a3 | |||
| d5a135b144 | |||
| f7a4e7281b | |||
| 3d2d44d74d | |||
| 9c7a329cea | |||
| 7f91d7501b | |||
| f38a2ce0cb | |||
| ec10a76923 | |||
| 57c7f6faeb | |||
| 93c2f0cabb | |||
| 7fe17b5dad | |||
| 3384a73b00 | |||
| 88b0b0fe8b | |||
| 8349cc0d10 | |||
| f55b52ab58 | |||
| fd85363d13 | |||
| 7ce9aa518e | |||
| 68bde280d0 | |||
| 09800ea74e | |||
| 9cf06a3e00 | |||
| 3c33fcb69c | |||
| 6f4a2a2c2a | |||
| 9954615542 | |||
| c7a044b091 |
@@ -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 -->
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+8
-31
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.3",
|
||||
"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.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.3",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.3",
|
||||
"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.3"
|
||||
},
|
||||
"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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.3",
|
||||
"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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
metadata: {
|
||||
source: 'Classifier (Control)',
|
||||
source: 'NumericalClassifier (Control)',
|
||||
latencyMs: expect.any(Number),
|
||||
reasoning: expect.stringContaining('Score: 40 / Threshold: 50'),
|
||||
},
|
||||
@@ -148,7 +148,7 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: {
|
||||
source: 'Classifier (Control)',
|
||||
source: 'NumericalClassifier (Control)',
|
||||
latencyMs: expect.any(Number),
|
||||
reasoning: expect.stringContaining('Score: 60 / Threshold: 50'),
|
||||
},
|
||||
@@ -174,7 +174,7 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_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'),
|
||||
},
|
||||
@@ -200,7 +200,7 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: {
|
||||
source: 'Classifier (Strict)',
|
||||
source: 'NumericalClassifier (Strict)',
|
||||
latencyMs: expect.any(Number),
|
||||
reasoning: expect.stringContaining('Score: 90 / Threshold: 80'),
|
||||
},
|
||||
@@ -228,7 +228,7 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_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'),
|
||||
},
|
||||
@@ -254,7 +254,7 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_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'),
|
||||
},
|
||||
@@ -280,7 +280,7 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_GEMINI_MODEL, // Score 35 >= Threshold 30
|
||||
metadata: {
|
||||
source: 'Classifier (Remote)',
|
||||
source: 'NumericalClassifier (Remote)',
|
||||
latencyMs: expect.any(Number),
|
||||
reasoning: expect.stringContaining('Score: 35 / Threshold: 30'),
|
||||
},
|
||||
@@ -308,7 +308,7 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_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'),
|
||||
},
|
||||
@@ -335,7 +335,7 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
metadata: {
|
||||
source: 'Classifier (Control)',
|
||||
source: 'NumericalClassifier (Control)',
|
||||
latencyMs: expect.any(Number),
|
||||
reasoning: expect.stringContaining('Score: 40 / Threshold: 50'),
|
||||
},
|
||||
@@ -362,7 +362,7 @@ describe('NumericalClassifierStrategy', () => {
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: {
|
||||
source: 'Classifier (Control)',
|
||||
source: 'NumericalClassifier (Control)',
|
||||
latencyMs: expect.any(Number),
|
||||
reasoning: expect.stringContaining('Score: 60 / Threshold: 50'),
|
||||
},
|
||||
|
||||
@@ -187,7 +187,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(() => {});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.3",
|
||||
"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.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.3",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user