Compare commits

..

20 Commits

Author SHA1 Message Date
gemini-cli-robot c316fc6c51 chore(release): v0.33.0-preview.4 2026-03-06 01:20:57 +00:00
gemini-cli-robot 7e6e40c814 fix(patch): cherry-pick 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch version v0.33.0-preview.3 and create version 0.33.0-preview.4 (#21349)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-03-05 16:53:40 -08:00
gemini-cli-robot b25c8137f6 chore(release): v0.33.0-preview.3 2026-03-05 23:56:03 +00:00
gemini-cli-robot 03a8fc3113 fix(patch): cherry-pick 0135b03 to release/v0.33.0-preview.2-pr-21171 [CONFLICTS] (#21336)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-03-05 23:22:08 +00:00
gemini-cli-robot 393e9a171f chore(release): v0.33.0-preview.2 2026-03-05 22:10:39 +00:00
gemini-cli-robot 59702f913d fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch version v0.33.0-preview.1 and create version 0.33.0-preview.2 (#21300)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-03-05 12:28:20 -08:00
gemini-cli-robot b3439e1458 chore(release): v0.33.0-preview.1 2026-03-04 04:12:41 +00:00
gemini-cli-robot b21b289e26 fix(patch): cherry-pick 0659ad1 to release/v0.33.0-preview.0-pr-21042 to patch version v0.33.0-preview.0 and create version 0.33.0-preview.1 (#21047)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-03 18:48:23 -08:00
gemini-cli-robot 56a63a35e2 chore(release): v0.33.0-preview.0 2026-03-03 23:20:33 +00:00
Shreya Keshive 34f0c1538b feat(acp): add set models interface (#20991) 2026-03-03 22:29:42 +00:00
Sehoon Shon c70c95ead3 remove hardcoded tiername when missing tier (#21022) 2026-03-03 22:16:37 +00:00
Dev Randalpura f3bbe6e77a fix(core): send shell output to model on cancel (#20501) 2026-03-03 22:10:16 +00:00
Abhi 28e79831ac fix(core): sanitize and length-check MCP tool qualified names (#20987) 2026-03-03 21:38:52 +00:00
Sam Roberts fdf5b18cfb Format the quota/limit style guide. (#21017) 2026-03-03 21:37:05 +00:00
ruomeng b5f3eb2c9c feat(plan): add copy subcommand to plan (#20491) (#20988) 2026-03-03 21:36:51 +00:00
Ale Aadithya 2a84090dd5 Docs/add hooks reference (#20961)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-03 21:29:15 +00:00
Sri Pasumarthi 27d7aeb1ed feat: Implement slash command handling in ACP for /memory,/init,/extensions and /restore (#20528) 2026-03-03 21:29:14 +00:00
Tommaso Sciortino d6c560498b fix(cli): pin clipboardy to ~5.2.x (#21009) 2026-03-03 21:01:29 +00:00
Sam Roberts 4500da339b Update docs-writer skill with new resource (#20917) 2026-03-03 20:49:18 +00:00
Ishaan Gupta 4be08a2261 refactor common settings logic for skills,agents (#17490)
Co-authored-by: ved015 <vedant.04.mahajan@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-03 19:25:17 +00:00
76 changed files with 3477 additions and 1197 deletions
+4
View File
@@ -45,6 +45,10 @@ Write precisely to ensure your instructions are unambiguous.
specific verbs.
- **Examples:** Use meaningful names in examples; avoid placeholders like
"foo" or "bar."
- **Quota and limit terminology:** For any content involving resource capacity
or using the word "quota" or "limit", strictly adhere to the guidelines in
the `quota-limit-style-guide.md` resource file. Generally, Use "quota" for the
administrative bucket and "limit" for the numerical ceiling.
### Formatting and syntax
Apply consistent formatting to make documentation visually organized and
@@ -0,0 +1,61 @@
# Style Guide: Quota vs. Limit
This guide defines the usage of "quota," "limit," and related terms in
user-facing interfaces.
## TL;DR
- **`quota`**: The administrative "bucket." Use for settings, billing, and
requesting increases. (e.g., "Adjust your storage **quota**.")
- **`limit`**: The real-time numerical "ceiling." Use for error messages when a
user is blocked. (e.g., "You've reached your request **limit**.")
- **When blocked, combine them:** Explain the **limit** that was hit and the
**quota** that is the remedy. (e.g., "You've reached the request **limit** for
your developer **quota**.")
- **Related terms:** Use `usage` for consumption tracking, `restriction` for
fixed rules, and `reset` for when a limit refreshes.
---
## Detailed Guidelines
### Definitions
- **Quota is the "what":** It identifies the category of resource being managed
(e.g., storage quota, GPU quota, request/prompt quota).
- **Limit is the "how much":** It defines the numerical boundary.
Use **quota** when referring to the administrative concept or the request for
more. Use **limit** when discussing the specific point of exhaustion.
### When to use "quota"
Use this term for **account management, billing, and settings.** It describes
the entitlement the user has purchased or been assigned.
**Examples:**
- **Navigation label:** Quota and usage
- **Contextual help:** Your **usage quota** is managed by your organization. To
request an increase, contact your administrator.
### When to use "limit"
Use this term for **real-time feedback, notifications, and error messages.** It
identifies the specific wall the user just hit.
**Examples:**
- **Error message:** Youve reached the 50-request-per-minute **limit**.
- **Inline warning:** Input exceeds the 32k token **limit**.
### How to use both together
When a user is blocked, combine both terms to explain the **event** (limit) and
the **remedy** (quota).
**Example:**
- **Heading:** Daily usage limit reached
- **Body:** You've reached the maximum daily capacity for your developer quota.
To continue working today, upgrade your quota.
+5
View File
@@ -21,6 +21,7 @@ implementation. It allows you to:
- [Entering Plan Mode](#entering-plan-mode)
- [Planning Workflow](#planning-workflow)
- [Exiting Plan Mode](#exiting-plan-mode)
- [Commands](#commands)
- [Tool Restrictions](#tool-restrictions)
- [Customizing Planning with Skills](#customizing-planning-with-skills)
- [Customizing Policies](#customizing-policies)
@@ -126,6 +127,10 @@ To exit Plan Mode, you can:
- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the
finalized plan for your approval.
### Commands
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
## Tool Restrictions
Plan Mode enforces strict safety policies to prevent accidental changes.
-2
View File
@@ -264,8 +264,6 @@ it yourself; just report it.
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. |
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. |
| `policy` | object | No | Scoped policy settings for the agent (see [Policy Engine](../reference/policy-engine.md)). |
| `mcp_servers` | object | No | MCP servers private to this agent. Key is server name, value is server configuration. |
### Optimizing your subagent
+3
View File
@@ -270,6 +270,9 @@ Slash commands provide meta-level control over the CLI itself.
one has been generated.
- **Note:** This feature requires the `experimental.plan` setting to be
enabled in your configuration.
- **Sub-commands:**
- **`copy`**:
- **Description:** Copy the currently approved plan to your clipboard.
### `/policies`
+8 -1
View File
@@ -94,7 +94,14 @@
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{ "label": "Hooks", "slug": "docs/hooks" },
{
"label": "Hooks",
"collapsed": true,
"items": [
{ "label": "Overview", "slug": "docs/hooks" },
{ "label": "Reference", "slug": "docs/hooks/reference" }
]
},
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
+2 -2
View File
@@ -55,7 +55,7 @@ describe.skip('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
child = spawn('node', [bundlePath, '--experimental-acp'], {
child = spawn('node', [bundlePath, '--acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
@@ -120,7 +120,7 @@ describe.skip('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
child = spawn('node', [bundlePath, '--experimental-acp'], {
child = spawn('node', [bundlePath, '--acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
+1 -1
View File
@@ -58,7 +58,7 @@ describe('ACP telemetry', () => {
'node',
[
bundlePath,
'--experimental-acp',
'--acp',
'--fake-responses',
join(rig.testDir!, 'fake-responses.json'),
],
+244 -71
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"workspaces": [
"packages/*"
],
@@ -5464,13 +5464,6 @@
"node": ">=8"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT",
"peer": true
},
"node_modules/array-includes": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
@@ -6305,16 +6298,36 @@
"node": ">= 12"
}
},
"node_modules/clipboardy": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.0.0.tgz",
"integrity": "sha512-MQfKHaD09eP80Pev4qBxZLbxJK/ONnqfSYAPlCmPh+7BDboYtO/3BmB6HGzxDIT0SlTRc2tzS8lQqfcdLtZ0Kg==",
"node_modules/clipboard-image": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/clipboard-image/-/clipboard-image-0.1.0.tgz",
"integrity": "sha512-SWk7FgaXLNFld19peQ/rTe0n97lwR1WbkqxV6JKCAOh7U52AKV/PeMFCyt/8IhBdqyDA8rdyewQMKZqvWT5Akg==",
"license": "MIT",
"dependencies": {
"execa": "^9.6.0",
"run-jxa": "^3.0.0"
},
"bin": {
"clipboard-image": "cli.js"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clipboardy": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.2.1.tgz",
"integrity": "sha512-RWp4E/ivQAzgF4QSWA9sjeW+Bjo+U2SvebkDhNIfO7y65eGdXPUxMTdIKYsn+bxM3ItPHGm3e68Bv3fgQ3mARw==",
"license": "MIT",
"dependencies": {
"clipboard-image": "^0.1.0",
"execa": "^9.6.1",
"is-wayland": "^0.1.0",
"is-wsl": "^3.1.0",
"is64bit": "^2.0.0"
"is64bit": "^2.0.0",
"powershell-utils": "^0.2.0"
},
"engines": {
"node": ">=20"
@@ -6570,7 +6583,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -6740,6 +6752,33 @@
"node": ">= 8"
}
},
"node_modules/crypto-random-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
"integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==",
"license": "MIT",
"dependencies": {
"type-fest": "^1.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/crypto-random-string/node_modules/type-fest": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -8430,9 +8469,9 @@
}
},
"node_modules/execa": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
"integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
@@ -8550,36 +8589,15 @@
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -8839,7 +8857,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -8848,18 +8865,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT",
"peer": true
},
"node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
"license": "MIT"
},
"node_modules/find-up": {
"version": "5.0.0",
@@ -11735,6 +11741,21 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/macos-version": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/macos-version/-/macos-version-6.0.0.tgz",
"integrity": "sha512-O2S8voA+pMfCHhBn/TIYDXzJ1qNHpPDU32oFxglKnVdJABiYYITt45oLkV9yhwA3E2FDwn3tQqUFrTsr1p3sBQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -11870,6 +11891,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -13420,6 +13447,18 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/powershell-utils": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz",
"integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -14302,6 +14341,107 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-jxa": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/run-jxa/-/run-jxa-3.0.0.tgz",
"integrity": "sha512-4f2CrY7H+sXkKXJn/cE6qRA3z+NMVO7zvlZ/nUV0e62yWftpiLAfw5eV9ZdomzWd2TXWwEIiGjAT57+lWIzzvA==",
"license": "MIT",
"dependencies": {
"execa": "^5.1.1",
"macos-version": "^6.0.0",
"subsume": "^4.0.0",
"type-fest": "^2.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-jxa/node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/run-jxa/node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-jxa/node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/run-jxa/node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/run-jxa/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/run-jxa/node_modules/strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/run-jxa/node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -15238,6 +15378,34 @@
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
"license": "MIT"
},
"node_modules/subsume": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/subsume/-/subsume-4.0.0.tgz",
"integrity": "sha512-BWnYJElmHbYZ/zKevy+TG+SsyoFCmRPDHJbR1MzLxkPOv1Jp/4hGhVUtP98s+wZBsBsHwCXvPTP0x287/WMjGg==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^5.0.0",
"unique-string": "^3.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/subsume/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/superagent": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz",
@@ -16220,6 +16388,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unique-string": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz",
"integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==",
"license": "MIT",
"dependencies": {
"crypto-random-string": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/universal-user-agent": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
@@ -16286,16 +16469,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@@ -17130,7 +17303,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -17188,7 +17361,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -17200,7 +17373,7 @@
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0",
"clipboardy": "~5.2.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
@@ -17271,7 +17444,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -17536,7 +17709,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17551,7 +17724,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17568,7 +17741,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17585,7 +17758,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.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.33.0-nightly.20260228.1ca5c05d0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-preview.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.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.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.33.0-nightly.20260228.1ca5c05d0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-preview.4"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -38,7 +38,7 @@
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0",
"clipboardy": "~5.2.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
@@ -14,7 +14,8 @@ import {
type Mock,
type Mocked,
} from 'vitest';
import { GeminiAgent, Session } from './zedIntegration.js';
import { GeminiAgent, Session } from './acpClient.js';
import type { CommandHandler } from './commandHandler.js';
import * as acp from '@agentclientprotocol/sdk';
import {
AuthType,
@@ -26,6 +27,7 @@ import {
type Config,
type MessageBus,
LlmRole,
type GitService,
} from '@google/gemini-cli-core';
import {
SettingScope,
@@ -62,7 +64,33 @@ vi.mock('node:path', async (importOriginal) => {
};
});
// Mock ReadManyFilesTool
vi.mock('../ui/commands/memoryCommand.js', () => ({
memoryCommand: {
name: 'memory',
action: vi.fn(),
},
}));
vi.mock('../ui/commands/extensionsCommand.js', () => ({
extensionsCommand: vi.fn().mockReturnValue({
name: 'extensions',
action: vi.fn(),
}),
}));
vi.mock('../ui/commands/restoreCommand.js', () => ({
restoreCommand: vi.fn().mockReturnValue({
name: 'restore',
action: vi.fn(),
}),
}));
vi.mock('../ui/commands/initCommand.js', () => ({
initCommand: {
name: 'init',
action: vi.fn(),
},
}));
vi.mock(
'@google/gemini-cli-core',
async (
@@ -145,6 +173,9 @@ describe('GeminiAgent', () => {
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>;
mockSettings = {
merged: {
@@ -177,7 +208,16 @@ describe('GeminiAgent', () => {
});
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
expect(response.authMethods).toHaveLength(3);
expect(response.authMethods).toHaveLength(4);
const gatewayAuth = response.authMethods?.find(
(m) => m.id === AuthType.GATEWAY,
);
expect(gatewayAuth?._meta).toEqual({
gateway: {
protocol: 'google',
restartRequired: 'false',
},
});
const geminiAuth = response.authMethods?.find(
(m) => m.id === AuthType.USE_GEMINI,
);
@@ -197,6 +237,8 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
undefined,
undefined,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -216,6 +258,8 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
'test-api-key',
undefined,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -224,7 +268,47 @@ describe('GeminiAgent', () => {
);
});
it('should authenticate correctly with gateway method', async () => {
await agent.authenticate({
methodId: AuthType.GATEWAY,
_meta: {
gateway: {
baseUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
},
},
} as unknown as acp.AuthenticateRequest);
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.GATEWAY,
undefined,
'https://example.com',
{ Authorization: 'Bearer token' },
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.GATEWAY,
);
});
it('should throw acp.RequestError when gateway payload is malformed', async () => {
await expect(
agent.authenticate({
methodId: AuthType.GATEWAY,
_meta: {
gateway: {
// Invalid baseUrl
baseUrl: 123,
headers: { Authorization: 'Bearer token' },
},
},
} as unknown as acp.AuthenticateRequest),
).rejects.toThrow(/Malformed gateway payload/);
});
it('should create a new session', async () => {
vi.useFakeTimers();
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
@@ -237,6 +321,17 @@ describe('GeminiAgent', () => {
expect(loadCliConfig).toHaveBeenCalled();
expect(mockConfig.initialize).toHaveBeenCalled();
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
// Verify deferred call
await vi.runAllTimersAsync();
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'available_commands_update',
}),
}),
);
vi.useRealTimers();
});
it('should return modes without plan mode when plan is disabled', async () => {
@@ -263,6 +358,38 @@ describe('GeminiAgent', () => {
],
currentModeId: 'default',
});
expect(response.models).toEqual({
availableModels: expect.arrayContaining([
expect.objectContaining({
modelId: 'auto-gemini-2.5',
name: 'Auto (Gemini 2.5)',
}),
]),
currentModelId: 'gemini-pro',
});
});
it('should include preview models when user has access', async () => {
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
const response = await agent.newSession({
cwd: '/tmp',
mcpServers: [],
});
expect(response.models?.availableModels).toEqual(
expect.arrayContaining([
expect.objectContaining({
modelId: 'auto-gemini-3',
name: expect.stringContaining('Auto'),
}),
expect.objectContaining({
modelId: 'gemini-3.1-pro-preview',
name: 'gemini-3.1-pro-preview',
}),
]),
);
});
it('should return modes with plan mode when plan is enabled', async () => {
@@ -290,6 +417,15 @@ describe('GeminiAgent', () => {
],
currentModeId: 'plan',
});
expect(response.models).toEqual({
availableModels: expect.arrayContaining([
expect.objectContaining({
modelId: 'auto-gemini-2.5',
name: 'Auto (Gemini 2.5)',
}),
]),
currentModelId: 'gemini-pro',
});
});
it('should fail session creation if Gemini API key is missing', async () => {
@@ -439,6 +575,32 @@ describe('GeminiAgent', () => {
}),
).rejects.toThrow('Session not found: unknown');
});
it('should delegate setModel to session (unstable)', async () => {
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const session = (
agent as unknown as { sessions: Map<string, Session> }
).sessions.get('test-session-id');
if (!session) throw new Error('Session not found');
session.setModel = vi.fn().mockReturnValue({});
const result = await agent.unstable_setSessionModel({
sessionId: 'test-session-id',
modelId: 'gemini-2.0-pro-exp',
});
expect(session.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
expect(result).toEqual({});
});
it('should throw error when setting model on non-existent session (unstable)', async () => {
await expect(
agent.unstable_setSessionModel({
sessionId: 'unknown',
modelId: 'gemini-2.0-pro-exp',
}),
).rejects.toThrow('Session not found: unknown');
});
});
describe('Session', () => {
@@ -477,6 +639,7 @@ describe('Session', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getMcpServers: vi.fn(),
getFileService: vi.fn().mockReturnValue({
shouldIgnoreFile: vi.fn().mockReturnValue(false),
}),
@@ -486,7 +649,10 @@ describe('Session', () => {
getDebugMode: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
setApprovalMode: vi.fn(),
setModel: vi.fn(),
isPlanEnabled: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService),
waitForMcpInit: vi.fn(),
} as unknown as Mocked<Config>;
mockConnection = {
@@ -495,13 +661,38 @@ describe('Session', () => {
sendNotification: vi.fn(),
} as unknown as Mocked<acp.AgentSideConnection>;
session = new Session('session-1', mockChat, mockConfig, mockConnection);
session = new Session('session-1', mockChat, mockConfig, mockConnection, {
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
merged: { settings: {} },
errors: [],
} as unknown as LoadedSettings);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should send available commands', async () => {
await session.sendAvailableCommands();
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'available_commands_update',
availableCommands: expect.arrayContaining([
expect.objectContaining({ name: 'memory' }),
expect.objectContaining({ name: 'extensions' }),
expect.objectContaining({ name: 'restore' }),
expect.objectContaining({ name: 'init' }),
]),
}),
}),
);
});
it('should await MCP initialization before processing a prompt', async () => {
const stream = createMockStream([
{
@@ -551,6 +742,113 @@ describe('Session', () => {
expect(result).toEqual({ stopReason: 'end_turn' });
});
it('should handle /memory command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/memory view' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/memory view',
expect.any(Object),
);
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /extensions command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/extensions list' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/extensions list',
expect.any(Object),
);
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /extensions explore command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/extensions explore' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/extensions explore',
expect.any(Object),
);
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /restore command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/restore' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/restore',
expect.any(Object),
);
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /init command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/init' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object));
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle tool calls', async () => {
const stream1 = createMockStream([
{
@@ -1207,4 +1505,30 @@ describe('Session', () => {
'Invalid or unavailable mode: invalid-mode',
);
});
it('should set model on config', () => {
session.setModel('gemini-2.0-flash-exp');
expect(mockConfig.setModel).toHaveBeenCalledWith('gemini-2.0-flash-exp');
});
it('should handle unquoted commands from autocomplete (with empty leading parts)', async () => {
// Mock handleCommand to verify it gets called
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
await session.prompt({
sessionId: 'session-1',
prompt: [
{ type: 'text', text: '' },
{ type: 'text', text: '/memory' },
],
});
expect(handleCommandSpy).toHaveBeenCalledWith('/memory', expect.anything());
});
});
@@ -4,15 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
GeminiChat,
ToolResult,
ToolCallConfirmationDetails,
FilterFilesOptions,
ConversationRecord,
} from '@google/gemini-cli-core';
import {
type Config,
type GeminiChat,
type ToolResult,
type ToolCallConfirmationDetails,
type FilterFilesOptions,
type ConversationRecord,
CoreToolCallStatus,
AuthType,
logToolCall,
@@ -39,6 +37,16 @@ import {
ApprovalMode,
getVersion,
convertSessionToClientHistory,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
getDisplayString,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -61,11 +69,14 @@ import { loadCliConfig } from '../config/config.js';
import { runExitCleanup } from '../utils/cleanup.js';
import { SessionSelector } from '../utils/sessionUtils.js';
export async function runZedIntegration(
import { CommandHandler } from './commandHandler.js';
export async function runAcpClient(
config: Config,
settings: LoadedSettings,
argv: CliArgs,
) {
// ... (skip unchanged lines) ...
const { stdout: workingStdout } = createWorkingStdio();
const stdout = Writable.toWeb(workingStdout) as WritableStream;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -87,6 +98,8 @@ export class GeminiAgent {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
private apiKey: string | undefined;
private baseUrl: string | undefined;
private customHeaders: Record<string, string> | undefined;
constructor(
private config: Config,
@@ -120,6 +133,17 @@ export class GeminiAgent {
name: 'Vertex AI',
description: 'Use an API key with Vertex AI GenAI API',
},
{
id: AuthType.GATEWAY,
name: 'AI API Gateway',
description: 'Use a custom AI API Gateway',
_meta: {
gateway: {
protocol: 'google',
restartRequired: 'false',
},
},
},
];
await this.config.initialize();
@@ -168,7 +192,38 @@ export class GeminiAgent {
if (apiKey) {
this.apiKey = apiKey;
}
await this.config.refreshAuth(method, apiKey ?? this.apiKey);
// Extract gateway details if present
const gatewaySchema = z.object({
baseUrl: z.string().optional(),
headers: z.record(z.string()).optional(),
});
let baseUrl: string | undefined;
let headers: Record<string, string> | undefined;
if (meta?.['gateway']) {
const result = gatewaySchema.safeParse(meta['gateway']);
if (result.success) {
baseUrl = result.data.baseUrl;
headers = result.data.headers;
} else {
throw new acp.RequestError(
-32602,
`Malformed gateway payload: ${result.error.message}`,
);
}
}
this.baseUrl = baseUrl;
this.customHeaders = headers;
await this.config.refreshAuth(
method,
apiKey ?? this.apiKey,
baseUrl,
headers,
);
} catch (e) {
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
}
@@ -198,7 +253,12 @@ export class GeminiAgent {
let isAuthenticated = false;
let authErrorMessage = '';
try {
await config.refreshAuth(authType, this.apiKey);
await config.refreshAuth(
authType,
this.apiKey,
this.baseUrl,
this.customHeaders,
);
isAuthenticated = true;
// Extra validation for Gemini API key
@@ -240,16 +300,37 @@ export class GeminiAgent {
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
const session = new Session(sessionId, chat, config, this.connection);
const session = new Session(
sessionId,
chat,
config,
this.connection,
this.settings,
);
this.sessions.set(sessionId, session);
return {
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.sendAvailableCommands();
}, 0);
const { availableModels, currentModelId } = buildAvailableModels(
config,
loadedSettings,
);
const response = {
sessionId,
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
models: {
availableModels,
currentModelId,
},
};
return response;
}
async loadSession({
@@ -291,6 +372,7 @@ export class GeminiAgent {
geminiClient.getChat(),
config,
this.connection,
this.settings,
);
this.sessions.set(sessionId, session);
@@ -298,12 +380,27 @@ export class GeminiAgent {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.streamHistory(sessionData.messages);
return {
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.sendAvailableCommands();
}, 0);
const { availableModels, currentModelId } = buildAvailableModels(
config,
this.settings,
);
const response = {
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
models: {
availableModels,
currentModelId,
},
};
return response;
}
private async initializeSessionConfig(
@@ -323,7 +420,12 @@ export class GeminiAgent {
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
await config.refreshAuth(selectedAuthType, this.apiKey);
await config.refreshAuth(
selectedAuthType,
this.apiKey,
this.baseUrl,
this.customHeaders,
);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
@@ -414,16 +516,28 @@ export class GeminiAgent {
}
return session.setMode(params.modeId);
}
async unstable_setSessionModel(
params: acp.SetSessionModelRequest,
): Promise<acp.SetSessionModelResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
return session.setModel(params.modelId);
}
}
export class Session {
private pendingPrompt: AbortController | null = null;
private commandHandler = new CommandHandler();
constructor(
private readonly id: string,
private readonly chat: GeminiChat,
private readonly config: Config,
private readonly connection: acp.AgentSideConnection,
private readonly settings: LoadedSettings,
) {}
async cancelPendingPrompt(): Promise<void> {
@@ -446,6 +560,27 @@ export class Session {
return {};
}
private getAvailableCommands() {
return this.commandHandler.getAvailableCommands();
}
async sendAvailableCommands(): Promise<void> {
const availableCommands = this.getAvailableCommands().map((command) => ({
name: command.name,
description: command.description,
}));
await this.sendUpdate({
sessionUpdate: 'available_commands_update',
availableCommands,
});
}
setModel(modelId: acp.ModelId): acp.SetSessionModelResponse {
this.config.setModel(modelId);
return {};
}
async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
for (const msg of messages) {
const contentString = partListUnionToString(msg.content);
@@ -528,6 +663,41 @@ export class Session {
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
// Command interception
let commandText = '';
for (const part of parts) {
if (typeof part === 'object' && part !== null) {
if ('text' in part) {
// It is a text part
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion
const text = (part as any).text;
if (typeof text === 'string') {
commandText += text;
}
} else {
// Non-text part (image, embedded resource)
// Stop looking for command
break;
}
}
}
commandText = commandText.trim();
if (
commandText &&
(commandText.startsWith('/') || commandText.startsWith('$'))
) {
// If we found a command, pass it to handleCommand
// Note: handleCommand currently expects `commandText` to be the command string
// It uses `parts` argument but effectively ignores it in current implementation
const handled = await this.handleCommand(commandText, parts);
if (handled) {
return { stopReason: 'end_turn' };
}
}
let nextMessage: Content | null = { role: 'user', parts };
while (nextMessage !== null) {
@@ -627,9 +797,28 @@ export class Session {
return { stopReason: 'end_turn' };
}
private async sendUpdate(
update: acp.SessionNotification['update'],
): Promise<void> {
private async handleCommand(
commandText: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
parts: Part[],
): Promise<boolean> {
const gitService = await this.config.getGitService();
const commandContext = {
config: this.config,
settings: this.settings,
git: gitService,
sendMessage: async (text: string) => {
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
});
},
};
return this.commandHandler.handleCommand(commandText, commandContext);
}
private async sendUpdate(update: acp.SessionUpdate): Promise<void> {
const params: acp.SessionNotification = {
sessionId: this.id,
update,
@@ -1377,3 +1566,94 @@ function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
return modes;
}
function buildAvailableModels(
config: Config,
settings: LoadedSettings,
): {
availableModels: Array<{
modelId: string;
name: string;
description?: string;
}>;
currentModelId: string;
} {
const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
const mainOptions = [
{
value: DEFAULT_GEMINI_MODEL_AUTO,
title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
description:
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
},
];
if (shouldShowPreviewModels) {
mainOptions.unshift({
value: PREVIEW_GEMINI_MODEL_AUTO,
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
description: useGemini31
? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
});
}
const manualOptions = [
{
value: DEFAULT_GEMINI_MODEL,
title: getDisplayString(DEFAULT_GEMINI_MODEL),
},
{
value: DEFAULT_GEMINI_FLASH_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
},
{
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
},
];
if (shouldShowPreviewModels) {
const previewProModel = useGemini31
? PREVIEW_GEMINI_3_1_MODEL
: PREVIEW_GEMINI_MODEL;
const previewProValue = useCustomToolModel
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: previewProModel;
manualOptions.unshift(
{
value: previewProValue,
title: getDisplayString(previewProModel),
},
{
value: PREVIEW_GEMINI_FLASH_MODEL,
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
},
);
}
const scaleOptions = (
options: Array<{ value: string; title: string; description?: string }>,
) =>
options.map((o) => ({
modelId: o.value,
name: o.title,
description: o.description,
}));
return {
availableModels: [
...scaleOptions(mainOptions),
...scaleOptions(manualOptions),
],
currentModelId: preferredModel,
};
}
@@ -13,7 +13,7 @@ import {
type Mocked,
type Mock,
} from 'vitest';
import { GeminiAgent } from './zedIntegration.js';
import { GeminiAgent } from './acpClient.js';
import * as acp from '@agentclientprotocol/sdk';
import {
ApprovalMode,
@@ -93,6 +93,10 @@ describe('GeminiAgent Session Resume', () => {
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
@@ -203,6 +207,10 @@ describe('GeminiAgent Session Resume', () => {
],
currentModeId: ApprovalMode.DEFAULT,
},
models: {
availableModels: expect.any(Array) as unknown,
currentModelId: 'gemini-pro',
},
});
// Verify resumeChat received the correct arguments
@@ -0,0 +1,30 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandHandler } from './commandHandler.js';
import { describe, it, expect } from 'vitest';
describe('CommandHandler', () => {
it('parses commands correctly', () => {
const handler = new CommandHandler();
// @ts-expect-error - testing private method
const parse = (query: string) => handler.parseSlashCommand(query);
const memShow = parse('/memory show');
expect(memShow.commandToExecute?.name).toBe('memory show');
expect(memShow.args).toBe('');
const memAdd = parse('/memory add hello world');
expect(memAdd.commandToExecute?.name).toBe('memory add');
expect(memAdd.args).toBe('hello world');
const extList = parse('/extensions list');
expect(extList.commandToExecute?.name).toBe('extensions list');
const init = parse('/init');
expect(init.commandToExecute?.name).toBe('init');
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Command, CommandContext } from './commands/types.js';
import { CommandRegistry } from './commands/commandRegistry.js';
import { MemoryCommand } from './commands/memory.js';
import { ExtensionsCommand } from './commands/extensions.js';
import { InitCommand } from './commands/init.js';
import { RestoreCommand } from './commands/restore.js';
export class CommandHandler {
private registry: CommandRegistry;
constructor() {
this.registry = CommandHandler.createRegistry();
}
private static createRegistry(): CommandRegistry {
const registry = new CommandRegistry();
registry.register(new MemoryCommand());
registry.register(new ExtensionsCommand());
registry.register(new InitCommand());
registry.register(new RestoreCommand());
return registry;
}
getAvailableCommands(): Array<{ name: string; description: string }> {
return this.registry.getAllCommands().map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
}
/**
* Parses and executes a command string if it matches a registered command.
* Returns true if a command was handled, false otherwise.
*/
async handleCommand(
commandText: string,
context: CommandContext,
): Promise<boolean> {
const { commandToExecute, args } = this.parseSlashCommand(commandText);
if (commandToExecute) {
await this.runCommand(commandToExecute, args, context);
return true;
}
return false;
}
private async runCommand(
commandToExecute: Command,
args: string,
context: CommandContext,
): Promise<void> {
try {
const result = await commandToExecute.execute(
context,
args ? args.split(/\s+/) : [],
);
let messageContent = '';
if (typeof result.data === 'string') {
messageContent = result.data;
} else if (
typeof result.data === 'object' &&
result.data !== null &&
'content' in result.data
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
messageContent = (result.data as Record<string, any>)[
'content'
] as string;
} else {
messageContent = JSON.stringify(result.data, null, 2);
}
await context.sendMessage(messageContent);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
await context.sendMessage(`Error: ${errorMessage}`);
}
}
/**
* Parses a raw slash command string into its matching headless command and arguments.
* Mirrors `packages/cli/src/utils/commands.ts` logic.
*/
private parseSlashCommand(query: string): {
commandToExecute: Command | undefined;
args: string;
} {
const trimmed = query.trim();
const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p);
let currentCommands = this.registry.getAllCommands();
let commandToExecute: Command | undefined;
let pathIndex = 0;
for (const part of commandPath) {
const foundCommand = currentCommands.find((cmd) => {
const expectedName = commandPath.slice(0, pathIndex + 1).join(' ');
return (
cmd.name === part ||
cmd.name === expectedName ||
cmd.aliases?.includes(part) ||
cmd.aliases?.includes(expectedName)
);
});
if (foundCommand) {
commandToExecute = foundCommand;
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
} else {
break;
}
} else {
break;
}
}
const args = parts.slice(pathIndex).join(' ');
return { commandToExecute, args };
}
}
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import type { Command } from './types.js';
export class CommandRegistry {
private readonly commands = new Map<string, Command>();
register(command: Command) {
if (this.commands.has(command.name)) {
debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
return;
}
this.commands.set(command.name, command);
for (const subCommand of command.subCommands ?? []) {
this.register(subCommand);
}
}
get(commandName: string): Command | undefined {
return this.commands.get(commandName);
}
getAllCommands(): Command[] {
return [...this.commands.values()];
}
}
+428
View File
@@ -0,0 +1,428 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { listExtensions } from '@google/gemini-cli-core';
import { SettingScope } from '../../config/settings.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { getErrorMessage } from '../../utils/errors.js';
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
import { stat } from 'node:fs/promises';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { Config } from '@google/gemini-cli-core';
export class ExtensionsCommand implements Command {
readonly name = 'extensions';
readonly description = 'Manage extensions.';
readonly subCommands = [
new ListExtensionsCommand(),
new ExploreExtensionsCommand(),
new EnableExtensionCommand(),
new DisableExtensionCommand(),
new InstallExtensionCommand(),
new LinkExtensionCommand(),
new UninstallExtensionCommand(),
new RestartExtensionCommand(),
new UpdateExtensionCommand(),
];
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ListExtensionsCommand().execute(context, _);
}
}
export class ListExtensionsCommand implements Command {
readonly name = 'extensions list';
readonly description = 'Lists all installed extensions.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensions = listExtensions(context.config);
const data = extensions.length ? extensions : 'No extensions installed.';
return { name: this.name, data };
}
}
export class ExploreExtensionsCommand implements Command {
readonly name = 'extensions explore';
readonly description = 'Explore available extensions.';
async execute(
_context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensionsUrl = 'https://geminicli.com/extensions/';
return {
name: this.name,
data: `View or install available extensions at ${extensionsUrl}`,
};
}
}
function getEnableDisableContext(
config: Config,
args: string[],
invocationName: string,
) {
const extensionManager = config.getExtensionLoader();
if (!(extensionManager instanceof ExtensionManager)) {
return {
error: `Cannot ${invocationName} extensions in this environment.`,
};
}
if (args.length === 0) {
return {
error: `Usage: /extensions ${invocationName} <extension> [--scope=<user|workspace|session>]`,
};
}
let scope = SettingScope.User;
if (args.includes('--scope=workspace') || args.includes('workspace')) {
scope = SettingScope.Workspace;
} else if (args.includes('--scope=session') || args.includes('session')) {
scope = SettingScope.Session;
}
const name = args.filter(
(a) =>
!a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a),
)[0];
let names: string[] = [];
if (name === '--all') {
let extensions = extensionManager.getExtensions();
if (invocationName === 'enable') {
extensions = extensions.filter((ext) => !ext.isActive);
}
if (invocationName === 'disable') {
extensions = extensions.filter((ext) => ext.isActive);
}
names = extensions.map((ext) => ext.name);
} else if (name) {
names = [name];
} else {
return { error: 'No extension name provided.' };
}
return { extensionManager, names, scope };
}
export class EnableExtensionCommand implements Command {
readonly name = 'extensions enable';
readonly description = 'Enable an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const enableContext = getEnableDisableContext(
context.config,
args,
'enable',
);
if ('error' in enableContext) {
return { name: this.name, data: enableContext.error };
}
const { names, scope, extensionManager } = enableContext;
const output: string[] = [];
for (const name of names) {
try {
await extensionManager.enableExtension(name, scope);
output.push(`Extension "${name}" enabled for scope "${scope}".`);
const extension = extensionManager
.getExtensions()
.find((e) => e.name === name);
if (extension?.mcpServers) {
const mcpEnablementManager = McpServerEnablementManager.getInstance();
const mcpClientManager = context.config.getMcpClientManager();
const enabledServers = await mcpEnablementManager.autoEnableServers(
Object.keys(extension.mcpServers),
);
if (mcpClientManager && enabledServers.length > 0) {
const restartPromises = enabledServers.map((serverName) =>
mcpClientManager.restartServer(serverName).catch((error) => {
output.push(
`Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`,
);
}),
);
await Promise.all(restartPromises);
output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`);
}
}
} catch (e) {
output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`);
}
}
return { name: this.name, data: output.join('\n') || 'No action taken.' };
}
}
export class DisableExtensionCommand implements Command {
readonly name = 'extensions disable';
readonly description = 'Disable an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const enableContext = getEnableDisableContext(
context.config,
args,
'disable',
);
if ('error' in enableContext) {
return { name: this.name, data: enableContext.error };
}
const { names, scope, extensionManager } = enableContext;
const output: string[] = [];
for (const name of names) {
try {
await extensionManager.disableExtension(name, scope);
output.push(`Extension "${name}" disabled for scope "${scope}".`);
} catch (e) {
output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`);
}
}
return { name: this.name, data: output.join('\n') || 'No action taken.' };
}
}
export class InstallExtensionCommand implements Command {
readonly name = 'extensions install';
readonly description = 'Install an extension from a git repo or local path.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot install extensions in this environment.',
};
}
const source = args.join(' ').trim();
if (!source) {
return { name: this.name, data: `Usage: /extensions install <source>` };
}
if (/[;&|`'"]/.test(source)) {
return {
name: this.name,
data: `Invalid source: contains disallowed characters.`,
};
}
try {
const installMetadata = await inferInstallMetadata(source);
const extension =
await extensionLoader.installOrUpdateExtension(installMetadata);
return {
name: this.name,
data: `Extension "${extension.name}" installed successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`,
};
}
}
}
export class LinkExtensionCommand implements Command {
readonly name = 'extensions link';
readonly description = 'Link an extension from a local path.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot link extensions in this environment.',
};
}
const sourceFilepath = args.join(' ').trim();
if (!sourceFilepath) {
return { name: this.name, data: `Usage: /extensions link <source>` };
}
try {
await stat(sourceFilepath);
} catch (_error) {
return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
}
try {
const extension = await extensionLoader.installOrUpdateExtension({
source: sourceFilepath,
type: 'link',
});
return {
name: this.name,
data: `Extension "${extension.name}" linked successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to link extension: ${getErrorMessage(error)}`,
};
}
}
}
export class UninstallExtensionCommand implements Command {
readonly name = 'extensions uninstall';
readonly description = 'Uninstall an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot uninstall extensions in this environment.',
};
}
const name = args.join(' ').trim();
if (!name) {
return {
name: this.name,
data: `Usage: /extensions uninstall <extension-name>`,
};
}
try {
await extensionLoader.uninstallExtension(name, false);
return {
name: this.name,
data: `Extension "${name}" uninstalled successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to uninstall extension "${name}": ${getErrorMessage(error)}`,
};
}
}
}
export class RestartExtensionCommand implements Command {
readonly name = 'extensions restart';
readonly description = 'Restart an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return { name: this.name, data: 'Cannot restart extensions.' };
}
const all = args.includes('--all');
const names = all ? null : args.filter((a) => !!a);
if (!all && names?.length === 0) {
return {
name: this.name,
data: 'Usage: /extensions restart <extension-names>|--all',
};
}
let extensionsToRestart = extensionLoader
.getExtensions()
.filter((e) => e.isActive);
if (names) {
extensionsToRestart = extensionsToRestart.filter((e) =>
names.includes(e.name),
);
}
if (extensionsToRestart.length === 0) {
return {
name: this.name,
data: 'No active extensions matched the request.',
};
}
const output: string[] = [];
for (const extension of extensionsToRestart) {
try {
await extensionLoader.restartExtension(extension);
output.push(`Restarted "${extension.name}".`);
} catch (e) {
output.push(
`Failed to restart "${extension.name}": ${getErrorMessage(e)}`,
);
}
}
return { name: this.name, data: output.join('\n') };
}
}
export class UpdateExtensionCommand implements Command {
readonly name = 'extensions update';
readonly description = 'Update an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return { name: this.name, data: 'Cannot update extensions.' };
}
const all = args.includes('--all');
const names = all ? null : args.filter((a) => !!a);
if (!all && names?.length === 0) {
return {
name: this.name,
data: 'Usage: /extensions update <extension-names>|--all',
};
}
return {
name: this.name,
data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.',
};
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { performInit } from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class InitCommand implements Command {
name = 'init';
description = 'Analyzes the project and creates a tailored GEMINI.md file';
requiresWorkspace = true;
async execute(
context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
const targetDir = context.config.getTargetDir();
if (!targetDir) {
throw new Error('Command requires a workspace.');
}
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
const result = performInit(fs.existsSync(geminiMdPath));
switch (result.type) {
case 'message':
return {
name: this.name,
data: result,
};
case 'submit_prompt':
fs.writeFileSync(geminiMdPath, '', 'utf8');
if (typeof result.content !== 'string') {
throw new Error('Init command content must be a string.');
}
// Inform the user since we can't trigger the UI-based interactive agent loop here directly.
// We output the prompt text they can use to re-trigger the generation manually,
// or just seed the GEMINI.md file as we've done above.
return {
name: this.name,
data: {
type: 'message',
messageType: 'info',
content: `A template GEMINI.md has been created at ${geminiMdPath}.\n\nTo populate it with project context, you can run the following prompt in a new chat:\n\n${result.content}`,
},
};
default:
throw new Error('Unknown result type from performInit');
}
}
}
+121
View File
@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
addMemory,
listMemoryFiles,
refreshMemory,
showMemory,
} from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
const DEFAULT_SANITIZATION_CONFIG = {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
};
export class MemoryCommand implements Command {
readonly name = 'memory';
readonly description = 'Manage memory.';
readonly subCommands = [
new ShowMemoryCommand(),
new RefreshMemoryCommand(),
new ListMemoryCommand(),
new AddMemoryCommand(),
];
readonly requiresWorkspace = true;
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ShowMemoryCommand().execute(context, _);
}
}
export class ShowMemoryCommand implements Command {
readonly name = 'memory show';
readonly description = 'Shows the current memory contents.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = showMemory(context.config);
return { name: this.name, data: result.content };
}
}
export class RefreshMemoryCommand implements Command {
readonly name = 'memory refresh';
readonly aliases = ['memory reload'];
readonly description = 'Refreshes the memory from the source.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = await refreshMemory(context.config);
return { name: this.name, data: result.content };
}
}
export class ListMemoryCommand implements Command {
readonly name = 'memory list';
readonly description = 'Lists the paths of the GEMINI.md files in use.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = listMemoryFiles(context.config);
return { name: this.name, data: result.content };
}
}
export class AddMemoryCommand implements Command {
readonly name = 'memory add';
readonly description = 'Add content to the memory.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const textToAdd = args.join(' ').trim();
const result = addMemory(textToAdd);
if (result.type === 'message') {
return { name: this.name, data: result.content };
}
const toolRegistry = context.config.getToolRegistry();
const tool = toolRegistry.getTool(result.toolName);
if (tool) {
const abortController = new AbortController();
const signal = abortController.signal;
await context.sendMessage(`Saving memory via ${result.toolName}...`);
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
});
await refreshMemory(context.config);
return {
name: this.name,
data: `Added memory: "${textToAdd}"`,
};
} else {
return {
name: this.name,
data: `Error: Tool ${result.toolName} not found.`,
};
}
}
}
+178
View File
@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
getCheckpointInfoList,
getToolCallDataSchema,
isNodeError,
performRestore,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class RestoreCommand implements Command {
readonly name = 'restore';
readonly description =
'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created';
readonly requiresWorkspace = true;
readonly subCommands = [new ListCheckpointsCommand()];
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const { config, git: gitService } = context;
const argsStr = args.join(' ');
try {
if (!argsStr) {
return await new ListCheckpointsCommand().execute(context);
}
if (!config.getCheckpointingEnabled()) {
return {
name: this.name,
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
};
}
const selectedFile = argsStr.endsWith('.json')
? argsStr
: `${argsStr}.json`;
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
const filePath = path.join(checkpointDir, selectedFile);
let data: string;
try {
data = await fs.readFile(filePath, 'utf-8');
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return {
name: this.name,
data: `File not found: ${selectedFile}`,
};
}
throw error;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const toolCallData = JSON.parse(data);
const ToolCallDataSchema = getToolCallDataSchema();
const parseResult = ToolCallDataSchema.safeParse(toolCallData);
if (!parseResult.success) {
return {
name: this.name,
data: 'Checkpoint file is invalid or corrupted.',
};
}
const restoreResultGenerator = performRestore(
parseResult.data,
gitService,
);
const restoreResult = [];
for await (const result of restoreResultGenerator) {
restoreResult.push(result);
}
// Format the result nicely since Zed just dumps data
const formattedResult = restoreResult
.map((r) => {
if (r.type === 'message') {
return `[${r.messageType.toUpperCase()}] ${r.content}`;
} else if (r.type === 'load_history') {
return `Loaded history with ${r.clientHistory.length} messages.`;
}
return `Restored: ${JSON.stringify(r)}`;
})
.join('\n');
return {
name: this.name,
data: formattedResult,
};
} catch (error) {
return {
name: this.name,
data: `An unexpected error occurred during restore: ${error}`,
};
}
}
}
export class ListCheckpointsCommand implements Command {
readonly name = 'restore list';
readonly description = 'Lists all available checkpoints.';
async execute(context: CommandContext): Promise<CommandExecutionResponse> {
const { config } = context;
try {
if (!config.getCheckpointingEnabled()) {
return {
name: this.name,
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
};
}
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
try {
await fs.mkdir(checkpointDir, { recursive: true });
} catch (_e) {
// Ignore
}
const files = await fs.readdir(checkpointDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
if (jsonFiles.length === 0) {
return { name: this.name, data: 'No checkpoints found.' };
}
const checkpointFiles = new Map<string, string>();
for (const file of jsonFiles) {
const filePath = path.join(checkpointDir, file);
const data = await fs.readFile(filePath, 'utf-8');
checkpointFiles.set(file, data);
}
const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
const formatted = checkpointInfoList
.map((info) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const i = info as Record<string, any>;
const fileName = String(i['fileName'] || 'Unknown');
const toolName = String(i['toolName'] || 'Unknown');
const status = String(i['status'] || 'Unknown');
const timestamp = new Date(
Number(i['timestamp']) || 0,
).toLocaleString();
return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`;
})
.join('\n');
return {
name: this.name,
data: `Available Checkpoints:\n${formatted}`,
};
} catch (_error) {
return {
name: this.name,
data: 'An unexpected error occurred while listing checkpoints.',
};
}
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, GitService } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
export interface CommandContext {
config: Config;
settings: LoadedSettings;
git?: GitService;
sendMessage: (text: string) => Promise<void>;
}
export interface CommandArgument {
readonly name: string;
readonly description: string;
readonly isRequired?: boolean;
}
export interface Command {
readonly name: string;
readonly aliases?: string[];
readonly description: string;
readonly arguments?: CommandArgument[];
readonly subCommands?: Command[];
readonly requiresWorkspace?: boolean;
execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse>;
}
export interface CommandExecutionResponse {
readonly name: string;
readonly data: unknown;
}
+11 -3
View File
@@ -81,7 +81,8 @@ export interface CliArgs {
policy: string[] | undefined;
allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined;
experimentalAcp: boolean | undefined;
acp?: boolean;
experimentalAcp?: boolean;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined;
@@ -177,10 +178,15 @@ export async function parseArguments(
.filter(Boolean),
),
})
.option('experimental-acp', {
.option('acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('experimental-acp', {
type: 'boolean',
description:
'Starts the agent in ACP mode (deprecated, use --acp instead)',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
@@ -632,6 +638,7 @@ export async function loadCliConfig(
// -i/--prompt-interactive forces interactive mode with an initial prompt
const interactive =
!!argv.promptInteractive ||
!!argv.acp ||
!!argv.experimentalAcp ||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
!argv.isCommand);
@@ -758,6 +765,7 @@ export async function loadCliConfig(
}
return new Config({
acpMode: !!argv.acp || !!argv.experimentalAcp,
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -821,7 +829,7 @@ export async function loadCliConfig(
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession,
+30 -47
View File
@@ -733,47 +733,37 @@ Would you like to attempt to install via "git clone" instead?`,
}
}
let publicMcpServers: Record<string, MCPServerConfig> | undefined;
let privateMcpServers: Record<string, MCPServerConfig> | undefined;
if (config.mcpServers) {
if (this.settings.admin.mcp.enabled !== false) {
if (this.settings.admin.mcp.enabled === false) {
config.mcpServers = undefined;
} else {
// Apply admin allowlist if configured
const adminAllowlist = this.settings.admin.mcp.config;
const filteredMcpServers =
adminAllowlist && Object.keys(adminAllowlist).length > 0
? (() => {
const result = applyAdminAllowlist(
config.mcpServers,
adminAllowlist,
);
if (result.blockedServerNames.length > 0) {
const message = getAdminBlockedMcpServersMessage(
result.blockedServerNames,
undefined,
);
coreEvents.emitConsoleLog('warn', message);
}
return result.mcpServers ?? {};
})()
: config.mcpServers;
if (adminAllowlist && Object.keys(adminAllowlist).length > 0) {
const result = applyAdminAllowlist(
config.mcpServers,
adminAllowlist,
);
config.mcpServers = result.mcpServers;
const entries = Object.entries(filteredMcpServers).map(
([key, value]) =>
[key, filterMcpConfig(value)] as [string, MCPServerConfig],
);
if (result.blockedServerNames.length > 0) {
const message = getAdminBlockedMcpServersMessage(
result.blockedServerNames,
undefined,
);
coreEvents.emitConsoleLog('warn', message);
}
}
publicMcpServers = Object.fromEntries(
entries.filter(([, v]) => v.visibility !== 'private'),
);
privateMcpServers = Object.fromEntries(
entries.filter(([, v]) => v.visibility === 'private'),
);
if (Object.keys(publicMcpServers).length === 0)
publicMcpServers = undefined;
if (Object.keys(privateMcpServers).length === 0)
privateMcpServers = undefined;
// Then apply local filtering/sanitization
if (config.mcpServers) {
config.mcpServers = Object.fromEntries(
Object.entries(config.mcpServers).map(([key, value]) => [
key,
filterMcpConfig(value),
]),
);
}
}
}
@@ -864,16 +854,9 @@ Would you like to attempt to install via "git clone" instead?`,
const agentLoadResult = await loadAgentsFromDirectory(
path.join(effectiveExtensionPath, 'agents'),
);
agentLoadResult.agents = agentLoadResult.agents.map((agent) => {
const hydrated = recursivelyHydrateStrings(agent, hydrationContext);
if (privateMcpServers && hydrated.kind === 'local') {
hydrated.mcpServers = {
...privateMcpServers,
...hydrated.mcpServers,
};
}
return hydrated;
});
agentLoadResult.agents = agentLoadResult.agents.map((agent) =>
recursivelyHydrateStrings(agent, hydrationContext),
);
// Log errors but don't fail the entire extension load
for (const error of agentLoadResult.errors) {
@@ -888,7 +871,7 @@ Would you like to attempt to install via "git clone" instead?`,
path: effectiveExtensionPath,
contextFiles,
installMetadata,
mcpServers: publicMcpServers,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
hooks,
isActive: this.extensionEnablementManager.isEnabled(
+3 -3
View File
@@ -79,7 +79,7 @@ import {
type InitializationResult,
} from './core/initializer.js';
import { validateAuthMethod } from './config/auth.js';
import { runZedIntegration } from './zed-integration/zedIntegration.js';
import { runAcpClient } from './acp/acpClient.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
@@ -672,8 +672,8 @@ export async function main() {
await getOauthClient(settings.merged.security.auth.selectedType, config);
}
if (config.getExperimentalZedIntegration()) {
return runZedIntegration(config, settings, argv);
if (config.getAcpMode()) {
return runAcpClient(config, settings, argv);
}
let input = config.getQuestion();
+2 -2
View File
@@ -179,7 +179,7 @@ describe('gemini.tsx main function cleanup', () => {
vi.restoreAllMocks();
});
it('should log error when cleanupExpiredSessions fails', async () => {
it.skip('should log error when cleanupExpiredSessions fails', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
@@ -216,7 +216,7 @@ describe('gemini.tsx main function cleanup', () => {
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: vi.fn(() => false),
getExperimentalZedIntegration: vi.fn(() => true),
getAcpMode: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getProjectRoot: vi.fn(() => '/'),
+1 -1
View File
@@ -42,7 +42,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
setSessionId: vi.fn(),
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
getExperimentalZedIntegration: vi.fn(() => false),
getAcpMode: vi.fn(() => false),
isBrowserLaunchSuppressed: vi.fn(() => false),
setRemoteAdminSettings: vi.fn(),
isYoloModeDisabled: vi.fn(() => false),
-130
View File
@@ -2544,136 +2544,6 @@ describe('AppContainer State Management', () => {
});
});
describe('Expansion Persistence', () => {
let rerender: () => void;
let unmount: () => void;
let stdin: ReturnType<typeof render>['stdin'];
const setupExpansionPersistenceTest = async (
HighPriorityChild?: React.FC,
) => {
const getTree = () => (
<SettingsContext.Provider value={mockSettings}>
<KeypressProvider config={mockConfig}>
<OverflowProvider>
<AppContainer
config={mockConfig}
version="1.0.0"
initializationResult={mockInitResult}
/>
{HighPriorityChild && <HighPriorityChild />}
</OverflowProvider>
</KeypressProvider>
</SettingsContext.Provider>
);
const renderResult = render(getTree());
stdin = renderResult.stdin;
await act(async () => {
vi.advanceTimersByTime(100);
});
rerender = () => renderResult.rerender(getTree());
unmount = () => renderResult.unmount();
};
const writeStdin = async (sequence: string) => {
await act(async () => {
stdin.write(sequence);
// Advance timers to allow escape sequence parsing and broadcasting
vi.advanceTimersByTime(100);
});
rerender();
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should reset expansion when a key is NOT handled by anyone', async () => {
await setupExpansionPersistenceTest();
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
rerender();
expect(capturedUIState.constrainHeight).toBe(false);
// Press a random key that no one handles (hits Low priority fallback)
await writeStdin('x');
// Should be reset to true (collapsed)
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
it('should toggle expansion when Ctrl+O is pressed', async () => {
await setupExpansionPersistenceTest();
// Initial state is collapsed
expect(capturedUIState.constrainHeight).toBe(true);
// Press Ctrl+O to expand (Ctrl+O is sequence \x0f)
await writeStdin('\x0f');
expect(capturedUIState.constrainHeight).toBe(false);
// Press Ctrl+O again to collapse
await writeStdin('\x0f');
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
it('should NOT collapse when a high-priority component handles the key (e.g., up/down arrows)', async () => {
const NavigationHandler = () => {
// use real useKeypress
useKeypress(
(key: Key) => {
if (key.name === 'up' || key.name === 'down') {
return true; // Handle navigation
}
return false;
},
{ isActive: true, priority: true }, // High priority
);
return null;
};
await setupExpansionPersistenceTest(NavigationHandler);
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
rerender();
expect(capturedUIState.constrainHeight).toBe(false);
// 1. Simulate Up arrow (handled by high priority child)
// CSI A is Up arrow
await writeStdin('\u001b[A');
// Should STILL be expanded
expect(capturedUIState.constrainHeight).toBe(false);
// 2. Simulate Down arrow (handled by high priority child)
// CSI B is Down arrow
await writeStdin('\u001b[B');
// Should STILL be expanded
expect(capturedUIState.constrainHeight).toBe(false);
// 3. Sanity check: press an unhandled key
await writeStdin('x');
// Should finally collapse
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
});
describe('Shortcuts Help Visibility', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let mockedUseKeypress: Mock;
+1 -4
View File
@@ -1873,10 +1873,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
useKeypress(handleGlobalKeypress, {
isActive: true,
priority: KeypressPriority.Low,
});
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useKeypress(
() => {
@@ -14,7 +14,9 @@ import {
coreEvents,
processSingleFileContent,
type ProcessedFileReadResult,
readFileWithEncoding,
} from '@google/gemini-cli-core';
import { copyToClipboard } from '../utils/commandUtils.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -25,6 +27,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
emitFeedback: vi.fn(),
},
processSingleFileContent: vi.fn(),
readFileWithEncoding: vi.fn(),
partToString: vi.fn((val) => val),
};
});
@@ -35,9 +38,14 @@ vi.mock('node:path', async (importOriginal) => {
...actual,
default: { ...actual },
join: vi.fn((...args) => args.join('/')),
basename: vi.fn((p) => p.split('/').pop()),
};
});
vi.mock('../utils/commandUtils.js', () => ({
copyToClipboard: vi.fn(),
}));
describe('planCommand', () => {
let mockContext: CommandContext;
@@ -115,4 +123,46 @@ describe('planCommand', () => {
text: '# Approved Plan Content',
});
});
describe('copy subcommand', () => {
it('should copy the approved plan to clipboard', async () => {
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
vi.mocked(
mockContext.services.config!.getApprovedPlanPath,
).mockReturnValue(mockPlanPath);
vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content');
const copySubCommand = planCommand.subCommands?.find(
(sc) => sc.name === 'copy',
);
if (!copySubCommand?.action) throw new Error('Copy action missing');
await copySubCommand.action(mockContext, '');
expect(readFileWithEncoding).toHaveBeenCalledWith(mockPlanPath);
expect(copyToClipboard).toHaveBeenCalledWith('# Plan Content');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Plan copied to clipboard (approved-plan.md).',
);
});
it('should warn if no approved plan is found', async () => {
vi.mocked(
mockContext.services.config!.getApprovedPlanPath,
).mockReturnValue(undefined);
const copySubCommand = planCommand.subCommands?.find(
(sc) => sc.name === 'copy',
);
if (!copySubCommand?.action) throw new Error('Copy action missing');
await copySubCommand.action(mockContext, '');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
'No approved plan found to copy.',
);
});
});
});
+43 -2
View File
@@ -4,22 +4,54 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
import {
type CommandContext,
CommandKind,
type SlashCommand,
} from './types.js';
import {
ApprovalMode,
coreEvents,
debugLogger,
processSingleFileContent,
partToString,
readFileWithEncoding,
} from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
import * as path from 'node:path';
import { copyToClipboard } from '../utils/commandUtils.js';
async function copyAction(context: CommandContext) {
const config = context.services.config;
if (!config) {
debugLogger.debug('Plan copy command: config is not available in context');
return;
}
const planPath = config.getApprovedPlanPath();
if (!planPath) {
coreEvents.emitFeedback('warning', 'No approved plan found to copy.');
return;
}
try {
const content = await readFileWithEncoding(planPath);
await copyToClipboard(content);
coreEvents.emitFeedback(
'info',
`Plan copied to clipboard (${path.basename(planPath)}).`,
);
} catch (error) {
coreEvents.emitFeedback('error', `Failed to copy plan: ${error}`, error);
}
}
export const planCommand: SlashCommand = {
name: 'plan',
description: 'Switch to Plan Mode and view current plan',
kind: CommandKind.BUILT_IN,
autoExecute: true,
autoExecute: false,
action: async (context) => {
const config = context.services.config;
if (!config) {
@@ -62,4 +94,13 @@ export const planCommand: SlashCommand = {
);
}
},
subCommands: [
{
name: 'copy',
description: 'Copy the currently approved plan to your clipboard',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: copyAction,
},
],
};
@@ -47,6 +47,7 @@ describe('<UserIdentity />', () => {
const output = lastFrame();
expect(output).toContain('test@example.com');
expect(output).toContain('/auth');
expect(output).not.toContain('/upgrade');
unmount();
});
@@ -74,6 +75,7 @@ describe('<UserIdentity />', () => {
const output = lastFrame();
expect(output).toContain('Logged in with Google');
expect(output).toContain('/auth');
expect(output).not.toContain('/upgrade');
unmount();
});
@@ -130,6 +132,26 @@ describe('<UserIdentity />', () => {
const output = lastFrame();
expect(output).toContain(`Authenticated with ${AuthType.USE_GEMINI}`);
expect(output).toContain('/auth');
expect(output).not.toContain('/upgrade');
unmount();
});
it('should render specific tier name when provided', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
model: 'gemini-pro',
} as unknown as ContentGeneratorConfig);
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue('Enterprise Tier');
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Enterprise Tier');
expect(output).toContain('/upgrade');
unmount();
});
});
@@ -53,12 +53,14 @@ export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
</Box>
{/* Tier Name /upgrade */}
<Box>
<Text color={theme.text.primary} wrap="truncate-end">
{tierName ?? 'Gemini Code Assist for individuals'}
</Text>
<Text color={theme.text.secondary}> /upgrade</Text>
</Box>
{tierName && (
<Box>
<Text color={theme.text.primary} wrap="truncate-end">
{tierName}
</Text>
<Text color={theme.text.secondary}> /upgrade</Text>
</Box>
)}
</Box>
);
};
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import {
SettingScope,
type LoadedSettings,
type LoadableSettingScope,
} from '../config/settings.js';
import { enableAgent, disableAgent } from './agentSettings.js';
function createMockLoadedSettings(opts: {
userSettings?: Record<string, unknown>;
workspaceSettings?: Record<string, unknown>;
userPath?: string;
workspacePath?: string;
}): LoadedSettings {
const scopes: Record<
string,
{
settings: Record<string, unknown>;
originalSettings: Record<string, unknown>;
path: string;
}
> = {
[SettingScope.User]: {
settings: opts.userSettings ?? {},
originalSettings: opts.userSettings ?? {},
path: opts.userPath ?? '/home/user/.gemini/settings.json',
},
[SettingScope.Workspace]: {
settings: opts.workspaceSettings ?? {},
originalSettings: opts.workspaceSettings ?? {},
path: opts.workspacePath ?? '/project/.gemini/settings.json',
},
};
return {
forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]),
setValue: vi.fn(),
} as unknown as LoadedSettings;
}
describe('agentSettings', () => {
describe('agentStrategy (via enableAgent / disableAgent)', () => {
describe('enableAgent', () => {
it('should return no-op when the agent is already enabled in both scopes', () => {
const settings = createMockLoadedSettings({
userSettings: {
agents: { overrides: { 'my-agent': { enabled: true } } },
},
workspaceSettings: {
agents: { overrides: { 'my-agent': { enabled: true } } },
},
});
const result = enableAgent(settings, 'my-agent');
expect(result.status).toBe('no-op');
expect(result.action).toBe('enable');
expect(result.agentName).toBe('my-agent');
expect(result.modifiedScopes).toHaveLength(0);
expect(settings.setValue).not.toHaveBeenCalled();
});
it('should enable the agent when not present in any scope', () => {
const settings = createMockLoadedSettings({
userSettings: {},
workspaceSettings: {},
});
const result = enableAgent(settings, 'my-agent');
expect(result.status).toBe('success');
expect(result.action).toBe('enable');
expect(result.agentName).toBe('my-agent');
expect(result.modifiedScopes).toHaveLength(2);
expect(settings.setValue).toHaveBeenCalledTimes(2);
});
it('should enable the agent only in the scope where it is not enabled', () => {
const settings = createMockLoadedSettings({
userSettings: {
agents: { overrides: { 'my-agent': { enabled: true } } },
},
workspaceSettings: {
agents: { overrides: { 'my-agent': { enabled: false } } },
},
});
const result = enableAgent(settings, 'my-agent');
expect(result.status).toBe('success');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace);
expect(result.alreadyInStateScopes).toHaveLength(1);
expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User);
expect(settings.setValue).toHaveBeenCalledTimes(1);
});
});
describe('disableAgent', () => {
it('should return no-op when agent is already explicitly disabled', () => {
const settings = createMockLoadedSettings({
userSettings: {
agents: { overrides: { 'my-agent': { enabled: false } } },
},
});
const result = disableAgent(settings, 'my-agent', SettingScope.User);
expect(result.status).toBe('no-op');
expect(result.action).toBe('disable');
expect(result.agentName).toBe('my-agent');
expect(settings.setValue).not.toHaveBeenCalled();
});
it('should disable the agent when it is currently enabled', () => {
const settings = createMockLoadedSettings({
userSettings: {
agents: { overrides: { 'my-agent': { enabled: true } } },
},
});
const result = disableAgent(settings, 'my-agent', SettingScope.User);
expect(result.status).toBe('success');
expect(result.action).toBe('disable');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
expect(settings.setValue).toHaveBeenCalledTimes(1);
});
it('should return error for an invalid scope', () => {
const settings = createMockLoadedSettings({});
const result = disableAgent(settings, 'my-agent', SettingScope.Session);
expect(result.status).toBe('error');
expect(result.error).toContain('Invalid settings scope');
});
});
});
});
+40 -107
View File
@@ -4,30 +4,41 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { SettingScope, LoadedSettings } from '../config/settings.js';
import {
SettingScope,
isLoadableSettingScope,
type LoadedSettings,
} from '../config/settings.js';
import type { ModifiedScope } from './skillSettings.js';
type FeatureActionResult,
type FeatureToggleStrategy,
enableFeature,
disableFeature,
} from './featureToggleUtils.js';
export type AgentActionStatus = 'success' | 'no-op' | 'error';
/**
* Metadata representing the result of an agent settings operation.
*/
export interface AgentActionResult {
status: AgentActionStatus;
export interface AgentActionResult
extends Omit<FeatureActionResult, 'featureName'> {
agentName: string;
action: 'enable' | 'disable';
/** Scopes where the agent's state was actually changed. */
modifiedScopes: ModifiedScope[];
/** Scopes where the agent was already in the desired state. */
alreadyInStateScopes: ModifiedScope[];
/** Error message if status is 'error'. */
error?: string;
}
const agentStrategy: FeatureToggleStrategy = {
needsEnabling: (settings, scope, agentName) => {
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
return agentOverrides?.[agentName]?.enabled !== true;
},
enable: (settings, scope, agentName) => {
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, true);
},
isExplicitlyDisabled: (settings, scope, agentName) => {
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
return agentOverrides?.[agentName]?.enabled === false;
},
disable: (settings, scope, agentName) => {
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, false);
},
};
/**
* Enables an agent by ensuring it is enabled in any writable scope (User and Workspace).
* It sets `agents.overrides.<agentName>.enabled` to `true`.
@@ -36,50 +47,14 @@ export function enableAgent(
settings: LoadedSettings,
agentName: string,
): AgentActionResult {
const writableScopes = [SettingScope.Workspace, SettingScope.User];
const foundInDisabledScopes: ModifiedScope[] = [];
const alreadyEnabledScopes: ModifiedScope[] = [];
for (const scope of writableScopes) {
if (isLoadableSettingScope(scope)) {
const scopePath = settings.forScope(scope).path;
const agentOverrides =
settings.forScope(scope).settings.agents?.overrides;
const isEnabled = agentOverrides?.[agentName]?.enabled === true;
if (!isEnabled) {
foundInDisabledScopes.push({ scope, path: scopePath });
} else {
alreadyEnabledScopes.push({ scope, path: scopePath });
}
}
}
if (foundInDisabledScopes.length === 0) {
return {
status: 'no-op',
agentName,
action: 'enable',
modifiedScopes: [],
alreadyInStateScopes: alreadyEnabledScopes,
};
}
const modifiedScopes: ModifiedScope[] = [];
for (const { scope, path } of foundInDisabledScopes) {
if (isLoadableSettingScope(scope)) {
// Explicitly enable it.
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, true);
modifiedScopes.push({ scope, path });
}
}
return {
status: 'success',
const { featureName, ...rest } = enableFeature(
settings,
agentName,
action: 'enable',
modifiedScopes,
alreadyInStateScopes: alreadyEnabledScopes,
agentStrategy,
);
return {
...rest,
agentName: featureName,
};
}
@@ -91,56 +66,14 @@ export function disableAgent(
agentName: string,
scope: SettingScope,
): AgentActionResult {
if (!isLoadableSettingScope(scope)) {
return {
status: 'error',
agentName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [],
error: `Invalid settings scope: ${scope}`,
};
}
const scopePath = settings.forScope(scope).path;
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
const isEnabled = agentOverrides?.[agentName]?.enabled !== false;
if (!isEnabled) {
return {
status: 'no-op',
agentName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [{ scope, path: scopePath }],
};
}
// Check if it's already disabled in the other writable scope
const otherScope =
scope === SettingScope.Workspace
? SettingScope.User
: SettingScope.Workspace;
const alreadyDisabledInOther: ModifiedScope[] = [];
if (isLoadableSettingScope(otherScope)) {
const otherOverrides =
settings.forScope(otherScope).settings.agents?.overrides;
if (otherOverrides?.[agentName]?.enabled === false) {
alreadyDisabledInOther.push({
scope: otherScope,
path: settings.forScope(otherScope).path,
});
}
}
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, false);
return {
status: 'success',
const { featureName, ...rest } = disableFeature(
settings,
agentName,
action: 'disable',
modifiedScopes: [{ scope, path: scopePath }],
alreadyInStateScopes: alreadyDisabledInOther,
scope,
agentStrategy,
);
return {
...rest,
agentName: featureName,
};
}
@@ -0,0 +1,195 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import {
enableFeature,
disableFeature,
type FeatureToggleStrategy,
} from './featureToggleUtils.js';
import {
SettingScope,
type LoadedSettings,
type LoadableSettingScope,
} from '../config/settings.js';
function createMockLoadedSettings(opts: {
userSettings?: Record<string, unknown>;
workspaceSettings?: Record<string, unknown>;
userPath?: string;
workspacePath?: string;
}): LoadedSettings {
const scopes: Record<
string,
{ settings: Record<string, unknown>; path: string }
> = {
[SettingScope.User]: {
settings: opts.userSettings ?? {},
path: opts.userPath ?? '/home/user/.gemini/settings.json',
},
[SettingScope.Workspace]: {
settings: opts.workspaceSettings ?? {},
path: opts.workspacePath ?? '/project/.gemini/settings.json',
},
};
const mockSettings = {
forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]),
setValue: vi.fn(),
} as unknown as LoadedSettings;
return mockSettings;
}
function createMockStrategy(overrides?: {
needsEnabling?: (
settings: LoadedSettings,
scope: LoadableSettingScope,
featureName: string,
) => boolean;
isExplicitlyDisabled?: (
settings: LoadedSettings,
scope: LoadableSettingScope,
featureName: string,
) => boolean;
}): FeatureToggleStrategy {
return {
needsEnabling: vi.fn(overrides?.needsEnabling ?? (() => false)),
enable: vi.fn(),
isExplicitlyDisabled: vi.fn(
overrides?.isExplicitlyDisabled ?? (() => false),
),
disable: vi.fn(),
};
}
describe('featureToggleUtils', () => {
describe('enableFeature', () => {
it('should return no-op when the feature is already enabled in all scopes', () => {
const settings = createMockLoadedSettings({});
const strategy = createMockStrategy({
needsEnabling: () => false,
});
const result = enableFeature(settings, 'my-feature', strategy);
expect(result.status).toBe('no-op');
expect(result.action).toBe('enable');
expect(result.featureName).toBe('my-feature');
expect(result.modifiedScopes).toHaveLength(0);
expect(result.alreadyInStateScopes).toHaveLength(2);
expect(strategy.enable).not.toHaveBeenCalled();
});
it('should enable the feature when disabled in one scope', () => {
const settings = createMockLoadedSettings({});
const strategy = createMockStrategy({
needsEnabling: (_s, scope) => scope === SettingScope.Workspace,
});
const result = enableFeature(settings, 'my-feature', strategy);
expect(result.status).toBe('success');
expect(result.action).toBe('enable');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace);
expect(result.alreadyInStateScopes).toHaveLength(1);
expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User);
expect(strategy.enable).toHaveBeenCalledTimes(1);
});
it('should enable the feature when disabled in both scopes', () => {
const settings = createMockLoadedSettings({});
const strategy = createMockStrategy({
needsEnabling: () => true,
});
const result = enableFeature(settings, 'my-feature', strategy);
expect(result.status).toBe('success');
expect(result.action).toBe('enable');
expect(result.modifiedScopes).toHaveLength(2);
expect(result.alreadyInStateScopes).toHaveLength(0);
expect(strategy.enable).toHaveBeenCalledTimes(2);
});
it('should include correct scope paths in the result', () => {
const settings = createMockLoadedSettings({
userPath: '/custom/user/path',
workspacePath: '/custom/workspace/path',
});
const strategy = createMockStrategy({
needsEnabling: () => true,
});
const result = enableFeature(settings, 'my-feature', strategy);
const paths = result.modifiedScopes.map((s) => s.path);
expect(paths).toContain('/custom/workspace/path');
expect(paths).toContain('/custom/user/path');
});
});
describe('disableFeature', () => {
it('should return no-op when the feature is already disabled in the target scope', () => {
const settings = createMockLoadedSettings({});
const strategy = createMockStrategy({
isExplicitlyDisabled: () => true,
});
const result = disableFeature(
settings,
'my-feature',
SettingScope.User,
strategy,
);
expect(result.status).toBe('no-op');
expect(result.action).toBe('disable');
expect(result.featureName).toBe('my-feature');
expect(result.modifiedScopes).toHaveLength(0);
expect(result.alreadyInStateScopes).toHaveLength(1);
expect(strategy.disable).not.toHaveBeenCalled();
});
it('should disable the feature when it is enabled', () => {
const settings = createMockLoadedSettings({});
const strategy = createMockStrategy({
isExplicitlyDisabled: () => false,
});
const result = disableFeature(
settings,
'my-feature',
SettingScope.User,
strategy,
);
expect(result.status).toBe('success');
expect(result.action).toBe('disable');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
expect(strategy.disable).toHaveBeenCalledOnce();
});
it('should return error for an invalid scope', () => {
const settings = createMockLoadedSettings({});
const strategy = createMockStrategy();
const result = disableFeature(
settings,
'my-feature',
SettingScope.Session,
strategy,
);
expect(result.status).toBe('error');
expect(result.action).toBe('disable');
expect(result.error).toContain('Invalid settings scope');
expect(strategy.disable).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,185 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
SettingScope,
isLoadableSettingScope,
type LoadableSettingScope,
type LoadedSettings,
} from '../config/settings.js';
export interface ModifiedScope {
scope: SettingScope;
path: string;
}
export type FeatureActionStatus = 'success' | 'no-op' | 'error';
export interface FeatureActionResult {
status: FeatureActionStatus;
featureName: string;
action: 'enable' | 'disable';
/** Scopes where the feature's state was actually changed. */
modifiedScopes: ModifiedScope[];
/** Scopes where the feature was already in the desired state. */
alreadyInStateScopes: ModifiedScope[];
/** Error message if status is 'error'. */
error?: string;
}
/**
* Strategy pattern to handle differences between feature types (e.g. skills vs agents).
*/
export interface FeatureToggleStrategy {
/**
* Checks if the feature needs to be enabled in the given scope.
* For skills (blacklist): returns true if in disabled list.
* For agents (whitelist): returns true if NOT explicitly enabled (false or undefined).
*/
needsEnabling(
settings: LoadedSettings,
scope: LoadableSettingScope,
featureName: string,
): boolean;
/**
* Applies the enable change to the settings object.
*/
enable(
settings: LoadedSettings,
scope: LoadableSettingScope,
featureName: string,
): void;
/**
* Checks if the feature is explicitly disabled in the given scope.
* For skills (blacklist): returns true if in disabled list.
* For agents (whitelist): returns true if explicitly set to false.
*/
isExplicitlyDisabled(
settings: LoadedSettings,
scope: LoadableSettingScope,
featureName: string,
): boolean;
/**
* Applies the disable change to the settings object.
*/
disable(
settings: LoadedSettings,
scope: LoadableSettingScope,
featureName: string,
): void;
}
/**
* Enables a feature by ensuring it is enabled in all writable scopes.
*/
export function enableFeature(
settings: LoadedSettings,
featureName: string,
strategy: FeatureToggleStrategy,
): FeatureActionResult {
const writableScopes = [SettingScope.Workspace, SettingScope.User];
const foundInDisabledScopes: ModifiedScope[] = [];
const alreadyEnabledScopes: ModifiedScope[] = [];
for (const scope of writableScopes) {
if (isLoadableSettingScope(scope)) {
const scopePath = settings.forScope(scope).path;
if (strategy.needsEnabling(settings, scope, featureName)) {
foundInDisabledScopes.push({ scope, path: scopePath });
} else {
alreadyEnabledScopes.push({ scope, path: scopePath });
}
}
}
if (foundInDisabledScopes.length === 0) {
return {
status: 'no-op',
featureName,
action: 'enable',
modifiedScopes: [],
alreadyInStateScopes: alreadyEnabledScopes,
};
}
const modifiedScopes: ModifiedScope[] = [];
for (const { scope, path } of foundInDisabledScopes) {
if (isLoadableSettingScope(scope)) {
strategy.enable(settings, scope, featureName);
modifiedScopes.push({ scope, path });
}
}
return {
status: 'success',
featureName,
action: 'enable',
modifiedScopes,
alreadyInStateScopes: alreadyEnabledScopes,
};
}
/**
* Disables a feature in the specified scope.
*/
export function disableFeature(
settings: LoadedSettings,
featureName: string,
scope: SettingScope,
strategy: FeatureToggleStrategy,
): FeatureActionResult {
if (!isLoadableSettingScope(scope)) {
return {
status: 'error',
featureName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [],
error: `Invalid settings scope: ${scope}`,
};
}
const scopePath = settings.forScope(scope).path;
if (strategy.isExplicitlyDisabled(settings, scope, featureName)) {
return {
status: 'no-op',
featureName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [{ scope, path: scopePath }],
};
}
// Check if it's already disabled in the other writable scope
const otherScope =
scope === SettingScope.Workspace
? SettingScope.User
: SettingScope.Workspace;
const alreadyDisabledInOther: ModifiedScope[] = [];
if (isLoadableSettingScope(otherScope)) {
if (strategy.isExplicitlyDisabled(settings, otherScope, featureName)) {
alreadyDisabledInOther.push({
scope: otherScope,
path: settings.forScope(otherScope).path,
});
}
}
strategy.disable(settings, scope, featureName);
return {
status: 'success',
featureName,
action: 'disable',
modifiedScopes: [{ scope, path: scopePath }],
alreadyInStateScopes: alreadyDisabledInOther,
};
}
@@ -0,0 +1,196 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import {
SettingScope,
type LoadedSettings,
type LoadableSettingScope,
} from '../config/settings.js';
import { enableSkill, disableSkill } from './skillSettings.js';
function createMockLoadedSettings(opts: {
userSettings?: Record<string, unknown>;
workspaceSettings?: Record<string, unknown>;
userPath?: string;
workspacePath?: string;
}): LoadedSettings {
const scopes: Record<
string,
{
settings: Record<string, unknown>;
originalSettings: Record<string, unknown>;
path: string;
}
> = {
[SettingScope.User]: {
settings: opts.userSettings ?? {},
originalSettings: opts.userSettings ?? {},
path: opts.userPath ?? '/home/user/.gemini/settings.json',
},
[SettingScope.Workspace]: {
settings: opts.workspaceSettings ?? {},
originalSettings: opts.workspaceSettings ?? {},
path: opts.workspacePath ?? '/project/.gemini/settings.json',
},
};
return {
forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]),
setValue: vi.fn(),
} as unknown as LoadedSettings;
}
describe('skillSettings', () => {
describe('skillStrategy (via enableSkill / disableSkill)', () => {
describe('enableSkill', () => {
it('should return no-op when the skill is not in any disabled list', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: [] } },
workspaceSettings: { skills: { disabled: [] } },
});
const result = enableSkill(settings, 'my-skill');
expect(result.status).toBe('no-op');
expect(result.action).toBe('enable');
expect(result.skillName).toBe('my-skill');
expect(result.modifiedScopes).toHaveLength(0);
expect(settings.setValue).not.toHaveBeenCalled();
});
it('should return no-op when skills.disabled is undefined', () => {
const settings = createMockLoadedSettings({
userSettings: {},
workspaceSettings: {},
});
const result = enableSkill(settings, 'my-skill');
expect(result.status).toBe('no-op');
expect(result.action).toBe('enable');
expect(result.modifiedScopes).toHaveLength(0);
});
it('should enable the skill when it is in the disabled list of one scope', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: ['my-skill'] } },
workspaceSettings: { skills: { disabled: [] } },
});
const result = enableSkill(settings, 'my-skill');
expect(result.status).toBe('success');
expect(result.action).toBe('enable');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
expect(result.alreadyInStateScopes).toHaveLength(1);
expect(result.alreadyInStateScopes[0].scope).toBe(
SettingScope.Workspace,
);
expect(settings.setValue).toHaveBeenCalledTimes(1);
});
it('should enable the skill when it is in the disabled list of both scopes', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: ['my-skill', 'other-skill'] } },
workspaceSettings: { skills: { disabled: ['my-skill'] } },
});
const result = enableSkill(settings, 'my-skill');
expect(result.status).toBe('success');
expect(result.modifiedScopes).toHaveLength(2);
expect(result.alreadyInStateScopes).toHaveLength(0);
expect(settings.setValue).toHaveBeenCalledTimes(2);
});
it('should not affect other skills in the disabled list', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: ['my-skill', 'keep-disabled'] } },
workspaceSettings: { skills: { disabled: [] } },
});
const result = enableSkill(settings, 'my-skill');
expect(result.status).toBe('success');
expect(settings.setValue).toHaveBeenCalledTimes(1);
});
});
describe('disableSkill', () => {
it('should return no-op when the skill is already in the disabled list', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: ['my-skill'] } },
});
const result = disableSkill(settings, 'my-skill', SettingScope.User);
expect(result.status).toBe('no-op');
expect(result.action).toBe('disable');
expect(result.skillName).toBe('my-skill');
expect(result.modifiedScopes).toHaveLength(0);
expect(result.alreadyInStateScopes).toHaveLength(1);
expect(settings.setValue).not.toHaveBeenCalled();
});
it('should disable the skill when it is not in the disabled list', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: [] } },
});
const result = disableSkill(settings, 'my-skill', SettingScope.User);
expect(result.status).toBe('success');
expect(result.action).toBe('disable');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
expect(settings.setValue).toHaveBeenCalledTimes(1);
});
it('should disable the skill when skills.disabled is undefined', () => {
const settings = createMockLoadedSettings({
userSettings: {},
});
const result = disableSkill(settings, 'my-skill', SettingScope.User);
expect(result.status).toBe('success');
expect(result.action).toBe('disable');
expect(result.modifiedScopes).toHaveLength(1);
expect(settings.setValue).toHaveBeenCalledTimes(1);
});
it('should return error for an invalid scope', () => {
const settings = createMockLoadedSettings({});
const result = disableSkill(settings, 'my-skill', SettingScope.Session);
expect(result.status).toBe('error');
expect(result.error).toContain('Invalid settings scope');
});
it('should disable in workspace and report user as already disabled', () => {
const settings = createMockLoadedSettings({
userSettings: { skills: { disabled: ['my-skill'] } },
workspaceSettings: { skills: { disabled: [] } },
});
const result = disableSkill(
settings,
'my-skill',
SettingScope.Workspace,
);
expect(result.status).toBe('success');
expect(result.modifiedScopes).toHaveLength(1);
expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace);
expect(result.alreadyInStateScopes).toHaveLength(1);
expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User);
});
});
});
});
+57 -113
View File
@@ -4,34 +4,58 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
SettingScope,
isLoadableSettingScope,
type LoadedSettings,
} from '../config/settings.js';
import type { SettingScope, LoadedSettings } from '../config/settings.js';
export interface ModifiedScope {
scope: SettingScope;
path: string;
}
import {
type FeatureActionResult,
type FeatureToggleStrategy,
enableFeature,
disableFeature,
} from './featureToggleUtils.js';
export type { ModifiedScope } from './featureToggleUtils.js';
export type SkillActionStatus = 'success' | 'no-op' | 'error';
/**
* Metadata representing the result of a skill settings operation.
*/
export interface SkillActionResult {
status: SkillActionStatus;
export interface SkillActionResult
extends Omit<FeatureActionResult, 'featureName'> {
skillName: string;
action: 'enable' | 'disable';
/** Scopes where the skill's state was actually changed. */
modifiedScopes: ModifiedScope[];
/** Scopes where the skill was already in the desired state. */
alreadyInStateScopes: ModifiedScope[];
/** Error message if status is 'error'. */
error?: string;
}
const skillStrategy: FeatureToggleStrategy = {
needsEnabling: (settings, scope, skillName) => {
const scopeDisabled = settings.forScope(scope).settings.skills?.disabled;
return !!scopeDisabled?.includes(skillName);
},
enable: (settings, scope, skillName) => {
const currentScopeDisabled =
settings.forScope(scope).settings.skills?.disabled ?? [];
const newDisabled = currentScopeDisabled.filter(
(name) => name !== skillName,
);
settings.setValue(scope, 'skills.disabled', newDisabled);
},
isExplicitlyDisabled: (settings, scope, skillName) => {
const currentScopeDisabled =
settings.forScope(scope).settings.skills?.disabled ?? [];
return currentScopeDisabled.includes(skillName);
},
disable: (settings, scope, skillName) => {
const currentScopeDisabled =
settings.forScope(scope).settings.skills?.disabled ?? [];
// The generic utility checks isExplicitlyDisabled before calling this,
// but just to be safe and idempotent, we check or we assume the utility did its job.
// The utility does check isExplicitlyDisabled first.
// So we can blindly add it, but since we are modifying an array, pushing is fine.
// However, if we assume purely that we must disable it:
const newDisabled = [...currentScopeDisabled, skillName];
settings.setValue(scope, 'skills.disabled', newDisabled);
},
};
/**
* Enables a skill by removing it from all writable disabled lists (User and Workspace).
*/
@@ -39,51 +63,14 @@ export function enableSkill(
settings: LoadedSettings,
skillName: string,
): SkillActionResult {
const writableScopes = [SettingScope.Workspace, SettingScope.User];
const foundInDisabledScopes: ModifiedScope[] = [];
const alreadyEnabledScopes: ModifiedScope[] = [];
for (const scope of writableScopes) {
if (isLoadableSettingScope(scope)) {
const scopePath = settings.forScope(scope).path;
const scopeDisabled = settings.forScope(scope).settings.skills?.disabled;
if (scopeDisabled?.includes(skillName)) {
foundInDisabledScopes.push({ scope, path: scopePath });
} else {
alreadyEnabledScopes.push({ scope, path: scopePath });
}
}
}
if (foundInDisabledScopes.length === 0) {
return {
status: 'no-op',
skillName,
action: 'enable',
modifiedScopes: [],
alreadyInStateScopes: alreadyEnabledScopes,
};
}
const modifiedScopes: ModifiedScope[] = [];
for (const { scope, path } of foundInDisabledScopes) {
if (isLoadableSettingScope(scope)) {
const currentScopeDisabled =
settings.forScope(scope).settings.skills?.disabled ?? [];
const newDisabled = currentScopeDisabled.filter(
(name) => name !== skillName,
);
settings.setValue(scope, 'skills.disabled', newDisabled);
modifiedScopes.push({ scope, path });
}
}
return {
status: 'success',
const { featureName, ...rest } = enableFeature(
settings,
skillName,
action: 'enable',
modifiedScopes,
alreadyInStateScopes: alreadyEnabledScopes,
skillStrategy,
);
return {
...rest,
skillName: featureName,
};
}
@@ -95,57 +82,14 @@ export function disableSkill(
skillName: string,
scope: SettingScope,
): SkillActionResult {
if (!isLoadableSettingScope(scope)) {
return {
status: 'error',
skillName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [],
error: `Invalid settings scope: ${scope}`,
};
}
const scopePath = settings.forScope(scope).path;
const currentScopeDisabled =
settings.forScope(scope).settings.skills?.disabled ?? [];
if (currentScopeDisabled.includes(skillName)) {
return {
status: 'no-op',
skillName,
action: 'disable',
modifiedScopes: [],
alreadyInStateScopes: [{ scope, path: scopePath }],
};
}
// Check if it's already disabled in the other writable scope
const otherScope =
scope === SettingScope.Workspace
? SettingScope.User
: SettingScope.Workspace;
const alreadyDisabledInOther: ModifiedScope[] = [];
if (isLoadableSettingScope(otherScope)) {
const otherScopeDisabled =
settings.forScope(otherScope).settings.skills?.disabled;
if (otherScopeDisabled?.includes(skillName)) {
alreadyDisabledInOther.push({
scope: otherScope,
path: settings.forScope(otherScope).path,
});
}
}
const newDisabled = [...currentScopeDisabled, skillName];
settings.setValue(scope, 'skills.disabled', newDisabled);
return {
status: 'success',
const { featureName, ...rest } = disableFeature(
settings,
skillName,
action: 'disable',
modifiedScopes: [{ scope, path: scopePath }],
alreadyInStateScopes: alreadyDisabledInOther,
scope,
skillStrategy,
);
return {
...rest,
skillName: featureName,
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
-12
View File
@@ -16,12 +16,6 @@ import {
DEFAULT_MAX_TIME_MINUTES,
} from './types.js';
import type { A2AAuthConfig } from './auth-provider/types.js';
import { type MCPServerConfig } from '../config/config.js';
import { type PolicySettings } from '../policy/types.js';
import {
PolicySettingsSchema,
MCPServersConfigSchema,
} from '../policy/schemas.js';
import { isValidToolName } from '../tools/tool-names.js';
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
import { getErrorMessage } from '../utils/errors.js';
@@ -44,8 +38,6 @@ interface FrontmatterLocalAgentDefinition
temperature?: number;
max_turns?: number;
timeout_mins?: number;
policy?: PolicySettings;
mcp_servers?: Record<string, MCPServerConfig>;
}
/**
@@ -119,8 +111,6 @@ const localAgentSchema = z
temperature: z.number().optional(),
max_turns: z.number().int().positive().optional(),
timeout_mins: z.number().int().positive().optional(),
policy: PolicySettingsSchema.optional(),
mcp_servers: MCPServersConfigSchema.optional(),
})
.strict();
@@ -480,8 +470,6 @@ export function markdownToAgentDefinition(
tools: markdown.tools,
}
: undefined,
policy: markdown.policy,
mcpServers: markdown.mcp_servers,
inputConfig,
metadata,
};
@@ -554,104 +554,6 @@ describe('LocalAgentExecutor', () => {
getToolSpy.mockRestore();
});
it('should support scoped policy and unique toolsets', async () => {
const toolToAllow = 'ls';
const toolToBlock = READ_FILE_TOOL_NAME;
const definition = createTestDefinition([toolToAllow, toolToBlock]);
definition.policy = {
tools: {
exclude: [toolToBlock],
},
};
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
const agentRegistry = executor['toolRegistry'];
// Tool explicitly allowed in definition but BLOCKED by policy should NOT be active
expect(agentRegistry.getTool(toolToAllow)).toBeDefined();
expect(agentRegistry.getTool(toolToBlock)).toBeUndefined();
});
it('should incorporate ADMIN policies from parent context into scoped policy engine', async () => {
const adminToolToBlock = 'admin_blocked_tool';
const userToolToBlock = 'user_blocked_tool';
// Mock parent policy engine with admin and user rules
const parentPolicyEngine = mockConfig.getPolicyEngine();
vi.spyOn(parentPolicyEngine, 'getRules').mockReturnValue([
{
toolName: adminToolToBlock,
decision: ApprovalMode.DEFAULT, // Use actual enum value if possible, or cast appropriately
priority: 5.1, // Admin tier
source: 'Admin Policy',
},
{
toolName: userToolToBlock,
decision: ApprovalMode.DEFAULT,
priority: 4.1, // User tier
source: 'User Policy',
},
] as unknown as PolicyRule[]);
vi.spyOn(parentPolicyEngine, 'getCheckers').mockReturnValue([]);
// Create a subagent definition that has its own policy
const definition = createTestDefinition([
adminToolToBlock,
userToolToBlock,
LS_TOOL_NAME,
]);
definition.policy = {
tools: {
allowed: [userToolToBlock], // Subagent tries to allow what user blocked
},
};
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
const scopedPolicyEngine = executor['runtimeContext'].getPolicyEngine();
const rules = scopedPolicyEngine.getRules();
// Should have incorporated the ADMIN rule
expect(rules).toContainEqual(
expect.objectContaining({
toolName: adminToolToBlock,
priority: 5.1,
}),
);
// Should NOT have incorporated the USER rule
expect(rules).not.toContainEqual(
expect.objectContaining({
toolName: userToolToBlock,
priority: 4.1,
}),
);
// Verify effective decisions
const adminCheck = await scopedPolicyEngine.check(
{ name: adminToolToBlock, args: {} },
undefined,
);
expect(adminCheck.decision).toBe('deny');
const userCheck = await scopedPolicyEngine.check(
{ name: userToolToBlock, args: {} },
undefined,
);
// User block was NOT inherited, and subagent policy allowed it.
expect(userCheck.decision).toBe('allow');
});
});
describe('run (Execution Loop and Logic)', () => {
@@ -2556,59 +2458,4 @@ describe('LocalAgentExecutor', () => {
expect(mockSetHistory).toHaveBeenCalledWith(compressedHistory);
});
});
describe('Isolated Tool Discovery and Shadowing', () => {
it('should allow subagent-specific MCP tools to shadow global tools in the isolated registry', async () => {
const toolName = 'shadowed_tool';
const globalTool = new MockTool({ name: toolName, description: 'Global Version' });
parentToolRegistry.registerTool(globalTool);
const subagentMcpTool = {
tool: vi.fn(),
callTool: vi.fn(),
} as unknown as CallableTool;
const mcpTool = new DiscoveredMCPTool(
subagentMcpTool,
'private-server',
toolName,
'Subagent Version',
{},
mockConfig.getMessageBus(),
);
// Mock McpClientManager to register our shadowing tool
const definition = createTestDefinition();
definition.mcpServers = {
'private-server': { command: 'node', args: ['server.js'] }
};
// We need to mock the McpClientManager's behavior
const McpClientManagerModule = await import('../tools/mcp-client-manager.js');
vi.spyOn(McpClientManagerModule.McpClientManager.prototype, 'maybeDiscoverMcpServer')
.mockImplementation(async function(this: unknown) {
// Manually register the tool into the registry provided to the manager
const manager = this as unknown as { toolRegistry: ToolRegistry };
manager.toolRegistry.registerTool(mcpTool);
});
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
const agentRegistry = executor['toolRegistry'];
// The tool in the agent's registry should be the subagent's version
const tool = agentRegistry.getTool(toolName);
expect(tool).toBeDefined();
expect(tool?.description).toContain('Subagent Version');
expect(tool).not.toBe(globalTool);
// The global registry should remain unchanged
expect(parentToolRegistry.getTool(toolName)).toBe(globalTool);
expect(parentToolRegistry.getTool(toolName)?.description).toContain('Global Version');
});
});
});
+3 -78
View File
@@ -16,13 +16,7 @@ import type {
Schema,
} from '@google/genai';
import { ToolRegistry } from '../tools/tool-registry.js';
import {
DiscoveredMCPTool,
MCP_QUALIFIED_NAME_SEPARATOR,
} from '../tools/mcp-tool.js';
import { PolicyEngine } from '../policy/policy-engine.js';
import { createPolicyEngineConfig } from '../policy/config.js';
import { McpClientManager } from '../tools/mcp-client-manager.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { CompressionStatus } from '../core/turn.js';
import { type ToolCallRequestInfo } from '../scheduler/types.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
@@ -121,81 +115,12 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
runtimeContext: Config,
onActivity?: ActivityCallback,
): Promise<LocalAgentExecutor<TOutput>> {
const parentToolRegistry = runtimeContext.getToolRegistry();
// Create an isolated tool registry for this agent instance.
const agentToolRegistry = new ToolRegistry(
runtimeContext,
runtimeContext.getMessageBus(),
);
// Create a scoped configuration if subagent has private policies or MCP servers.
let agentContext = runtimeContext;
if (definition.policy || definition.mcpServers) {
const parentPolicyEngine = runtimeContext.getPolicyEngine();
// Admin policies have priority >= ADMIN_POLICY_TIER (5).
const adminRules = parentPolicyEngine
.getRules()
.filter((r) => (r.priority ?? 0) >= 5);
const adminCheckers = parentPolicyEngine
.getCheckers()
.filter((c) => (c.priority ?? 0) >= 5);
const policyConfig = definition.policy
? await createPolicyEngineConfig(
definition.policy,
runtimeContext.getApprovalMode(),
undefined, // defaultPoliciesDir
adminRules,
adminCheckers,
)
: {
rules: adminRules,
checkers: adminCheckers,
approvalMode: runtimeContext.getApprovalMode(),
};
const scopedPolicyEngine = new PolicyEngine(
policyConfig,
runtimeContext.getCheckerRunner(),
);
// Populate scoped registry with parent's tools (including built-ins).
// If a subagent has private MCP servers, tools from those servers will
// be registered during discovery and will overwrite any global tools
// with the same name in this isolated registry.
for (const tool of parentToolRegistry.getAllKnownTools()) {
agentToolRegistry.registerTool(tool);
}
let scopedMcpManager: McpClientManager | undefined;
if (definition.mcpServers) {
scopedMcpManager = new McpClientManager(
runtimeContext.getClientVersion(),
agentToolRegistry,
runtimeContext, // Use parent context for some services, but we will scope it soon
);
}
agentContext = runtimeContext.createScopedConfig({
policyEngine: scopedPolicyEngine,
toolRegistry: agentToolRegistry,
mcpClientManager: scopedMcpManager,
});
// Update registry and mcp manager to use the scoped context
agentToolRegistry.setConfig(agentContext);
if (scopedMcpManager) {
scopedMcpManager.setConfig(agentContext);
// Discover and register subagent-specific MCP tools
for (const [name, config] of Object.entries(definition.mcpServers!)) {
await scopedMcpManager.maybeDiscoverMcpServer(name, config);
}
}
}
const parentToolRegistry = runtimeContext.getToolRegistry();
const allAgentNames = new Set(
runtimeContext.getAgentRegistry().getAllAgentNames(),
);
@@ -257,7 +182,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
return new LocalAgentExecutor(
definition,
agentContext,
runtimeContext,
agentToolRegistry,
parentPromptId,
parentCallId,
-12
View File
@@ -14,8 +14,6 @@ import { type z } from 'zod';
import type { ModelConfig } from '../services/modelConfigService.js';
import type { AnySchema } from 'ajv';
import type { A2AAuthConfig } from './auth-provider/types.js';
import type { PolicySettings } from '../policy/types.js';
import type { MCPServerConfig } from '../config/config.js';
/**
* Describes the possible termination modes for an agent.
@@ -132,16 +130,6 @@ export interface LocalAgentDefinition<
// Optional configs
toolConfig?: ToolConfig;
/**
* Scoped policy settings for this agent.
*/
policy?: PolicySettings;
/**
* MCP servers private to this agent.
*/
mcpServers?: Record<string, MCPServerConfig>;
/**
* An optional function to process the raw output from the agent's final tool
* call into a string format.
+2 -1
View File
@@ -95,7 +95,8 @@ const mockConfig = {
getNoBrowser: () => false,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => false,
getExperimentalZedIntegration: () => false,
getAcpMode: () => false,
isInteractive: () => true,
} as unknown as Config;
// Mock fetch globally
+2 -2
View File
@@ -271,8 +271,8 @@ async function initOauthClient(
await triggerPostAuthCallbacks(client.credentials);
} else {
// In Zed integration, we skip the interactive consent and directly open the browser
if (!config.getExperimentalZedIntegration()) {
// In ACP mode, we skip the interactive consent and directly open the browser
if (!config.getAcpMode()) {
const userConsent = await getConsentForOauth('');
if (!userConsent) {
throw new FatalCancellationError('Authentication cancelled by user.');
+2
View File
@@ -500,6 +500,8 @@ describe('Server Config (config.ts)', () => {
config,
authType,
undefined,
undefined,
undefined,
);
// Verify that contentGeneratorConfig is updated
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
+20 -73
View File
@@ -424,12 +424,6 @@ export class MCPServerConfig {
readonly targetAudience?: string,
/* targetServiceAccount format: <service-account-name>@<project-num>.iam.gserviceaccount.com */
readonly targetServiceAccount?: string,
/**
* Visibility of the MCP server.
* 'public' (default): available to all agents.
* 'private': hidden from the primary agent, available only to specific subagents.
*/
readonly visibility?: 'public' | 'private',
) {}
}
@@ -509,7 +503,7 @@ export interface ConfigParameters {
model: string;
disableLoopDetection?: boolean;
maxSessionTurns?: number;
experimentalZedIntegration?: boolean;
acpMode?: boolean;
listSessions?: boolean;
deleteSession?: string;
listExtensions?: boolean;
@@ -594,9 +588,7 @@ export interface ConfigParameters {
export class Config implements McpContext {
private toolRegistry!: ToolRegistry;
private _scopedToolRegistry?: ToolRegistry;
private mcpClientManager?: McpClientManager;
private _scopedMcpClientManager?: McpClientManager;
private allowedMcpServers: string[];
private blockedMcpServers: string[];
private allowedEnvironmentVariables: string[];
@@ -707,7 +699,7 @@ export class Config implements McpContext {
private readonly summarizeToolOutput:
| Record<string, SummarizeToolOutputSettings>
| undefined;
private readonly experimentalZedIntegration: boolean = false;
private readonly acpMode: boolean = false;
private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly includeDirectoryTree: boolean = true;
private readonly importFormat: 'tree' | 'flat';
@@ -736,8 +728,6 @@ export class Config implements McpContext {
private readonly useWriteTodos: boolean;
private readonly messageBus: MessageBus;
private readonly policyEngine: PolicyEngine;
private readonly checkerRunner: CheckerRunner;
private _scopedPolicyEngine?: PolicyEngine;
private policyUpdateConfirmationRequest:
| PolicyUpdateConfirmationRequest
| undefined;
@@ -904,8 +894,7 @@ export class Config implements McpContext {
DEFAULT_PROTECT_LATEST_TURN,
};
this.maxSessionTurns = params.maxSessionTurns ?? -1;
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
this.acpMode = params.acpMode ?? false;
this.listSessions = params.listSessions ?? false;
this.deleteSession = params.deleteSession;
this.listExtensions = params.listExtensions ?? false;
@@ -974,7 +963,6 @@ export class Config implements McpContext {
checkersPath,
timeout: 30000, // 30 seconds to allow for LLM-based checkers
});
this.checkerRunner = checkerRunner;
this.policyUpdateConfirmationRequest =
params.policyUpdateConfirmationRequest;
@@ -1159,7 +1147,7 @@ export class Config implements McpContext {
}
});
if (!this.interactive || this.experimentalZedIntegration) {
if (!this.interactive || this.acpMode) {
await this.mcpInitializationPromise;
}
@@ -1202,7 +1190,12 @@ export class Config implements McpContext {
return this.contentGenerator;
}
async refreshAuth(authMethod: AuthType, apiKey?: string) {
async refreshAuth(
authMethod: AuthType,
apiKey?: string,
baseUrl?: string,
customHeaders?: Record<string, string>,
) {
// Reset availability service when switching auth
this.modelAvailabilityService.reset();
@@ -1229,6 +1222,8 @@ export class Config implements McpContext {
this,
authMethod,
apiKey,
baseUrl,
customHeaders,
);
this.contentGenerator = await createContentGenerator(
newContentGeneratorConfig,
@@ -1336,18 +1331,10 @@ export class Config implements McpContext {
return this.sessionId;
}
getClientVersion(): string {
return this.clientVersion;
}
setSessionId(sessionId: string): void {
this.sessionId = sessionId;
}
getCheckerRunner(): CheckerRunner | undefined {
return this.checkerRunner;
}
setTerminalBackground(terminalBackground: string | undefined): void {
this.terminalBackground = terminalBackground;
}
@@ -1597,15 +1584,7 @@ export class Config implements McpContext {
}
getToolRegistry(): ToolRegistry {
return this._scopedToolRegistry ?? this.toolRegistry;
}
getMcpClientManager(): McpClientManager | undefined {
return this._scopedMcpClientManager ?? this.mcpClientManager;
}
setMcpClientManager(manager: McpClientManager): void {
this.mcpClientManager = manager;
return this.toolRegistry;
}
getPromptRegistry(): PromptRegistry {
@@ -1761,7 +1740,7 @@ export class Config implements McpContext {
}
}
const policyExclusions = this.getPolicyEngine().getExcludedTools(
const policyExclusions = this.policyEngine.getExcludedTools(
toolMetadata,
allToolNames,
);
@@ -1805,6 +1784,9 @@ export class Config implements McpContext {
return this.extensionsEnabled;
}
getMcpClientManager(): McpClientManager | undefined {
return this.mcpClientManager;
}
setUserInteractedWithMcp(): void {
this.mcpClientManager?.setUserInteractedWithMcp();
@@ -2229,8 +2211,8 @@ export class Config implements McpContext {
return this.usageStatisticsEnabled;
}
getExperimentalZedIntegration(): boolean {
return this.experimentalZedIntegration;
getAcpMode(): boolean {
return this.acpMode;
}
async waitForMcpInit(): Promise<void> {
@@ -2693,7 +2675,7 @@ export class Config implements McpContext {
}
getPolicyEngine(): PolicyEngine {
return this._scopedPolicyEngine ?? this.policyEngine;
return this.policyEngine;
}
getEnableHooks(): boolean {
@@ -2984,41 +2966,6 @@ export class Config implements McpContext {
}
};
/**
* Creates a scoped copy of this configuration with overrides for policy and tools.
* Scoped configs are used by subagents to maintain isolation from the primary agent.
* This uses prototype-based shadowing for overrides while maintaining access to global state.
*/
createScopedConfig(overrides: {
policyEngine?: PolicyEngine;
toolRegistry?: ToolRegistry;
mcpClientManager?: McpClientManager;
}): Config {
const scoped = Object.create(this) as unknown as Config;
// Define properties explicitly to ensure they shadow the base class properties
Object.defineProperties(scoped, {
_scopedPolicyEngine: {
value: overrides.policyEngine,
writable: true,
configurable: true,
enumerable: true,
},
_scopedToolRegistry: {
value: overrides.toolRegistry,
writable: true,
configurable: true,
enumerable: true,
},
_scopedMcpClientManager: {
value: overrides.mcpClientManager,
writable: true,
configurable: true,
enumerable: true,
},
});
return scoped;
}
/**
* Disposes of resources and removes event listeners.
*/
+20 -2
View File
@@ -59,6 +59,7 @@ export enum AuthType {
USE_VERTEX_AI = 'vertex-ai',
LEGACY_CLOUD_SHELL = 'cloud-shell',
COMPUTE_ADC = 'compute-default-credentials',
GATEWAY = 'gateway',
}
/**
@@ -93,12 +94,16 @@ export type ContentGeneratorConfig = {
vertexai?: boolean;
authType?: AuthType;
proxy?: string;
baseUrl?: string;
customHeaders?: Record<string, string>;
};
export async function createContentGeneratorConfig(
config: Config,
authType: AuthType | undefined,
apiKey?: string,
baseUrl?: string,
customHeaders?: Record<string, string>,
): Promise<ContentGeneratorConfig> {
const geminiApiKey =
apiKey ||
@@ -115,6 +120,8 @@ export async function createContentGeneratorConfig(
const contentGeneratorConfig: ContentGeneratorConfig = {
authType,
proxy: config?.getProxy(),
baseUrl,
customHeaders,
};
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now
@@ -203,9 +210,13 @@ export async function createContentGenerator(
if (
config.authType === AuthType.USE_GEMINI ||
config.authType === AuthType.USE_VERTEX_AI
config.authType === AuthType.USE_VERTEX_AI ||
config.authType === AuthType.GATEWAY
) {
let headers: Record<string, string> = { ...baseHeaders };
if (config.customHeaders) {
headers = { ...headers, ...config.customHeaders };
}
if (gcConfig?.getUsageStatisticsEnabled()) {
const installationManager = new InstallationManager();
const installationId = installationManager.getInstallationId();
@@ -214,7 +225,14 @@ export async function createContentGenerator(
'x-gemini-api-privileged-user-id': `${installationId}`,
};
}
const httpOptions = { headers };
const httpOptions: {
baseUrl?: string;
headers: Record<string, string>;
} = { headers };
if (config.baseUrl) {
httpOptions.baseUrl = config.baseUrl;
}
const googleGenAI = new GoogleGenAI({
apiKey: config.apiKey === '' ? undefined : config.apiKey,
+42 -22
View File
@@ -20,7 +20,10 @@ import { ToolErrorType } from '../tools/tool-error.js';
import { ToolCallEvent } from '../telemetry/types.js';
import { runInDevTraceSpan } from '../telemetry/trace.js';
import { ToolModificationHandler } from '../scheduler/tool-modifier.js';
import { getToolSuggestion } from '../utils/tool-utils.js';
import {
getToolSuggestion,
isToolCallResponseInfo,
} from '../utils/tool-utils.js';
import type { ToolConfirmationRequest } from '../confirmation-bus/types.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
@@ -225,32 +228,36 @@ export class CoreToolScheduler {
const durationMs = existingStartTime
? Date.now() - existingStartTime
: undefined;
return {
request: currentCall.request,
tool: toolInstance,
invocation,
status: CoreToolCallStatus.Success,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
response: auxiliaryData as ToolCallResponseInfo,
durationMs,
outcome,
approvalMode,
} as SuccessfulToolCall;
if (isToolCallResponseInfo(auxiliaryData)) {
return {
request: currentCall.request,
tool: toolInstance,
invocation,
status: CoreToolCallStatus.Success,
response: auxiliaryData,
durationMs,
outcome,
approvalMode,
} as SuccessfulToolCall;
}
throw new Error('Invalid response data for tool success');
}
case CoreToolCallStatus.Error: {
const durationMs = existingStartTime
? Date.now() - existingStartTime
: undefined;
return {
request: currentCall.request,
status: CoreToolCallStatus.Error,
tool: toolInstance,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
response: auxiliaryData as ToolCallResponseInfo,
durationMs,
outcome,
approvalMode,
} as ErroredToolCall;
if (isToolCallResponseInfo(auxiliaryData)) {
return {
request: currentCall.request,
status: CoreToolCallStatus.Error,
tool: toolInstance,
response: auxiliaryData,
durationMs,
outcome,
approvalMode,
} as ErroredToolCall;
}
throw new Error('Invalid response data for tool error');
}
case CoreToolCallStatus.AwaitingApproval:
return {
@@ -280,6 +287,19 @@ export class CoreToolScheduler {
? Date.now() - existingStartTime
: undefined;
if (isToolCallResponseInfo(auxiliaryData)) {
return {
request: currentCall.request,
tool: toolInstance,
invocation,
status: CoreToolCallStatus.Cancelled,
response: auxiliaryData,
durationMs,
outcome,
approvalMode,
} as CancelledToolCall;
}
// Preserve diff for cancelled edit operations
let resultDisplay: ToolResultDisplay | undefined = undefined;
if (currentCall.status === CoreToolCallStatus.AwaitingApproval) {
+2 -4
View File
@@ -250,8 +250,6 @@ export async function createPolicyEngineConfig(
settings: PolicySettings,
approvalMode: ApprovalMode,
defaultPoliciesDir?: string,
baseRules: PolicyRule[] = [],
baseCheckers: SafetyCheckerRule[] = [],
): Promise<PolicyEngineConfig> {
const policyDirs = getPolicyDirectories(
defaultPoliciesDir,
@@ -303,8 +301,8 @@ export async function createPolicyEngineConfig(
}
}
const rules: PolicyRule[] = [...baseRules, ...tomlRules];
const checkers = [...baseCheckers, ...tomlCheckers];
const rules: PolicyRule[] = [...tomlRules];
const checkers = [...tomlCheckers];
// Priority system for policy rules:
@@ -51,3 +51,8 @@ priority = 50
toolName = "google_web_search"
decision = "allow"
priority = 50
[[rule]]
toolName = ["codebase_investigator", "cli_help"]
decision = "allow"
priority = 50
-66
View File
@@ -1,66 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { z } from 'zod';
/**
* Zod schema for MCPServerConfig.
*/
export const MCPServerConfigSchema = z.object({
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
trust: z.boolean().optional(),
alwaysAllowTools: z.array(z.string()).optional(),
includeTools: z.array(z.string()).optional(),
excludeTools: z.array(z.string()).optional(),
targetAudience: z.string().optional(),
targetServiceAccount: z.string().optional(),
visibility: z.enum(['public', 'private']).optional(),
});
/**
* Zod schema for PolicySettings.
*/
export const PolicySettingsSchema = z.preprocess(
(val) => {
if (typeof val === 'object' && val !== null) {
const v = val as Record<string, unknown>;
// Map snake_case to camelCase
if (v.policy_paths && !v.policyPaths) {
v.policyPaths = v.policy_paths;
}
if (v.workspace_policies_dir && !v.workspacePoliciesDir) {
v.workspacePoliciesDir = v.workspace_policies_dir;
}
if (v.mcp_servers && !v.mcpServers) {
v.mcpServers = v.mcp_servers;
}
}
return val;
},
z.object({
mcp: z
.object({
excluded: z.array(z.string()).optional(),
allowed: z.array(z.string()).optional(),
})
.optional(),
tools: z
.object({
exclude: z.array(z.string()).optional(),
allowed: z.array(z.string()).optional(),
})
.optional(),
mcpServers: z
.record(z.object({ trust: z.boolean().optional() }))
.optional(),
policyPaths: z.array(z.string()).optional(),
workspacePoliciesDir: z.string().optional(),
}),
);
export const MCPServersConfigSchema = z.record(MCPServerConfigSchema);
@@ -946,7 +946,7 @@ describe('Scheduler (Orchestrator)', () => {
expect(mockStateManager.updateStatus).toHaveBeenCalledWith(
'call-1',
CoreToolCallStatus.Cancelled,
'Operation cancelled',
{ callId: 'call-1', responseParts: [] },
);
});
+1 -1
View File
@@ -741,7 +741,7 @@ export class Scheduler {
this.state.updateStatus(
callId,
CoreToolCallStatus.Cancelled,
'Operation cancelled',
result.response,
);
} else {
this.state.updateStatus(
+27 -15
View File
@@ -30,6 +30,7 @@ import {
MessageBusType,
type SerializableConfirmationDetails,
} from '../confirmation-bus/types.js';
import { isToolCallResponseInfo } from '../utils/tool-utils.js';
/**
* Handler for terminal tool calls.
@@ -127,7 +128,7 @@ export class SchedulerStateManager {
updateStatus(
callId: string,
status: CoreToolCallStatus.Cancelled,
data: string,
data: string | ToolCallResponseInfo,
): void;
updateStatus(
callId: string,
@@ -264,7 +265,7 @@ export class SchedulerStateManager {
): ToolCall {
switch (newStatus) {
case CoreToolCallStatus.Success: {
if (!this.isToolCallResponseInfo(auxiliaryData)) {
if (!isToolCallResponseInfo(auxiliaryData)) {
throw new Error(
`Invalid data for 'success' transition (callId: ${call.request.callId})`,
);
@@ -272,7 +273,7 @@ export class SchedulerStateManager {
return this.toSuccess(call, auxiliaryData);
}
case CoreToolCallStatus.Error: {
if (!this.isToolCallResponseInfo(auxiliaryData)) {
if (!isToolCallResponseInfo(auxiliaryData)) {
throw new Error(
`Invalid data for 'error' transition (callId: ${call.request.callId})`,
);
@@ -290,9 +291,12 @@ export class SchedulerStateManager {
case CoreToolCallStatus.Scheduled:
return this.toScheduled(call);
case CoreToolCallStatus.Cancelled: {
if (typeof auxiliaryData !== 'string') {
if (
typeof auxiliaryData !== 'string' &&
!isToolCallResponseInfo(auxiliaryData)
) {
throw new Error(
`Invalid reason (string) for 'cancelled' transition (callId: ${call.request.callId})`,
`Invalid reason (string) or response for 'cancelled' transition (callId: ${call.request.callId})`,
);
}
return this.toCancelled(call, auxiliaryData);
@@ -317,15 +321,6 @@ export class SchedulerStateManager {
}
}
private isToolCallResponseInfo(data: unknown): data is ToolCallResponseInfo {
return (
typeof data === 'object' &&
data !== null &&
'callId' in data &&
'responseParts' in data
);
}
private isExecutingToolCallPatch(
data: unknown,
): data is Partial<ExecutingToolCall> {
@@ -451,7 +446,10 @@ export class SchedulerStateManager {
};
}
private toCancelled(call: ToolCall, reason: string): CancelledToolCall {
private toCancelled(
call: ToolCall,
reason: string | ToolCallResponseInfo,
): CancelledToolCall {
this.validateHasToolAndInvocation(call, CoreToolCallStatus.Cancelled);
const startTime = 'startTime' in call ? call.startTime : undefined;
@@ -478,6 +476,20 @@ export class SchedulerStateManager {
}
}
if (isToolCallResponseInfo(reason)) {
return {
request: call.request,
tool: call.tool,
invocation: call.invocation,
status: CoreToolCallStatus.Cancelled,
response: reason,
durationMs: startTime ? Date.now() - startTime : undefined,
outcome: call.outcome,
schedulerId: call.schedulerId,
approvalMode: call.approvalMode,
};
}
const errorMessage = `[Operation Cancelled] Reason: ${reason}`;
return {
request: call.request,
@@ -534,4 +534,113 @@ describe('ToolExecutor', () => {
}),
);
});
it('should return cancelled result with partial output when signal is aborted', async () => {
const mockTool = new MockTool({
name: 'slowTool',
});
const invocation = mockTool.build({});
const partialOutput = 'Some partial output before cancellation';
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation(
async () => ({
llmContent: partialOutput,
returnDisplay: `[Cancelled] ${partialOutput}`,
}),
);
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-cancel-partial',
name: 'slowTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-cancel',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
const controller = new AbortController();
controller.abort();
const result = await executor.execute({
call: scheduledCall,
signal: controller.signal,
onUpdateToolCall: vi.fn(),
});
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
if (result.status === CoreToolCallStatus.Cancelled) {
const response = result.response.responseParts[0]?.functionResponse
?.response as Record<string, unknown>;
expect(response).toEqual({
error: '[Operation Cancelled] User cancelled tool execution.',
output: partialOutput,
});
expect(result.response.resultDisplay).toBe(
`[Cancelled] ${partialOutput}`,
);
}
});
it('should truncate large shell output even on cancellation', async () => {
// 1. Setup Config for Truncation
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
const invocation = mockTool.build({});
const longOutput = 'This is a very long output that should be truncated.';
// 2. Mock execution returning long content
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: longOutput,
returnDisplay: longOutput,
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-trunc-cancel',
name: SHELL_TOOL_NAME,
args: { command: 'echo long' },
isClientInitiated: false,
prompt_id: 'prompt-trunc-cancel',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
// 3. Abort immediately
const controller = new AbortController();
controller.abort();
// 4. Execute
const result = await executor.execute({
call: scheduledCall,
signal: controller.signal,
onUpdateToolCall: vi.fn(),
});
// 5. Verify Truncation Logic was applied in cancelled path
expect(fileUtils.saveTruncatedToolOutput).toHaveBeenCalledWith(
longOutput,
SHELL_TOOL_NAME,
'call-trunc-cancel',
expect.any(String),
'test-session-id',
);
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
if (result.status === CoreToolCallStatus.Cancelled) {
const response = result.response.responseParts[0]?.functionResponse
?.response as Record<string, unknown>;
expect(response['output']).toBe('TruncatedContent...');
expect(result.response.outputFile).toBe('/tmp/truncated_output.txt');
}
});
});
+109 -56
View File
@@ -9,7 +9,6 @@ import type {
ToolCallResponseInfo,
ToolResult,
Config,
ToolResultDisplay,
ToolLiveOutput,
} from '../index.js';
import {
@@ -19,8 +18,8 @@ import {
runInDevTraceSpan,
} from '../index.js';
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { ShellToolInvocation } from '../tools/shell.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { executeToolWithHooks } from '../core/coreToolHookTriggers.js';
import {
saveTruncatedToolOutput,
@@ -36,6 +35,7 @@ import type {
CancelledToolCall,
} from './types.js';
import { CoreToolCallStatus } from './types.js';
import type { PartListUnion, Part } from '@google/genai';
import {
GeminiCliOperation,
GEN_AI_TOOL_CALL_ID,
@@ -132,10 +132,10 @@ export class ToolExecutor {
const toolResult: ToolResult = await promise;
if (signal.aborted) {
completedToolCall = this.createCancelledResult(
completedToolCall = await this.createCancelledResult(
call,
'User cancelled tool execution.',
toolResult.returnDisplay,
toolResult,
);
} else if (toolResult.error === undefined) {
completedToolCall = await this.createSuccessResult(
@@ -163,7 +163,7 @@ export class ToolExecutor {
executionError.message.includes('Operation cancelled by user'));
if (signal.aborted || isAbortError) {
completedToolCall = this.createCancelledResult(
completedToolCall = await this.createCancelledResult(
call,
'User cancelled tool execution.',
);
@@ -186,56 +186,13 @@ export class ToolExecutor {
);
}
private createCancelledResult(
private async truncateOutputIfNeeded(
call: ToolCall,
reason: string,
resultDisplay?: ToolResultDisplay,
): CancelledToolCall {
const errorMessage = `[Operation Cancelled] ${reason}`;
const startTime = 'startTime' in call ? call.startTime : undefined;
if (!('tool' in call) || !('invocation' in call)) {
// This should effectively never happen in execution phase, but we handle
// it safely
throw new Error('Cancelled tool call missing tool/invocation references');
}
return {
status: CoreToolCallStatus.Cancelled,
request: call.request,
response: {
callId: call.request.callId,
responseParts: [
{
functionResponse: {
id: call.request.callId,
name: call.request.name,
response: { error: errorMessage },
},
},
],
resultDisplay,
error: undefined,
errorType: undefined,
contentLength: errorMessage.length,
},
tool: call.tool,
invocation: call.invocation,
durationMs: startTime ? Date.now() - startTime : undefined,
startTime,
endTime: Date.now(),
outcome: call.outcome,
};
}
private async createSuccessResult(
call: ToolCall,
toolResult: ToolResult,
): Promise<SuccessfulToolCall> {
let content = toolResult.llmContent;
let outputFile: string | undefined;
const toolName = call.request.originalRequestName || call.request.name;
content: PartListUnion,
): Promise<{ truncatedContent: PartListUnion; outputFile?: string }> {
const toolName = call.request.name;
const callId = call.request.callId;
let outputFile: string | undefined;
if (typeof content === 'string' && toolName === SHELL_TOOL_NAME) {
const threshold = this.config.getTruncateToolOutputThreshold();
@@ -250,17 +207,23 @@ export class ToolExecutor {
this.config.getSessionId(),
);
outputFile = savedPath;
content = formatTruncatedToolOutput(content, outputFile, threshold);
const truncatedContent = formatTruncatedToolOutput(
content,
outputFile,
threshold,
);
logToolOutputTruncated(
this.config,
new ToolOutputTruncatedEvent(call.request.prompt_id, {
toolName,
originalContentLength,
truncatedContentLength: content.length,
truncatedContentLength: truncatedContent.length,
threshold,
}),
);
return { truncatedContent, outputFile };
}
} else if (
Array.isArray(content) &&
@@ -288,7 +251,12 @@ export class ToolExecutor {
outputFile,
threshold,
);
content[0] = { ...firstPart, text: truncatedText };
// We need to return a NEW array to avoid mutating the original toolResult if it matters,
// though here we are creating the response so it's probably fine to mutate or return new.
const truncatedContent: Part[] = [
{ ...firstPart, text: truncatedText },
];
logToolOutputTruncated(
this.config,
@@ -299,10 +267,95 @@ export class ToolExecutor {
threshold,
}),
);
return { truncatedContent, outputFile };
}
}
}
return { truncatedContent: content, outputFile };
}
private async createCancelledResult(
call: ToolCall,
reason: string,
toolResult?: ToolResult,
): Promise<CancelledToolCall> {
const errorMessage = `[Operation Cancelled] ${reason}`;
const startTime = 'startTime' in call ? call.startTime : undefined;
if (!('tool' in call) || !('invocation' in call)) {
// This should effectively never happen in execution phase, but we handle
// it safely
throw new Error('Cancelled tool call missing tool/invocation references');
}
let responseParts: Part[] = [];
let outputFile: string | undefined;
if (toolResult?.llmContent) {
// Attempt to truncate and save output if we have content, even in cancellation case
// This is to handle cases where the tool may have produced output before cancellation
const { truncatedContent: output, outputFile: truncatedOutputFile } =
await this.truncateOutputIfNeeded(call, toolResult?.llmContent);
outputFile = truncatedOutputFile;
responseParts = convertToFunctionResponse(
call.request.name,
call.request.callId,
output,
this.config.getActiveModel(),
);
// Inject the cancellation error into the response object
const mainPart = responseParts[0];
if (mainPart?.functionResponse?.response) {
const respObj = mainPart.functionResponse.response;
respObj['error'] = errorMessage;
}
} else {
responseParts = [
{
functionResponse: {
id: call.request.callId,
name: call.request.name,
response: { error: errorMessage },
},
},
];
}
return {
status: CoreToolCallStatus.Cancelled,
request: call.request,
response: {
callId: call.request.callId,
responseParts,
resultDisplay: toolResult?.returnDisplay,
error: undefined,
errorType: undefined,
outputFile,
contentLength: JSON.stringify(responseParts).length,
},
tool: call.tool,
invocation: call.invocation,
durationMs: startTime ? Date.now() - startTime : undefined,
startTime,
endTime: Date.now(),
outcome: call.outcome,
};
}
private async createSuccessResult(
call: ToolCall,
toolResult: ToolResult,
): Promise<SuccessfulToolCall> {
const { truncatedContent: content, outputFile } =
await this.truncateOutputIfNeeded(call, toolResult.llmContent);
const toolName = call.request.originalRequestName || call.request.name;
const callId = call.request.callId;
const response = convertToFunctionResponse(
toolName,
callId,
@@ -76,14 +76,6 @@ export class McpClientManager {
this.eventEmitter = eventEmitter;
}
/**
* Updates the configuration used by the MCP client manager.
* This is used when creating scoped managers for subagents.
*/
setConfig(config: Config): void {
this.cliConfig = config;
}
setUserInteractedWithMcp() {
this.userInteractedWithMcp = true;
}
+80 -1
View File
@@ -54,7 +54,7 @@ describe('generateValidName', () => {
it('should truncate long names', () => {
expect(generateValidName('x'.repeat(80))).toBe(
'xxxxxxxxxxxxxxxxxxxxxxxxxxxx___xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
);
});
@@ -933,3 +933,82 @@ describe('DiscoveredMCPTool', () => {
});
});
});
describe('MCP Tool Naming Regression Fixes', () => {
describe('generateValidName', () => {
it('should replace spaces with underscores', () => {
expect(generateValidName('My Tool')).toBe('My_Tool');
});
it('should allow colons', () => {
expect(generateValidName('namespace:tool')).toBe('namespace:tool');
});
it('should ensure name starts with a letter or underscore', () => {
expect(generateValidName('123-tool')).toBe('_123-tool');
expect(generateValidName('-tool')).toBe('_-tool');
expect(generateValidName('.tool')).toBe('_.tool');
});
it('should handle very long names by truncating in the middle', () => {
const longName = 'a'.repeat(40) + '__' + 'b'.repeat(40);
const result = generateValidName(longName);
expect(result.length).toBeLessThanOrEqual(63);
expect(result).toMatch(/^a{30}\.\.\.b{30}$/);
});
it('should handle very long names starting with a digit', () => {
const longName = '1' + 'a'.repeat(80);
const result = generateValidName(longName);
expect(result.length).toBeLessThanOrEqual(63);
expect(result.startsWith('_1')).toBe(true);
});
});
describe('DiscoveredMCPTool qualified names', () => {
it('should generate a valid qualified name even with spaces in server name', () => {
const tool = new DiscoveredMCPTool(
{} as any,
'My Server',
'my-tool',
'desc',
{},
{} as any,
);
const qn = tool.getFullyQualifiedName();
expect(qn).toBe('My_Server__my-tool');
});
it('should handle long server and tool names in qualified name', () => {
const serverName = 'a'.repeat(40);
const toolName = 'b'.repeat(40);
const tool = new DiscoveredMCPTool(
{} as any,
serverName,
toolName,
'desc',
{},
{} as any,
);
const qn = tool.getFullyQualifiedName();
expect(qn.length).toBeLessThanOrEqual(63);
expect(qn).toContain('...');
});
it('should handle server names starting with digits', () => {
const tool = new DiscoveredMCPTool(
{} as any,
'123-server',
'tool',
'desc',
{},
{} as any,
);
const qn = tool.getFullyQualifiedName();
expect(qn).toBe('_123-server__tool');
});
});
});
+29 -11
View File
@@ -96,14 +96,17 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
) {
// Use composite format for policy checks: serverName__toolName
// This enables server wildcards (e.g., "google-workspace__*")
// while still allowing specific tool rules
// while still allowing specific tool rules.
// We use the same sanitized names as the registry to ensure policy matches.
super(
params,
messageBus,
`${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${serverToolName}`,
generateValidName(
`${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${serverToolName}`,
),
displayName,
serverName,
generateValidName(serverName),
toolAnnotationsData,
);
}
@@ -273,7 +276,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
private readonly _toolAnnotations?: Record<string, unknown>,
) {
super(
nameOverride ?? generateValidName(serverToolName),
generateValidName(nameOverride ?? serverToolName),
`${serverToolName} (${serverName} MCP Server)`,
description,
Kind.Other,
@@ -305,7 +308,9 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
}
getFullyQualifiedName(): string {
return `${this.getFullyQualifiedPrefix()}${generateValidName(this.serverToolName)}`;
return generateValidName(
`${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${this.serverToolName}`,
);
}
asFullyQualifiedTool(): DiscoveredMCPTool {
@@ -482,16 +487,29 @@ function getStringifiedResultForDisplay(rawResponse: Part[]): string {
return displayParts.join('\n');
}
/**
* Maximum length for a function name in the Gemini API.
* @see https://docs.cloud.google.com/vertex-ai/generative-ai/docs/model-reference/function-calling#functiondeclaration
*/
const MAX_FUNCTION_NAME_LENGTH = 64;
/** Visible for testing */
export function generateValidName(name: string) {
// Replace invalid characters (based on 400 error message from Gemini API) with underscores
let validToolname = name.replace(/[^a-zA-Z0-9_.-]/g, '_');
let validToolname = name.replace(/[^a-zA-Z0-9_.:-]/g, '_');
// If longer than 63 characters, replace middle with '___'
// (Gemini API says max length 64, but actual limit seems to be 63)
if (validToolname.length > 63) {
validToolname =
validToolname.slice(0, 28) + '___' + validToolname.slice(-32);
// Ensure it starts with a letter or underscore
if (/^[^a-zA-Z_]/.test(validToolname)) {
validToolname = `_${validToolname}`;
}
// If longer than the API limit, replace middle with '...'
// Note: We use 63 instead of 64 to be safe, as some environments have off-by-one behaviors.
const safeLimit = MAX_FUNCTION_NAME_LENGTH - 1;
if (validToolname.length > safeLimit) {
validToolname =
validToolname.slice(0, 30) + '...' + validToolname.slice(-30);
}
return validToolname;
}
+8 -7
View File
@@ -388,16 +388,17 @@ export class ShellToolInvocation extends BaseToolInvocation<
} else {
if (this.params.is_background || result.backgrounded) {
returnDisplayMessage = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
} else if (result.aborted) {
const cancelMsg = timeoutMessage || 'Command cancelled by user.';
if (result.output.trim()) {
returnDisplayMessage = `${cancelMsg}\n\nOutput before cancellation:\n${result.output}`;
} else {
returnDisplayMessage = cancelMsg;
}
} else if (result.output.trim()) {
returnDisplayMessage = result.output;
} else {
if (result.aborted) {
if (timeoutMessage) {
returnDisplayMessage = timeoutMessage;
} else {
returnDisplayMessage = 'Command cancelled by user.';
}
} else if (result.signal) {
if (result.signal) {
returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
} else if (result.error) {
returnDisplayMessage = `Command failed: ${getErrorMessage(
@@ -58,6 +58,8 @@ describe('tool-names', () => {
it('should validate MCP tool names (server__tool)', () => {
expect(isValidToolName('server__tool')).toBe(true);
expect(isValidToolName('my-server__my-tool')).toBe(true);
expect(isValidToolName('my.server__my:tool')).toBe(true);
expect(isValidToolName('my-server...truncated__tool')).toBe(true);
});
it('should validate legacy tool aliases', async () => {
+4 -2
View File
@@ -260,8 +260,10 @@ export function isValidToolName(
return !!options.allowWildcards;
}
// Basic slug validation for server and tool names
const slugRegex = /^[a-z0-9-_]+$/i;
// Basic slug validation for server and tool names.
// We allow dots (.) and colons (:) as they are valid in function names and
// used for truncation markers.
const slugRegex = /^[a-z0-9_.:-]+$/i;
return slugRegex.test(server) && slugRegex.test(tool);
}
-15
View File
@@ -206,14 +206,6 @@ export class ToolRegistry {
this.messageBus = messageBus;
}
/**
* Updates the configuration used by the tool registry.
* This is used when creating scoped registries for subagents.
*/
setConfig(config: Config): void {
this.config = config;
}
getMessageBus(): MessageBus {
return this.messageBus;
}
@@ -240,13 +232,6 @@ export class ToolRegistry {
this.allKnownTools.set(tool.name, tool);
}
/**
* Returns all known tools, including inactive ones.
*/
getAllKnownTools(): AnyDeclarativeTool[] {
return Array.from(this.allKnownTools.values());
}
/**
* Unregisters a tool definition by name.
*
+18 -1
View File
@@ -9,13 +9,30 @@ import { isTool } from '../index.js';
import { SHELL_TOOL_NAMES } from './shell-utils.js';
import levenshtein from 'fast-levenshtein';
import { ApprovalMode } from '../policy/types.js';
import { CoreToolCallStatus } from '../scheduler/types.js';
import {
CoreToolCallStatus,
type ToolCallResponseInfo,
} from '../scheduler/types.js';
import {
ASK_USER_DISPLAY_NAME,
WRITE_FILE_DISPLAY_NAME,
EDIT_DISPLAY_NAME,
} from '../tools/tool-names.js';
/**
* Validates if an object is a ToolCallResponseInfo.
*/
export function isToolCallResponseInfo(
data: unknown,
): data is ToolCallResponseInfo {
return (
typeof data === 'object' &&
data !== null &&
'callId' in data &&
'responseParts' in data
);
}
/**
* Options for determining if a tool call should be hidden in the CLI history.
*/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.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.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.4",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
-125
View File
@@ -1,125 +0,0 @@
# Technical Specification: Subagent Policy Isolation and Unique Toolsets
## Overview
This specification details the implementation of independent policies and unique, isolated toolsets for subagents. Currently, subagents inherit the primary agent's policy and toolset, which limits isolation and security. The proposed mechanism allows subagents to have private tools (built-in and MCP) that are hidden from the primary agent and subject to their own scoped policies.
## 1. YAML Frontmatter Schema
The subagent Markdown configuration (`.md` files) will be updated to support a `policy` block and an `mcp_servers` block in the YAML frontmatter.
### Schema Definition
```yaml
---
name: security-specialist
display_name: Security Specialist
description: Expert in vulnerability scanning and security audits.
# Explicitly listed tools available to the subagent
tools:
- builtin:read_file
- mcp:security-scanner:scan_vulnerabilities
# Scoped policy for the subagent
policy:
tools:
allowed:
- "builtin:read_file"
- "mcp:security-scanner:*"
exclude:
- "builtin:shell"
- "builtin:write_file"
mcp:
allowed:
- "security-scanner"
excluded:
- "*"
# Trust configuration for MCP servers
mcp_servers:
security-scanner:
trust: true
# MCP servers private to this subagent
mcp_servers:
security-scanner:
command: "npx"
args: ["@security/mcp-server"]
env:
API_KEY: "${SECURITY_API_KEY}"
---
```
## 2. Orchestration and Scoping Logic
The isolation is enforced by creating a scoped execution context for each subagent.
### Scoped Config and Policy Engine
1. **`LocalAgentExecutor.create`**: When a subagent is instantiated, it will no longer share the global `Config` directly.
2. **Config Scoping**: A new method `Config.createScopedConfig()` will be implemented to create a lightweight fork of the configuration.
3. **Policy Isolation**: If the subagent definition includes a `policy` block, a new `PolicyEngine` instance will be created using these settings. This engine will be used by the subagent's `ToolRegistry`.
4. **Tool Registry Scoping**: The subagent's `ToolRegistry` will be initialized with the scoped `PolicyEngine`. It will prioritize tools explicitly defined in the subagent's `tools` list and `mcp_servers` block.
## 3. Extension Authors and Private MCP Tools
Extension authors can now define MCP servers that are only available to specific agents within that extension, hiding them from the primary agent.
### `gemini-extension.json` Updates
MCP servers in extensions can now specify a `visibility` field.
```json
{
"name": "security-extension",
"mcpServers": {
"private-audit-tool": {
"command": "...",
"visibility": "private"
}
},
"agents": [
{
"name": "auditor",
"path": "agents/auditor.md"
}
]
}
```
- **`visibility: "public"` (default)**: Registered globally and available to the primary agent.
- **`visibility: "private"`**: Not registered globally. The `ExtensionManager` will attach these server configurations to the agents loaded from the same extension.
## 4. Component Updates
### Agent Registry (`packages/core/src/agents/registry.ts`)
- Update `AgentDefinition` and its internal storage to hold the scoped `policy` and private `mcpServers`.
- Ensure `registerAgent` handles the ingestion of these new fields.
### Tool Registry / Dispatcher (`packages/core/src/tools/tool-registry.ts`)
- The registry will now correctly filter tools based on the scoped `PolicyEngine` provided during construction.
- It will support dynamic registration of subagent-specific MCP servers without affecting the global registry.
### Configuration Parser (`packages/core/src/agents/agentLoader.ts`)
- Update `FrontmatterLocalAgentDefinition` and `localAgentSchema` (Zod) to include the `policy` and `mcp_servers` fields.
- Update `markdownToAgentDefinition` to map these fields to the internal `AgentDefinition`.
## 5. Runtime Enforcement
Boundaries are enforced at multiple levels:
1. **Discovery Boundary**: The primary agent's `ToolRegistry` never sees "private" or "internal" MCP tools.
2. **Policy Boundary**: The subagent's `PolicyEngine` independently evaluates every tool call against the subagent's specific `allow`/`exclude` rules.
3. **Execution Boundary**: `LocalAgentExecutor` validates all incoming `function_call` requests against the subagent's scoped `ToolRegistry` before scheduling execution.
---
## Implementation Roadmap
### Phase 1: Core Types and Parsing
- [ ] Update `AgentDefinition` and `LocalAgentDefinition` types in `core/agents/types.ts`.
- [ ] Update Zod schemas and parsing logic in `core/agents/agentLoader.ts`.
- [ ] Add `visibility` field to `MCPServerConfig` in `core/config/config.ts`.
### Phase 2: Configuration Scoping
- [ ] Implement `Config.createScopedConfig()` in `core/config/config.ts`.
- [ ] Update `PolicyEngine` to support initialization from `PolicySettings`.
### Phase 3: Isolated Execution
- [ ] Modify `LocalAgentExecutor.create` to use scoped config and policy.
- [ ] Implement logic to start and register subagent-specific MCP servers in the isolated `ToolRegistry`.
- [ ] Update `ExtensionManager` in `cli/config/extension-manager.ts` to handle private MCP server visibility.
### Phase 4: Validation and Testing
- [ ] Add unit tests for scoped policy enforcement.
- [ ] Add integration tests for subagent-specific MCP tools.
- [ ] Verify that private tools are not leaked to the primary agent's `ToolRegistry`.