mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 05:31:02 -07:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c316fc6c51 | |||
| 7e6e40c814 | |||
| b25c8137f6 | |||
| 03a8fc3113 | |||
| 393e9a171f | |||
| 59702f913d | |||
| b3439e1458 | |||
| b21b289e26 | |||
| 56a63a35e2 | |||
| 34f0c1538b | |||
| c70c95ead3 | |||
| f3bbe6e77a | |||
| 28e79831ac | |||
| fdf5b18cfb | |||
| b5f3eb2c9c | |||
| 2a84090dd5 | |||
| 27d7aeb1ed | |||
| d6c560498b | |||
| 4500da339b | |||
| 4be08a2261 |
@@ -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:** You’ve 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('ACP telemetry', () => {
|
||||
'node',
|
||||
[
|
||||
bundlePath,
|
||||
'--experimental-acp',
|
||||
'--acp',
|
||||
'--fake-responses',
|
||||
join(rig.testDir!, 'fake-responses.json'),
|
||||
],
|
||||
|
||||
Generated
+244
-71
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+328
-4
@@ -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());
|
||||
});
|
||||
});
|
||||
+298
-18
@@ -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,
|
||||
};
|
||||
}
|
||||
+9
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => '/'),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -500,6 +500,8 @@ describe('Server Config (config.ts)', () => {
|
||||
config,
|
||||
authType,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
// Verify that contentGeneratorConfig is updated
|
||||
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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: [] },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -741,7 +741,7 @@ export class Scheduler {
|
||||
this.state.updateStatus(
|
||||
callId,
|
||||
CoreToolCallStatus.Cancelled,
|
||||
'Operation cancelled',
|
||||
result.response,
|
||||
);
|
||||
} else {
|
||||
this.state.updateStatus(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,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,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,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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user