Compare commits

..

14 Commits

Author SHA1 Message Date
gemini-cli-robot 79fa7dcb6d chore(release): v0.21.0 2025-12-16 18:32:05 +00:00
gemini-cli-robot 1f1bbc462f chore(release): v0.21.0-preview.6 2025-12-16 17:29:29 +00:00
gemini-cli-robot 32f536b76b fix(patch): cherry-pick 2995af6 to release/v0.21.0-preview.5-pr-15131 to patch version v0.21.0-preview.5 and create version 0.21.0-preview.6 (#15153)
Co-authored-by: Sehoon Shon <sshon@google.com>
2025-12-16 17:12:38 +00:00
gemini-cli-robot 5977ce8d62 chore(release): v0.21.0-preview.5 2025-12-12 21:26:52 +00:00
gemini-cli-robot f47b44e70d fix(patch): cherry-pick edbe548 to release/v0.21.0-preview.4-pr-15007 to patch version v0.21.0-preview.4 and create version 0.21.0-preview.5 (#15015)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-12-12 15:57:08 -05:00
gemini-cli-robot dcf2317adf chore(release): v0.21.0-preview.4 2025-12-12 18:37:01 +00:00
gemini-cli-robot 61c7c95374 fix(patch): cherry-pick 12cbe32 to release/v0.21.0-preview.3-pr-15000 to patch version v0.21.0-preview.3 and create version 0.21.0-preview.4 (#15003)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-12-12 10:15:52 -08:00
gemini-cli-robot ccee6a71df chore(release): v0.21.0-preview.3 2025-12-12 06:57:31 +00:00
gemini-cli-robot 73add0fda7 fix(patch): cherry-pick 54de675 to release/v0.21.0-preview.2-pr-14961 [CONFLICTS] (#14968)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-12-12 06:36:28 +00:00
gemini-cli-robot cc5c6bd90a chore(release): v0.21.0-preview.2 2025-12-10 19:27:51 +00:00
gemini-cli-robot d9e79331ef fix(patch): cherry-pick ee6556c to release/v0.21.0-preview.1-pr-14691 to patch version v0.21.0-preview.1 and create version 0.21.0-preview.2 (#14908)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2025-12-10 13:58:07 -05:00
gemini-cli-robot 68b7714d9b chore(release): v0.21.0-preview.1 2025-12-09 21:06:14 +00:00
gemini-cli-robot 04dece3e3c fix(patch): cherry-pick 3f5f030 to release/v0.21.0-preview.0-pr-14843 to patch version v0.21.0-preview.0 and create version 0.21.0-preview.1 (#14851)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2025-12-09 12:48:37 -08:00
gemini-cli-robot 2a3513bcdb chore(release): v0.21.0-preview.0 2025-12-09 20:11:24 +00:00
324 changed files with 4817 additions and 9921 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

+2 -2
View File
@@ -1,13 +1,13 @@
description="Injects context of all relevant cli files"
prompt = """
The source code contains the content of absolutely every source code file in
packages/cli and key files from packages/core. In addition to the source code, the following test files are
packages/cli. In addition to the source code, the following test files are
included as they are test files that conform to the project's testing standards:
`packages/cli/src/ui/components/InputPrompt.test.tsx` and `packages/cli/src/ui/App.test.tsx`.
You should very rarely need to read any other files from packages/cli to resolve
prompts.
!{find packages/cli -path packages/cli/dist -prune -o -type f \\( -name "*.ts" -o -name "*.tsx" \\) ! -name "*.test.ts" ! -name "*.test.tsx" ! -name "*.d.ts" -exec echo "--- {} ---" \\; -exec cat {} \\; && echo "--- packages/cli/src/ui/components/InputPrompt.test.tsx ---" && cat packages/cli/src/ui/components/InputPrompt.test.tsx && echo "--- packages/cli/src/ui/App.test.tsx ---" && cat packages/cli/src/ui/App.test.tsx && echo "--- packages/core/src/core/coreToolScheduler.ts ---" && cat packages/core/src/core/coreToolScheduler.ts && echo "--- packages/core/src/core/nonInteractiveToolExecutor.ts ---" && cat packages/core/src/core/nonInteractiveToolExecutor.ts && echo "--- packages/core/src/confirmation-bus/message-bus.ts ---" && cat packages/core/src/confirmation-bus/message-bus.ts && echo "--- packages/core/src/confirmation-bus/types.ts ---" && cat packages/core/src/confirmation-bus/types.ts && echo "--- packages/core/src/utils/events.ts ---" && cat packages/core/src/utils/events.ts && echo "--- packages/core/src/core/client.ts ---" && cat packages/core/src/core/client.ts && echo "--- packages/core/src/core/geminiChat.ts ---" && cat packages/core/src/core/geminiChat.ts && echo "--- packages/core/src/core/turn.ts ---" && cat packages/core/src/core/turn.ts && echo "--- packages/core/src/agents/executor.ts ---" && cat packages/core/src/agents/executor.ts && echo "--- packages/core/src/telemetry/types.ts ---" && cat packages/core/src/telemetry/types.ts && echo "--- packages/core/src/telemetry/loggers.ts ---" && cat packages/core/src/telemetry/loggers.ts && echo "--- packages/core/src/telemetry/uiTelemetry.ts ---" && cat packages/core/src/telemetry/uiTelemetry.ts}
!{find packages/cli -path packages/cli/dist -prune -o -type f \\( -name "*.ts" -o -name "*.tsx" \\) ! -name "*.test.ts" ! -name "*.test.tsx" ! -name "*.d.ts" -exec echo "--- {} ---" \\; -exec cat {} \\; && echo "--- packages/cli/src/ui/components/InputPrompt.test.tsx ---" && cat packages/cli/src/ui/components/InputPrompt.test.tsx && echo "--- packages/cli/src/ui/App.test.tsx ---" && cat packages/cli/src/ui/App.test.tsx}
**Pay extremely close attention to these files.** They define the project's
core architecture, component patterns, and testing standards.
@@ -6,7 +6,7 @@ on:
jobs:
label-pr:
timeout-minutes: 10
timeout-minutes: 5
if: |-
${{ github.repository == 'google-gemini/gemini-cli' }}
permissions:
@@ -142,8 +142,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned'
state: 'closed'
});
}
}
+1 -3
View File
@@ -41,9 +41,7 @@ This project follows
The process for contributing code is as follows:
1. **Find an issue** that you want to work on. If an issue is tagged as
"🔒Maintainers only", this means it is reserved for project maintainers. We
will not accept pull requests related to these issues.
1. **Find an issue** that you want to work on.
2. **Fork the repository** and create a new branch.
3. **Make your changes** in the `packages/` directory.
4. **Ensure all checks pass** by running `npm run preflight`.
+30 -30
View File
@@ -1,13 +1,12 @@
# Gemini CLI model selection (`/model` command)
Select your Gemini CLI model. The `/model` command lets you configure the model
used by Gemini CLI, giving you more control over your results. Use **Pro**
models for complex tasks and reasoning, **Flash** models for high speed results,
or the (recommended) **Auto** setting to choose the best model for your tasks.
Select your Gemini CLI model. The `/model` command opens a dialog where you can
configure the model used by Gemini CLI, giving you more control over your
results.
> **Note:** The `/model` command (and the `--model` flag) does not override the
> model used by sub-agents. Consequently, even when using the `/model` flag you
> may see other models used in your model usage reports.
**Note:** The `/model` command (and the `--model` flag) does not override the
model used by sub-agents. Consequently, even when using the `/model` flag you
may see other models used in your model usage reports.
## How to use the `/model` command
@@ -17,25 +16,26 @@ Use the following command in Gemini CLI:
/model
```
Running this command will open a dialog with your options:
Running this command will open a dialog with your model options:
| Option | Description | Models |
| ----------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview (if enabled), gemini-3-flash-preview (if enabled) |
| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash |
| Manual | Select a specific model. | Any available model. |
| Option | Description | Models |
| ------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Auto (recommended) | Let the system choose the best model for your task. | gemini-3-pro-preview (if enabled), gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite |
| Pro | For complex tasks that require deep reasoning and creativity. | gemini-3-pro-preview (if enabled), gemini-2.5-pro |
| Flash | For tasks that need a balance of speed and reasoning. | gemini-2.5-flash |
| Flash-Lite | For simple tasks that need to be done quickly. | gemini-2.5-flash-lite |
We recommend selecting one of the above **Auto** options. However, you can
select **Manual** to select a specific model from those available.
### Gemini 3 Pro and preview features
### Gemini 3 and preview features
Note: Gemini 3 is not currently available on all account types. To learn more
about Gemini 3 access, refer to
[Gemini 3 Pro on Gemini CLI](../get-started/gemini-3.md).
> **Note:** Gemini 3 is not currently available on all account types. To learn
> more about Gemini 3 access, refer to
> [Gemini 3 on Gemini CLI](../get-started/gemini-3.md).
To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable
[**Preview Features** by using the `settings` command](../cli/settings.md).
To enable Gemini 3 Pro (if available), enable
[**Preview features** by using the `settings` command](../cli/settings.md). Once
enabled, Gemini CLI will attempt to use Gemini 3 Pro when you select **Auto** or
**Pro**. Both **Auto** and **Pro** will try to use Gemini 3 Pro before falling
back to Gemini 2.5 Pro.
You can also use the `--model` flag to specify a particular Gemini model on
startup. For more details, refer to the
@@ -46,16 +46,16 @@ Gemini CLI.
## Best practices for model selection
- **Default to Auto.** For most users, the _Auto_ option model provides a
balance between speed and performance, automatically selecting the correct
model based on the complexity of the task. Example: Developing a web
application could include a mix of complex tasks (building architecture and
scaffolding the project) and simple tasks (generating CSS).
- **Default to Auto (recommended).** For most users, the _Auto (recommended)_
model provides a balance between speed and performance, automatically
selecting the correct model based on the complexity of the task. Example:
Developing a web application could include a mix of complex tasks (building
architecture and scaffolding the project) and simple tasks (generating CSS).
- **Switch to Pro if you aren't getting the results you want.** If you think you
need your model to be a little "smarter," you can manually select Pro. Pro
will provide you with the highest levels of reasoning and creativity. Example:
A complex or multi-stage debugging task.
need your model to be a little "smarter," use Pro. Pro will provide you with
the highest levels of reasoning and creativity. Example: A complex or
multi-stage debugging task.
- **Switch to Flash or Flash-Lite if you need faster results.** If you need a
simple response quickly, Flash or Flash-Lite is the best option. Example:
+1 -1
View File
@@ -94,7 +94,7 @@ they appear in the UI.
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `10000` |
| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `100` |
| Enable Message Bus Integration | `tools.enableMessageBusIntegration` | Enable policy-based tool confirmation via message bus integration. | `true` |
| Enable Message Bus Integration | `tools.enableMessageBusIntegration` | Enable policy-based tool confirmation via message bus integration. | `false` |
### Security
+3 -24
View File
@@ -1,32 +1,13 @@
# Policy engine
:::note This feature is currently in testing. To enable it, set
`tools.enableMessageBusIntegration` to `true` in your `settings.json` file. :::
The Gemini CLI includes a powerful policy engine that provides fine-grained
control over tool execution. It allows users and administrators to define rules
that determine whether a tool call should be allowed, denied, or require user
confirmation.
## Quick start
To create your first policy:
1. **Create the policy directory** if it doesn't exist:
```bash
mkdir -p ~/.gemini/policies
```
2. **Create a new policy file** (e.g., `~/.gemini/policies/my-rules.toml`). You
can use any filename ending in `.toml`; all such files in this directory
will be loaded and combined:
```toml
[[rule]]
toolName = "run_shell_command"
commandPrefix = "git status"
decision = "allow"
priority = 100
```
3. **Run a command** that triggers the policy (e.g., ask Gemini CLI to
`git status`). The tool will now execute automatically without prompting for
confirmation.
## Core concepts
The policy engine operates on a set of rules. Each rule is a combination of
@@ -258,8 +239,6 @@ The Gemini CLI ships with a set of default policies to provide a safe
out-of-the-box experience.
- **Read-only tools** (like `read_file`, `glob`) are generally **allowed**.
- **Agent delegation** (like `delegate_to_agent`) is **allowed** (sub-agent
actions are checked individually).
- **Write tools** (like `write_file`, `run_shell_command`) default to
**`ask_user`**.
- In **`yolo`** mode, a high-priority rule allows all tools.
+1 -22
View File
@@ -367,12 +367,6 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-3-pro-preview"
}
},
"gemini-3-flash-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3-flash-preview"
}
},
"gemini-2.5-pro": {
"extends": "chat-base-2.5",
"modelConfig": {
@@ -502,11 +496,6 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-3-pro-preview"
}
},
"chat-compression-3-flash": {
"modelConfig": {
"model": "gemini-3-flash-preview"
}
},
"chat-compression-2.5-pro": {
"modelConfig": {
"model": "gemini-2.5-pro"
@@ -535,11 +524,6 @@ their corresponding top-level category object in your `settings.json` file.
with (and override) the built-in aliases.
- **Default:** `{}`
- **`modelConfigs.customOverrides`** (array):
- **Description:** Custom model config overrides. These are merged with (and
added to) the built-in overrides.
- **Default:** `[]`
- **`modelConfigs.overrides`** (array):
- **Description:** Apply specific configuration overrides based on matches,
with a primary key of model (or alias). The most specific match will be
@@ -731,11 +715,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`security.enablePermanentToolApproval`** (boolean):
- **Description:** Enable the "Allow for all future sessions" option in tool
confirmation dialogs.
- **Default:** `false`
- **`security.blockGitExtensions`** (boolean):
- **Description:** Blocks installing and loading extensions from Git.
- **Default:** `false`
@@ -837,7 +816,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`experimental.codebaseInvestigatorSettings.model`** (string):
- **Description:** The model to use for the Codebase Investigator agent.
- **Default:** `"auto"`
- **Default:** `"gemini-2.5-pro"`
- **Requires restart:** Yes
#### `hooks`
+1 -1
View File
@@ -24,7 +24,7 @@ Rename the photos in my "photos" directory based on their contents.
Result: Gemini will ask for permission to rename your files.
Select **Allow once** and your files will be renamed:
Select **Yes, allow once.** and your files will be renamed:
```bash
photos/yellow_flowers.png
+48 -29
View File
@@ -1,37 +1,56 @@
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
# Gemini 3 Pro on Gemini CLI
Gemini 3 Pro and Gemini 3 Flash are now available on Gemini CLI! Currently, most
paid customers of Gemini CLI will have access to both Gemini 3 Pro and Gemini 3
Flash, including the following subscribers:
Were excited to bring Gemini 3 Pro to Gemini CLI. Gemini 3 Pro is **currently
available** on Gemini CLI to all of the following subscribers:
- Google AI Pro and Google AI Ultra (excluding business customers).
- Gemini Code Assist Standard and Enterprise (requires
- Google AI Ultra (except Google AI Ultra for Business).
- Google AI Pro.
- Gemini Code Assist Standard (requires
[administrative enablement](#administrator-instructions)).
- Paid Gemini API and Vertex API key holders.
- Gemini Code Assist Enterprise (requires
[administrative enablement](#administrator-instructions)).
- Paid Gemini API key holders.
- Paid Vertex API key holders.
For free tier users:
For **everyone else**, we're gradually expanding access
[through a waitlist](https://goo.gle/geminicli-waitlist-signup). If you don't
have one of the listed subscriptions, sign up for the waitlist to access Gemini
3 Pro once approved.
- If you signed up for the waitlist, please check your email for details. Weve
onboarded everyone who signed up to the previously available waitlist.
- If you were not on our waitlist, were rolling out additional access gradually
to ensure the experience remains fast and reliable. Stay tuned for more
details.
**Note:** Whether youre automatically granted access or accepted from the
waitlist, youll still need to enable Gemini 3 Pro
[using the `/settings` command](../cli/settings.md).
## How to get started with Gemini 3 on Gemini CLI
## How to join the waitlist
Get started by upgrading Gemini CLI to the latest version (0.21.1):
Users not automatically granted access will need to join the waitlist. Follow
these instructions to sign up:
```bash
npm install -g @google/gemini-cli@latest
```
- Install Gemini CLI.
- Authenticate using the **Login with Google** option. Youll see a banner that
says “Gemini 3 is now available.” If you do not see this banner, update your
installation of Gemini CLI to the most recent version.
- Fill out this Google form:
[Access Gemini 3 in Gemini CLI](https://goo.gle/geminicli-waitlist-signup).
Provide the email address of the account you used to authenticate with Gemini
CLI.
After youve confirmed your version is 0.21.1 or later:
Users will be onboarded in batches, subject to availability. When youve been
granted access to Gemini 3 Pro, youll receive an acceptance email to your
submitted email address.
1. Use the `/settings` command in Gemini CLI.
2. Toggle **Preview Features** to `true`.
3. Run `/model` and select **Auto (Gemini 3)**.
**Note:** Please wait until you have been approved to use Gemini 3 Pro to enable
**Preview Features**. If enabled early, the CLI will fallback to Gemini 2.5 Pro.
For more information, see [Gemini CLI model selection](../cli/model.md).
## How to use Gemini 3 Pro with Gemini CLI
Once you receive your acceptance emailor if you are automatically granted
accessyou still need to enable Gemini 3 Pro within Gemini CLI.
To enable Gemini 3 Pro, use the `/settings` command in Gemini CLI and set
**Preview Features** to `true`.
For more information, see [Gemini CLI Settings](../cli/settings.md).
### Usage limits and fallback
@@ -49,10 +68,10 @@ There may be times when the Gemini 3 Pro model is overloaded. When that happens,
Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro
or fallback to Gemini 2.5 Pro.
> **Note:** The **Keep trying** option uses exponential backoff, in which Gemini
> CLI waits longer between each retry, when the system is busy. If the retry
> doesn't happen immediately, please wait a few minutes for the request to
> process.
**Note:** The **Keep trying** option uses exponential backoff, in which Gemini
CLI waits longer between each retry, when the system is busy. If the retry
doesn't happen immediately, please wait a few minutes for the request to
process.
### Model selection and routing types
@@ -73,7 +92,7 @@ manage your usage limits:
To learn more about selecting a model and routing, refer to
[Gemini CLI Model Selection](../cli/model.md).
## How to enable Gemini 3 with Gemini CLI on Gemini Code Assist
## How to enable Gemini 3 Pro with Gemini CLI on Gemini Code Assist
If you're using Gemini Code Assist Standard or Gemini Code Assist Enterprise,
enabling Gemini 3 Pro on Gemini CLI requires configuring your release channels.
@@ -104,7 +123,7 @@ then:
- Use the `/settings` command.
- Set **Preview Features** to `true`.
Restart Gemini CLI and you should have access to Gemini 3.
Restart Gemini CLI and you should have access to Gemini 3 Pro.
## Need help?
+2 -2
View File
@@ -128,8 +128,8 @@ editor.
You can also **modify the suggested changes** directly in the diff view before
accepting them.
If you select Allow for this session in the CLI, changes will no longer show
up in the IDE as they will be auto-accepted.
If you select Yes, allow always in the CLI, changes will no longer show up in
the IDE as they will be auto-accepted.
## Using with sandboxing
-3
View File
@@ -14,9 +14,6 @@ feature), while the PR is the "how" (the implementation). This separation helps
us track work, prioritize features, and maintain clear historical context. Our
automation is built around this principle.
> **Note:** Issues tagged as "🔒Maintainers only" are reserved for project
> maintainers. We will not accept pull requests related to these issues.
---
## Detailed automation workflows
+1 -1
View File
@@ -24,7 +24,7 @@
"slug": "docs/get-started"
},
{
"label": "Gemini 3 on Gemini CLI",
"label": "Gemini 3 Pro on Gemini CLI",
"slug": "docs/get-started/gemini-3"
},
{
-3
View File
@@ -153,6 +153,3 @@ guide_, consider searching the Gemini CLI
[Issue tracker on GitHub](https://github.com/google-gemini/gemini-cli/issues).
If you can't find an issue similar to yours, consider creating a new GitHub
Issue with a detailed description. Pull requests are also welcome!
> **Note:** Issues tagged as "🔒Maintainers only" are reserved for project
> maintainers. We will not accept pull requests related to these issues.
-1
View File
@@ -166,7 +166,6 @@ export default tseslint.config(
radix: 'error',
'default-case': 'error',
'@typescript-eslint/no-floating-promises': ['error'],
'@typescript-eslint/no-unnecessary-type-assertion': ['error'],
},
},
{
+2 -9
View File
@@ -4,20 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import * as os from 'node:os';
import { TestRig } from './test-helper.js';
describe('Ctrl+C exit', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should exit gracefully on second Ctrl+C', async () => {
const rig = new TestRig();
await rig.setup('should exit gracefully on second Ctrl+C', {
settings: { tools: { useRipgrep: false } },
});
+3 -9
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { describe, expect, it } from 'vitest';
import { TestRig } from './test-helper.js';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
@@ -20,15 +20,8 @@ const extensionUpdate = `{
}`;
describe('extension install', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('installs a local extension, verifies a command, and updates it', async () => {
const rig = new TestRig();
rig.setup('extension install test');
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
writeFileSync(testServerPath, extension);
@@ -54,6 +47,7 @@ describe('extension install', () => {
'uninstall',
'test-extension-install',
]);
await rig.cleanup();
}
});
});
+4 -10
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { expect, it, describe, beforeEach, afterEach } from 'vitest';
import { expect, it, describe } from 'vitest';
import { TestRig } from './test-helper.js';
import { TestMcpServer } from './test-mcp-server.js';
import { writeFileSync } from 'node:fs';
@@ -18,14 +18,6 @@ import stripAnsi from 'strip-ansi';
const itIf = (condition: boolean) => (condition ? it : it.skip);
describe('extension reloading', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
const sandboxEnv = env['GEMINI_SANDBOX'];
// Fails in linux non-sandbox e2e tests
// TODO(#14527): Re-enable this once fixed
@@ -51,6 +43,7 @@ describe('extension reloading', () => {
},
};
const rig = new TestRig();
rig.setup('extension reload test', {
settings: {
experimental: { extensionReloading: true },
@@ -82,7 +75,7 @@ describe('extension reloading', () => {
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
// Start the CLI.
const run = await rig.runInteractive({ args: '--debug' });
const run = await rig.runInteractive('--debug');
await run.expectText('You have 1 extension with an update available');
// See the outdated extension
await run.sendText('/extensions list');
@@ -152,6 +145,7 @@ describe('extension reloading', () => {
await serverA.stop();
await serverB.stop();
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
await rig.cleanup();
},
);
});
+19 -25
View File
@@ -4,29 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { existsSync } from 'node:fs';
import * as path from 'node:path';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('file-system', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should be able to read a file', async () => {
const rig = new TestRig();
await rig.setup('should be able to read a file', {
settings: { tools: { core: ['read_file'] } },
});
rig.createFile('test.txt', 'hello world');
const result = await rig.run({
args: `read the file test.txt and show me its contents`,
});
const result = await rig.run(
`read the file test.txt and show me its contents`,
);
const foundToolCall = await rig.waitForToolCall('read_file');
@@ -48,14 +41,13 @@ describe('file-system', () => {
});
it('should be able to write a file', async () => {
const rig = new TestRig();
await rig.setup('should be able to write a file', {
settings: { tools: { core: ['write_file', 'replace', 'read_file'] } },
});
rig.createFile('test.txt', '');
const result = await rig.run({
args: `edit test.txt to have a hello world message`,
});
const result = await rig.run(`edit test.txt to have a hello world message`);
// Accept multiple valid tools for editing files
const foundToolCall = await rig.waitForAnyToolCall([
@@ -106,14 +98,15 @@ describe('file-system', () => {
});
it('should correctly handle file paths with spaces', async () => {
const rig = new TestRig();
await rig.setup('should correctly handle file paths with spaces', {
settings: { tools: { core: ['write_file', 'read_file'] } },
});
const fileName = 'my test file.txt';
const result = await rig.run({
args: `write "hello" to "${fileName}" and then stop. Do not perform any other actions.`,
});
const result = await rig.run(
`write "hello" to "${fileName}" and then stop. Do not perform any other actions.`,
);
const foundToolCall = await rig.waitForToolCall('write_file');
if (!foundToolCall) {
@@ -129,6 +122,7 @@ describe('file-system', () => {
});
it('should perform a read-then-write sequence', async () => {
const rig = new TestRig();
await rig.setup('should perform a read-then-write sequence', {
settings: { tools: { core: ['read_file', 'replace', 'write_file'] } },
});
@@ -136,7 +130,7 @@ describe('file-system', () => {
rig.createFile(fileName, '1.0.0');
const prompt = `Read the version from ${fileName} and write the next version 1.0.1 back to the file.`;
const result = await rig.run({ args: prompt });
const result = await rig.run(prompt);
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
@@ -165,15 +159,16 @@ describe('file-system', () => {
});
it.skip('should replace multiple instances of a string', async () => {
const rig = new TestRig();
rig.setup('should replace multiple instances of a string');
const fileName = 'ambiguous.txt';
const fileContent = 'Hey there, \ntest line\ntest line';
const expectedContent = 'Hey there, \nnew line\nnew line';
rig.createFile(fileName, fileContent);
const result = await rig.run({
args: `rewrite the file ${fileName} to replace all instances of "test line" with "new line"`,
});
const result = await rig.run(
`rewrite the file ${fileName} to replace all instances of "test line" with "new line"`,
);
const validTools = ['write_file', 'edit'];
const foundToolCall = await rig.waitForAnyToolCall(validTools);
@@ -216,15 +211,14 @@ describe('file-system', () => {
});
it('should fail safely when trying to edit a non-existent file', async () => {
const rig = new TestRig();
await rig.setup(
'should fail safely when trying to edit a non-existent file',
{ settings: { tools: { core: ['read_file', 'replace'] } } },
);
const fileName = 'non_existent.txt';
const result = await rig.run({
args: `In ${fileName}, replace "a" with "b"`,
});
const result = await rig.run(`In ${fileName}, replace "a" with "b"`);
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
+17 -20
View File
@@ -4,39 +4,36 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { TestRig } from './test-helper.js';
import { join } from 'node:path';
describe('Flicker Detector', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should not detect a flicker under the max height budget', async () => {
const rig = new TestRig();
rig.setup('flicker-detector-test', {
fakeResponsesPath: join(
import.meta.dirname,
'flicker-detector.max-height.responses',
),
});
const run = await rig.runInteractive();
const prompt = 'Tell me a fun fact.';
await run.type(prompt);
await run.type('\r');
try {
const run = await rig.runInteractive();
const prompt = 'Tell me a fun fact.';
await run.type(prompt);
await run.type('\r');
const hasUserPromptEvent = await rig.waitForTelemetryEvent('user_prompt');
expect(hasUserPromptEvent).toBe(true);
const hasUserPromptEvent = await rig.waitForTelemetryEvent('user_prompt');
expect(hasUserPromptEvent).toBe(true);
const hasSessionCountMetric = await rig.waitForMetric('session.count');
expect(hasSessionCountMetric).toBe(true);
const hasSessionCountMetric = await rig.waitForMetric('session.count');
expect(hasSessionCountMetric).toBe(true);
// We expect NO flicker event to be found.
const flickerMetric = rig.readMetric('ui.flicker.count');
expect(flickerMetric).toBeNull();
// We expect NO flicker event to be found.
const flickerMetric = rig.readMetric('ui.flicker.count');
expect(flickerMetric).toBeNull();
} finally {
await rig.cleanup();
}
});
});
+4 -11
View File
@@ -5,26 +5,19 @@
*/
import { WEB_SEARCH_TOOL_NAME } from '../packages/core/src/tools/tool-names.js';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('web search tool', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
describe(WEB_SEARCH_TOOL_NAME, () => {
it('should be able to search the web', async () => {
const rig = new TestRig();
await rig.setup('should be able to search the web', {
settings: { tools: { core: [WEB_SEARCH_TOOL_NAME] } },
});
let result;
try {
result = await rig.run({ args: `what is the weather in London` });
result = await rig.run(`what is the weather in London`);
} catch (error) {
// Network errors can occur in CI environments
if (
+44 -41
View File
@@ -55,9 +55,8 @@ describe('Hooks System Integration', () => {
},
);
const result = await rig.run({
args: 'Create a file called test.txt with content "Hello World"',
});
const prompt = 'Create a file called test.txt with content "Hello World"';
const result = await rig.run(prompt);
// The hook should block the write_file tool
const toolLogs = rig.readToolLogs();
@@ -108,9 +107,9 @@ describe('Hooks System Integration', () => {
},
);
await rig.run({
args: 'Create a file called approved.txt with content "Approved content"',
});
const prompt =
'Create a file called approved.txt with content "Approved content"';
await rig.run(prompt);
// The hook should allow the write_file tool
const foundWriteFile = await rig.waitForToolCall('write_file');
@@ -159,9 +158,9 @@ describe('Hooks System Integration', () => {
// Create a test file to read
rig.createFile('test-file.txt', 'This is test content');
await rig.run({
args: 'Read the contents of test-file.txt and tell me what it contains',
});
const prompt =
'Read the contents of test-file.txt and tell me what it contains';
await rig.run(prompt);
// Should find read_file tool call
const foundReadFile = await rig.waitForToolCall('read_file');
@@ -233,7 +232,8 @@ echo '{
},
});
const result = await rig.run({ args: 'Tell me a story' });
const prompt = 'Tell me a story';
const result = await rig.run(prompt);
// The hook should have replaced the request entirely
// Verify that the model responded to the modified request, not the original
@@ -316,7 +316,8 @@ echo '{
},
});
const result = await rig.run({ args: 'What is 2 + 2?' });
const prompt = 'What is 2 + 2?';
const result = await rig.run(prompt);
// The hook should have replaced the model response
expect(result).toContain(
@@ -373,9 +374,9 @@ echo '{
// Create a test file
rig.createFile('new_file_data.txt', 'test data');
await rig.run({
args: 'Check the content of new_file_data.txt, after that run echo command to see the content',
});
const prompt =
'Check the content of new_file_data.txt, after that run echo command to see the content';
await rig.run(prompt);
// Should use read_file (allowed) but not run_shell_command (not in allowed list)
const foundReadFile = await rig.waitForToolCall('read_file');
@@ -439,7 +440,8 @@ echo '{
},
});
const result = await rig.run({ args: 'Hello, how are you?' });
const prompt = 'Hello, how are you?';
const result = await rig.run(prompt);
// The hook should have added security context, which should influence the response
expect(result).toContain('security');
@@ -583,7 +585,8 @@ echo '{
},
});
await rig.run({ args: 'Hello, please help me with a task' });
const prompt = 'Hello, please help me with a task';
await rig.run(prompt);
// Should generate hook telemetry
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
@@ -660,9 +663,8 @@ fi`;
},
});
await rig.run({
args: 'Create a file called input-test.txt with content "test"',
});
const prompt = 'Create a file called input-test.txt with content "test"';
await rig.run(prompt);
// Hook should validate input format successfully
const foundWriteFile = await rig.waitForToolCall('write_file');
@@ -737,12 +739,11 @@ fi`;
},
});
const result = await rig.run({
args:
'Create a file called multi-event-test.txt with content ' +
'"testing multiple events", and then please reply with ' +
'everything I say just after this:"',
});
const prompt =
'Create a file called multi-event-test.txt with content ' +
'"testing multiple events", and then please reply with ' +
'everything I say just after this:"';
const result = await rig.run(prompt);
// Should execute write_file tool
const foundWriteFile = await rig.waitForToolCall('write_file');
@@ -833,9 +834,9 @@ fi`;
},
});
await rig.run({
args: 'Create a file called error-test.txt with content "testing error handling"',
});
const prompt =
'Create a file called error-test.txt with content "testing error handling"';
await rig.run(prompt);
// Despite one hook failing, the working hook should still allow the operation
const foundWriteFile = await rig.waitForToolCall('write_file');
@@ -882,7 +883,8 @@ fi`;
},
});
await rig.run({ args: 'Create a file called telemetry-test.txt' });
const prompt = 'Create a file called telemetry-test.txt';
await rig.run(prompt);
// Should execute the tool
const foundWriteFile = await rig.waitForToolCall('write_file');
@@ -927,7 +929,8 @@ fi`;
});
// Run a simple query - the SessionStart hook will fire during app initialization
await rig.run({ args: 'Say hello' });
const prompt = 'Say hello';
await rig.run(prompt);
// Verify hook executed with correct parameters
const hookLogs = rig.readHookLogs();
@@ -1171,7 +1174,8 @@ fi`;
});
// Run a simple query that will trigger automatic compression
await rig.run({ args: 'Say hello in exactly 5 words' });
const prompt = 'Say hello in exactly 5 words';
await rig.run(prompt);
// Verify hook executed with correct parameters
const hookLogs = rig.readHookLogs();
@@ -1232,7 +1236,8 @@ fi`;
});
// Run in non-interactive mode with a simple prompt
await rig.run({ args: 'Hello' });
const prompt = 'Hello';
await rig.run(prompt);
// The process should exit gracefully, firing the SessionEnd hook
// Wait for telemetry to be written to disk
@@ -1339,9 +1344,9 @@ echo '{"decision": "block", "systemMessage": "Disabled hook should not execute",
},
});
const result = await rig.run({
args: 'Create a file called disabled-test.txt with content "test"',
});
const prompt =
'Create a file called disabled-test.txt with content "test"';
const result = await rig.run(prompt);
// Tool should execute (enabled hook allows it)
const foundWriteFile = await rig.waitForToolCall('write_file');
@@ -1426,9 +1431,8 @@ echo '{"decision": "block", "systemMessage": "Disabled hook should not execute",
);
// First run - only active hook should execute
const result1 = await rig.run({
args: 'Create a file called first-run.txt with "test1"',
});
const prompt1 = 'Create a file called first-run.txt with "test1"';
const result1 = await rig.run(prompt1);
// Tool should execute (active hook allows it)
const foundWriteFile1 = await rig.waitForToolCall('write_file');
@@ -1451,9 +1455,8 @@ echo '{"decision": "block", "systemMessage": "Disabled hook should not execute",
expect(disabledHookLog1).toBeUndefined();
// Second run - verify disabled hook stays disabled
const result2 = await rig.run({
args: 'Create a file called second-run.txt with "test2"',
});
const prompt2 = 'Create a file called second-run.txt with "test2"';
const result2 = await rig.run(prompt2);
const foundWriteFile2 = await rig.waitForToolCall('write_file');
expect(foundWriteFile2).toBeTruthy();
+13 -15
View File
@@ -22,9 +22,11 @@ describe('JSON output', () => {
});
it('should return a valid JSON with response and stats', async () => {
const result = await rig.run({
args: ['What is the capital of France?', '--output-format', 'json'],
});
const result = await rig.run(
'What is the capital of France?',
'--output-format',
'json',
);
const parsed = JSON.parse(result);
expect(parsed).toHaveProperty('response');
@@ -36,9 +38,7 @@ describe('JSON output', () => {
});
it('should return a valid JSON with a session ID', async () => {
const result = await rig.run({
args: ['Hello', '--output-format', 'json'],
});
const result = await rig.run('Hello', '--output-format', 'json');
const parsed = JSON.parse(result);
expect(parsed).toHaveProperty('session_id');
@@ -58,7 +58,7 @@ describe('JSON output', () => {
let thrown: Error | undefined;
try {
await rig.run({ args: ['Hello', '--output-format', 'json'] });
await rig.run('Hello', '--output-format', 'json');
expect.fail('Expected process to exit with error');
} catch (e) {
thrown = e as Error;
@@ -108,14 +108,12 @@ describe('JSON output', () => {
'json-output.error.responses',
),
});
const result = await rig.run({
args: [
`Read the contents of ${rig.testDir}/path/to/nonexistent/file.txt and tell me what it says. ` +
'On error, respond to the user with exactly the text "File not found".',
'--output-format',
'json',
],
});
const result = await rig.run(
`Read the contents of ${rig.testDir}/path/to/nonexistent/file.txt and tell me what it says. ` +
'On error, respond to the user with exactly the text "File not found".',
'--output-format',
'json',
);
const parsed = JSON.parse(result);
+3 -10
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, beforeEach, afterEach } from 'vitest';
import { describe, it } from 'vitest';
import {
TestRig,
poll,
@@ -15,15 +15,8 @@ import { existsSync } from 'node:fs';
import { join } from 'node:path';
describe('list_directory', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should be able to list a directory', async () => {
const rig = new TestRig();
await rig.setup('should be able to list a directory', {
settings: { tools: { core: ['list_directory'] } },
});
@@ -45,7 +38,7 @@ describe('list_directory', () => {
const prompt = `Can you list the files in the current directory.`;
const result = await rig.run({ args: prompt });
const result = await rig.run(prompt);
try {
await rig.expectToolCallSuccess(['list_directory']);
@@ -23,7 +23,7 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, afterEach, beforeEach } from 'vitest';
import { beforeAll, describe, it } from 'vitest';
import { TestRig } from './test-helper.js';
// Create a minimal MCP server that doesn't require external dependencies
@@ -166,15 +166,9 @@ rpc.send({
`;
describe('mcp server with cyclic tool schema is detected', () => {
let rig: TestRig;
const rig = new TestRig();
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('mcp tool list should include tool with cyclic tool schema', async () => {
beforeAll(async () => {
// Setup test directory with MCP server configuration
await rig.setup('cyclic-schema-mcp-server', {
settings: {
@@ -196,7 +190,9 @@ describe('mcp server with cyclic tool schema is detected', () => {
const { chmodSync } = await import('node:fs');
chmodSync(testServerPath, 0o755);
}
});
it('mcp tool list should include tool with cyclic tool schema', async () => {
const run = await rig.runInteractive();
await run.type('/mcp list');
+14 -17
View File
@@ -4,29 +4,24 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { TestRig } from './test-helper.js';
describe('mixed input crash prevention', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should not crash when using mixed prompt inputs', async () => {
const rig = new TestRig();
rig.setup('should not crash when using mixed prompt inputs');
// Test: echo "say '1'." | gemini --prompt-interactive="say '2'." say '3'.
const stdinContent = "say '1'.";
try {
await rig.run({
args: ['--prompt-interactive', "say '2'.", "say '3'."],
stdin: stdinContent,
});
await rig.run(
{ stdin: stdinContent },
'--prompt-interactive',
"say '2'.",
"say '3'.",
);
throw new Error('Expected the command to fail, but it succeeded');
} catch (error: unknown) {
expect(error).toBeInstanceOf(Error);
@@ -45,13 +40,15 @@ describe('mixed input crash prevention', () => {
});
it('should provide clear error message for mixed input', async () => {
const rig = new TestRig();
rig.setup('should provide clear error message for mixed input');
try {
await rig.run({
args: ['--prompt-interactive', 'test prompt'],
stdin: 'test input',
});
await rig.run(
{ stdin: 'test input' },
'--prompt-interactive',
'test prompt',
);
throw new Error('Expected the command to fail, but it succeeded');
} catch (error: unknown) {
expect(error).toBeInstanceOf(Error);
+4 -10
View File
@@ -4,19 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('read_many_files', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it.skip('should be able to read multiple files', async () => {
const rig = new TestRig();
await rig.setup('should be able to read multiple files', {
settings: { tools: { core: ['read_many_files', 'read_file'] } },
});
@@ -25,7 +18,7 @@ describe('read_many_files', () => {
const prompt = `Use the read_many_files tool to read the contents of file1.txt and file2.txt and then print the contents of each file.`;
const result = await rig.run({ args: prompt });
const result = await rig.run(prompt);
// Check for either read_many_files or multiple read_file calls
const allTools = rig.readToolLogs();
@@ -52,5 +45,6 @@ describe('read_many_files', () => {
// Validate model output - will throw if no output
validateModelOutput(result, null, 'Read many files test');
await rig.cleanup();
});
});
+13 -18
View File
@@ -4,18 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { TestRig } from './test-helper.js';
describe('replace', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should be able to replace content in a file', async () => {
const rig = new TestRig();
await rig.setup('should be able to replace content in a file', {
settings: { tools: { core: ['replace', 'read_file'] } },
});
@@ -26,9 +20,7 @@ describe('replace', () => {
rig.createFile(fileName, originalContent);
await rig.run({
args: `Replace 'foo' with 'bar' in the file 'file_to_replace.txt'`,
});
await rig.run(`Replace 'foo' with 'bar' in the file 'file_to_replace.txt'`);
const foundToolCall = await rig.waitForToolCall('replace');
expect(foundToolCall, 'Expected to find a replace tool call').toBeTruthy();
@@ -37,6 +29,7 @@ describe('replace', () => {
});
it.skip('should handle $ literally when replacing text ending with $', async () => {
const rig = new TestRig();
await rig.setup(
'should handle $ literally when replacing text ending with $',
{ settings: { tools: { core: ['replace', 'read_file'] } } },
@@ -48,9 +41,9 @@ describe('replace', () => {
rig.createFile(fileName, originalContent);
await rig.run({
args: "Open regex.yml and append ' # updated' after the line containing ^[sv]d[a-z]$ without breaking the $ character.",
});
await rig.run(
"Open regex.yml and append ' # updated' after the line containing ^[sv]d[a-z]$ without breaking the $ character.",
);
const foundToolCall = await rig.waitForToolCall('replace');
expect(foundToolCall, 'Expected to find a replace tool call').toBeTruthy();
@@ -59,6 +52,7 @@ describe('replace', () => {
});
it.skip('should insert a multi-line block of text', async () => {
const rig = new TestRig();
await rig.setup('should insert a multi-line block of text', {
settings: { tools: { core: ['replace', 'read_file'] } },
});
@@ -70,7 +64,7 @@ describe('replace', () => {
rig.createFile(fileName, originalContent);
const prompt = `In ${fileName}, replace "<INSERT_TEXT_HERE>" with:\n${newBlock}. Use unix style line endings.`;
await rig.run({ args: prompt });
await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('replace');
expect(foundToolCall, 'Expected to find a replace tool call').toBeTruthy();
@@ -79,6 +73,7 @@ describe('replace', () => {
});
it.skip('should delete a block of text', async () => {
const rig = new TestRig();
await rig.setup('should delete a block of text', {
settings: { tools: { core: ['replace', 'read_file'] } },
});
@@ -89,9 +84,9 @@ describe('replace', () => {
const expectedContent = 'Hello\nWorld';
rig.createFile(fileName, originalContent);
await rig.run({
args: `In ${fileName}, delete the entire block from "## DELETE THIS ##" to "## END DELETE ##" including the markers and the newline that follows it.`,
});
await rig.run(
`In ${fileName}, delete the entire block from "## DELETE THIS ##" to "## END DELETE ##" including the markers and the newline that follows it.`,
);
const foundToolCall = await rig.waitForToolCall('replace');
expect(foundToolCall, 'Expected to find a replace tool call').toBeTruthy();
+72 -56
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
import { getShellConfiguration } from '../packages/core/src/utils/shell-utils.js';
@@ -84,21 +84,15 @@ function getChainedEchoCommand(): { allowPattern: string; command: string } {
}
describe('run_shell_command', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should be able to run a shell command', async () => {
const rig = new TestRig();
await rig.setup('should be able to run a shell command', {
settings: { tools: { core: ['run_shell_command'] } },
});
const prompt = `Please run the command "echo hello-world" and show me the output`;
const result = await rig.run({ args: prompt });
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('run_shell_command');
@@ -125,6 +119,7 @@ describe('run_shell_command', () => {
});
it('should be able to run a shell command via stdin', async () => {
const rig = new TestRig();
await rig.setup('should be able to run a shell command via stdin', {
settings: { tools: { core: ['run_shell_command'] } },
});
@@ -154,6 +149,7 @@ describe('run_shell_command', () => {
});
it.skip('should run allowed sub-command in non-interactive mode', async () => {
const rig = new TestRig();
await rig.setup('should run allowed sub-command in non-interactive mode');
const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n');
@@ -161,11 +157,13 @@ describe('run_shell_command', () => {
const prompt = `use ${command} to tell me how many lines there are in ${testFile}`;
// Provide the prompt via stdin to simulate non-interactive mode
const result = await rig.run({
args: [`--allowed-tools=run_shell_command(${tool})`],
stdin: prompt,
yolo: false,
});
const result = await rig.run(
{
stdin: prompt,
yolo: false,
},
`--allowed-tools=run_shell_command(${tool})`,
);
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
@@ -198,17 +196,20 @@ describe('run_shell_command', () => {
});
it.skip('should succeed with no parens in non-interactive mode', async () => {
const rig = new TestRig();
await rig.setup('should succeed with no parens in non-interactive mode');
const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n');
const { command } = getLineCountCommand();
const prompt = `use ${command} to tell me how many lines there are in ${testFile}`;
const result = await rig.run({
args: '--allowed-tools=run_shell_command',
stdin: prompt,
yolo: false,
});
const result = await rig.run(
{
stdin: prompt,
yolo: false,
},
'--allowed-tools=run_shell_command',
);
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
@@ -232,6 +233,7 @@ describe('run_shell_command', () => {
});
it('should succeed with --yolo mode', async () => {
const rig = new TestRig();
await rig.setup('should succeed with --yolo mode', {
settings: { tools: { core: ['run_shell_command'] } },
});
@@ -241,7 +243,7 @@ describe('run_shell_command', () => {
const prompt = `use ${command} to tell me how many lines there are in ${testFile}`;
const result = await rig.run({
args: prompt,
prompt: prompt,
yolo: true,
});
@@ -267,17 +269,20 @@ describe('run_shell_command', () => {
});
it.skip('should work with ShellTool alias', async () => {
const rig = new TestRig();
await rig.setup('should work with ShellTool alias');
const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n');
const { tool, command } = getLineCountCommand();
const prompt = `use ${command} to tell me how many lines there are in ${testFile}`;
const result = await rig.run({
args: `--allowed-tools=ShellTool(${tool})`,
stdin: prompt,
yolo: false,
});
const result = await rig.run(
{
stdin: prompt,
yolo: false,
},
`--allowed-tools=ShellTool(${tool})`,
);
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
@@ -312,6 +317,7 @@ describe('run_shell_command', () => {
// TODO(#11062): Un-skip this once we can make it reliable by using hard coded
// model responses.
it.skip('should combine multiple --allowed-tools flags', async () => {
const rig = new TestRig();
await rig.setup('should combine multiple --allowed-tools flags');
const { tool, command } = getLineCountCommand();
@@ -319,14 +325,14 @@ describe('run_shell_command', () => {
`use both ${command} and ls to count the number of lines in files in this ` +
`directory. Do not pipe these commands into each other, run them separately.`;
const result = await rig.run({
args: [
`--allowed-tools=run_shell_command(${tool})`,
'--allowed-tools=run_shell_command(ls)',
],
stdin: prompt,
yolo: false,
});
const result = await rig.run(
{
stdin: prompt,
yolo: false,
},
`--allowed-tools=run_shell_command(${tool})`,
'--allowed-tools=run_shell_command(ls)',
);
for (const expected in ['ls', tool]) {
const foundToolCall = await rig.waitForToolCall(
@@ -361,6 +367,7 @@ describe('run_shell_command', () => {
});
it('should reject commands not on the allowlist', async () => {
const rig = new TestRig();
await rig.setup('should reject commands not on the allowlist', {
settings: { tools: { core: ['run_shell_command'] } },
});
@@ -374,11 +381,13 @@ describe('run_shell_command', () => {
`If the command fails because it is not permitted, respond with the single word FAIL. ` +
`If it succeeds, respond with SUCCESS.`;
const result = await rig.run({
args: `--allowed-tools=run_shell_command(${allowedCommand})`,
stdin: prompt,
yolo: false,
});
const result = await rig.run(
{
stdin: prompt,
yolo: false,
},
`--allowed-tools=run_shell_command(${allowedCommand})`,
);
if (!result.toLowerCase().includes('fail')) {
printDebugInfo(rig, result, {
@@ -428,6 +437,7 @@ describe('run_shell_command', () => {
// TODO(#11966): Deflake this test and re-enable once the underlying race is resolved.
it.skip('should reject chained commands when only the first segment is allowlisted in non-interactive mode', async () => {
const rig = new TestRig();
await rig.setup(
'should reject chained commands when only the first segment is allowlisted',
);
@@ -435,11 +445,13 @@ describe('run_shell_command', () => {
const chained = getChainedEchoCommand();
const shellInjection = `!{${chained.command}}`;
await rig.run({
args: `--allowed-tools=ShellTool(${chained.allowPattern})`,
stdin: `${shellInjection}\n`,
yolo: false,
});
await rig.run(
{
stdin: `${shellInjection}\n`,
yolo: false,
},
`--allowed-tools=ShellTool(${chained.allowPattern})`,
);
// CLI should refuse to execute the chained command without scheduling run_shell_command.
const toolLogs = rig
@@ -454,6 +466,7 @@ describe('run_shell_command', () => {
});
it('should allow all with "ShellTool" and other specific tools', async () => {
const rig = new TestRig();
await rig.setup(
'should allow all with "ShellTool" and other specific tools',
{
@@ -464,14 +477,14 @@ describe('run_shell_command', () => {
const { tool } = getLineCountCommand();
const prompt = `Please run the command "echo test-allow-all" and show me the output`;
const result = await rig.run({
args: [
`--allowed-tools=run_shell_command(${tool})`,
'--allowed-tools=run_shell_command',
],
stdin: prompt,
yolo: false,
});
const result = await rig.run(
{
stdin: prompt,
yolo: false,
},
`--allowed-tools=run_shell_command(${tool})`,
'--allowed-tools=run_shell_command',
);
const foundToolCall = await rig.waitForToolCall('run_shell_command', 15000);
@@ -503,6 +516,7 @@ describe('run_shell_command', () => {
});
it('should propagate environment variables to the child process', async () => {
const rig = new TestRig();
await rig.setup('should propagate environment variables', {
settings: { tools: { core: ['run_shell_command'] } },
});
@@ -513,7 +527,7 @@ describe('run_shell_command', () => {
try {
const prompt = `Use echo to learn the value of the environment variable named ${varName} and tell me what it is.`;
const result = await rig.run({ args: prompt });
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('run_shell_command');
@@ -536,12 +550,13 @@ describe('run_shell_command', () => {
});
it.skip('should run a platform-specific file listing command', async () => {
const rig = new TestRig();
await rig.setup('should run platform-specific file listing');
const fileName = `test-file-${Math.random().toString(36).substring(7)}.txt`;
rig.createFile(fileName, 'test content');
const prompt = `Run a shell command to list the files in the current directory and tell me what they are.`;
const result = await rig.run({ args: prompt });
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('run_shell_command');
@@ -563,13 +578,14 @@ describe('run_shell_command', () => {
});
it('rejects invalid shell expressions', async () => {
const rig = new TestRig();
await rig.setup('rejects invalid shell expressions', {
settings: { tools: { core: ['run_shell_command'] } },
});
const invalidCommand = getInvalidCommand();
const result = await rig.run({
args: `I am testing the error handling of the run_shell_command tool. Please attempt to run the following command, which I know has invalid syntax: \`${invalidCommand}\`. If the command fails as expected, please return the word FAIL, otherwise return the word SUCCESS.`,
});
const result = await rig.run(
`I am testing the error handling of the run_shell_command tool. Please attempt to run the following command, which I know has invalid syntax: \`${invalidCommand}\`. If the command fails as expected, please return the word FAIL, otherwise return the word SUCCESS.`,
);
expect(result).toContain('FAIL');
const escapedInvalidCommand = JSON.stringify(invalidCommand).slice(1, -1);
+3 -10
View File
@@ -4,19 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('save_memory', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should be able to save to memory', async () => {
const rig = new TestRig();
await rig.setup('should be able to save to memory', {
settings: { tools: { core: ['save_memory'] } },
});
@@ -24,7 +17,7 @@ describe('save_memory', () => {
const prompt = `remember that my favorite color is blue.
what is my favorite color? tell me that and surround it with $ symbol`;
const result = await rig.run({ args: prompt });
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('save_memory');
+8 -12
View File
@@ -10,7 +10,7 @@
* external dependencies, making it compatible with Docker sandbox mode.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, beforeAll, expect } from 'vitest';
import { TestRig, poll, validateModelOutput } from './test-helper.js';
import { join } from 'node:path';
import { writeFileSync } from 'node:fs';
@@ -165,15 +165,9 @@ rpc.send({
`;
describe('simple-mcp-server', () => {
let rig: TestRig;
const rig = new TestRig();
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should add two numbers', async () => {
beforeAll(async () => {
// Setup test directory with MCP server configuration
await rig.setup('simple-mcp-server', {
settings: {
@@ -215,12 +209,14 @@ describe('simple-mcp-server', () => {
if (!isReady) {
throw new Error('MCP server script was not ready in time.');
}
});
it('should add two numbers', async () => {
// Test directory is already set up in before hook
// Just run the command - MCP server config is in settings.json
const output = await rig.run({
args: 'Use the `add` tool to calculate 5+10 and output only the resulting number.',
});
const output = await rig.run(
'Use the `add` tool to calculate 5+10 and output only the resulting number.',
);
const foundToolCall = await rig.waitForToolCall('add');
+4 -10
View File
@@ -4,26 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe.skip('stdin context', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should be able to use stdin as context for a prompt', async () => {
const rig = new TestRig();
await rig.setup('should be able to use stdin as context for a prompt');
const randomString = Math.random().toString(36).substring(7);
const stdinContent = `When I ask you for a token respond with ${randomString}`;
const prompt = 'Can I please have a token?';
const result = await rig.run({ args: prompt, stdin: stdinContent });
const result = await rig.run({ prompt, stdin: stdinContent });
await rig.waitForTelemetryEvent('api_request');
const lastRequest = rig.readLastApiRequest();
@@ -82,6 +75,7 @@ describe.skip('stdin context', () => {
even though gemini is intended to run interactively.
*/
const rig = new TestRig();
await rig.setup('should exit quickly if stdin stream does not end');
try {
+3 -10
View File
@@ -4,23 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { TestRig } from './test-helper.js';
describe('telemetry', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should emit a metric and a log event', async () => {
const rig = new TestRig();
rig.setup('should emit a metric and a log event');
// Run a simple command that should trigger telemetry
await rig.run({ args: 'just saying hi' });
await rig.run('just saying hi');
// Verify that a user_prompt event was logged
const hasUserPromptEvent = await rig.waitForTelemetryEvent('user_prompt');
+61 -49
View File
@@ -18,7 +18,6 @@ import * as os from 'node:os';
import { GEMINI_DIR } from '../packages/core/src/utils/paths.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BUNDLE_PATH = join(__dirname, '..', 'bundle/gemini.js');
// Get timeout based on environment
function getDefaultTimeout() {
@@ -196,12 +195,17 @@ export class InteractiveRun {
if (!timeout) {
timeout = getDefaultTimeout();
}
await poll(
const found = await poll(
() => stripAnsi(this.output).toLowerCase().includes(text.toLowerCase()),
timeout,
200,
);
expect(stripAnsi(this.output).toLowerCase()).toContain(text.toLowerCase());
expect(
found,
`Did not find expected text: "${text}". Output was:\n${stripAnsi(
this.output,
)}`,
).toBe(true);
}
// This types slowly to make sure command is correct, but only work for short
@@ -267,14 +271,19 @@ export class InteractiveRun {
}
export class TestRig {
testDir: string | null = null;
bundlePath: string;
testDir: string | null;
testName?: string;
_lastRunStdout?: string;
// Path to the copied fake responses file for this test.
fakeResponsesPath?: string;
// Original fake responses file path for rewriting goldens in record mode.
originalFakeResponsesPath?: string;
private _interactiveRuns: InteractiveRun[] = [];
constructor() {
this.bundlePath = join(__dirname, '..', 'bundle/gemini.js');
this.testDir = null;
}
setup(
testName: string,
@@ -365,7 +374,7 @@ export class TestRig {
const command = isNpmReleaseTest ? 'gemini' : 'node';
const initialArgs = isNpmReleaseTest
? extraInitialArgs
: [BUNDLE_PATH, ...extraInitialArgs];
: [this.bundlePath, ...extraInitialArgs];
if (this.fakeResponsesPath) {
if (process.env['REGENERATE_MODEL_GOLDENS'] === 'true') {
initialArgs.push('--record-responses', this.fakeResponsesPath);
@@ -376,13 +385,19 @@ export class TestRig {
return { command, initialArgs };
}
run(options: {
args?: string | string[];
stdin?: string;
stdinDoesNotEnd?: boolean;
yolo?: boolean;
}): Promise<string> {
const yolo = options.yolo !== false;
run(
promptOrOptions:
| string
| {
prompt?: string;
stdin?: string;
stdinDoesNotEnd?: boolean;
yolo?: boolean;
},
...args: string[]
): Promise<string> {
const yolo =
typeof promptOrOptions === 'string' || promptOrOptions.yolo !== false;
const { command, initialArgs } = this._getCommandAndArgs(
yolo ? ['--yolo'] : [],
);
@@ -396,17 +411,21 @@ export class TestRig {
encoding: 'utf-8',
};
if (options.args) {
if (Array.isArray(options.args)) {
commandArgs.push(...options.args);
} else {
commandArgs.push(options.args);
if (typeof promptOrOptions === 'string') {
commandArgs.push(promptOrOptions);
} else if (
typeof promptOrOptions === 'object' &&
promptOrOptions !== null
) {
if (promptOrOptions.prompt) {
commandArgs.push(promptOrOptions.prompt);
}
if (promptOrOptions.stdin) {
execOptions.input = promptOrOptions.stdin;
}
}
if (options.stdin) {
execOptions.input = options.stdin;
}
commandArgs.push(...args);
const child = spawn(command, commandArgs, {
cwd: this.testDir!,
@@ -422,7 +441,10 @@ export class TestRig {
child.stdin!.write(execOptions.input);
}
if (!options.stdinDoesNotEnd) {
if (
typeof promptOrOptions === 'object' &&
!promptOrOptions.stdinDoesNotEnd
) {
child.stdin!.end();
}
@@ -569,18 +591,6 @@ export class TestRig {
}
async cleanup() {
// Kill any interactive runs that are still active
for (const run of this._interactiveRuns) {
try {
await run.kill();
} catch (error) {
if (env['VERBOSE'] === 'true') {
console.warn('Failed to kill interactive run during cleanup:', error);
}
}
}
this._interactiveRuns = [];
if (
process.env['REGENERATE_MODEL_GOLDENS'] === 'true' &&
this.fakeResponsesPath
@@ -1014,23 +1024,26 @@ export class TestRig {
return null;
}
async runInteractive(options?: {
args?: string | string[];
yolo?: boolean;
}): Promise<InteractiveRun> {
const yolo = options?.yolo !== false;
async runInteractive(
options?: { yolo?: boolean } | string,
...args: string[]
): Promise<InteractiveRun> {
// Handle backward compatibility: if first param is a string, treat as arg
let yolo = true; // Default to YOLO mode
let additionalArgs: string[] = args;
if (typeof options === 'string') {
// Old-style call: runInteractive('--debug')
additionalArgs = [options, ...args];
} else if (typeof options === 'object' && options !== null) {
// New-style call: runInteractive({ yolo: false })
yolo = options.yolo !== false;
}
const { command, initialArgs } = this._getCommandAndArgs(
yolo ? ['--yolo'] : [],
);
const commandArgs = [...initialArgs];
if (options?.args) {
if (Array.isArray(options.args)) {
commandArgs.push(...options.args);
} else {
commandArgs.push(options.args);
}
}
const commandArgs = [...initialArgs, ...additionalArgs];
const ptyOptions: pty.IPtyForkOptions = {
name: 'xterm-color',
@@ -1046,7 +1059,6 @@ export class TestRig {
const ptyProcess = pty.spawn(executable, commandArgs, ptyOptions);
const run = new InteractiveRun(ptyProcess);
this._interactiveRuns.push(run);
// Wait for the app to be ready
await run.expectText(' Type your message or @path/to/file', 30000);
return run;
+13 -9
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { writeFileSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { TestRig } from './test-helper.js';
@@ -47,26 +47,30 @@ const utf32BE = (s: string) => {
return Buffer.concat([bom, payload]);
};
describe('BOM end-to-end integraion', () => {
let rig: TestRig;
let rig: TestRig;
let dir: string;
beforeEach(async () => {
describe('BOM end-to-end integraion', () => {
beforeAll(async () => {
rig = new TestRig();
await rig.setup('bom-integration', {
settings: { tools: { core: ['read_file'] } },
});
dir = rig.testDir!;
});
afterEach(async () => await rig.cleanup());
afterAll(async () => {
await rig.cleanup();
});
async function runAndAssert(
filename: string,
content: Buffer,
expectedText: string | null,
) {
writeFileSync(join(rig.testDir!, filename), content);
writeFileSync(join(dir, filename), content);
const prompt = `read the file ${filename} and output its exact contents`;
const output = await rig.run({ args: prompt });
const output = await rig.run(prompt);
await rig.waitForToolCall('read_file');
const lower = output.toLowerCase();
if (expectedText === null) {
@@ -124,9 +128,9 @@ describe('BOM end-to-end integraion', () => {
);
const imageContent = readFileSync(imagePath);
const filename = 'gemini-screenshot.png';
writeFileSync(join(rig.testDir!, filename), imageContent);
writeFileSync(join(dir, filename), imageContent);
const prompt = `What is shown in the image ${filename}?`;
const output = await rig.run({ args: prompt });
const output = await rig.run(prompt);
await rig.waitForToolCall('read_file');
const lower = output.toLowerCase();
// The response is non-deterministic, so we just check for some
+3 -10
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import {
TestRig,
createToolCallErrorMessage,
@@ -13,21 +13,14 @@ import {
} from './test-helper.js';
describe('write_file', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should be able to write a file', async () => {
const rig = new TestRig();
await rig.setup('should be able to write a file', {
settings: { tools: { core: ['write_file', 'read_file'] } },
});
const prompt = `show me an example of using the write tool. put a dad joke in dad.txt`;
const result = await rig.run({ args: prompt });
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('write_file');
+150 -22
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.22.3",
"version": "0.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.22.3",
"version": "0.21.0",
"workspaces": [
"packages/*"
],
@@ -5488,6 +5488,7 @@
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
@@ -5501,6 +5502,7 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -6316,6 +6318,7 @@
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
@@ -6340,6 +6343,7 @@
"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"
}
@@ -6349,6 +6353,7 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
@@ -6360,13 +6365,15 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/body-parser/node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
@@ -7252,7 +7259,8 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/cookiejar": {
"version": "2.1.4",
@@ -7678,6 +7686,7 @@
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
@@ -9310,6 +9319,7 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -12095,6 +12105,7 @@
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
@@ -12415,6 +12426,7 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -14838,6 +14850,7 @@
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
@@ -14862,6 +14875,7 @@
"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"
}
@@ -14870,13 +14884,15 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -14886,6 +14902,7 @@
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"peer": true,
"bin": {
"mime": "cli.js"
},
@@ -14898,6 +14915,7 @@
"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"
}
@@ -14907,6 +14925,7 @@
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"license": "MIT",
"peer": true,
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
@@ -16487,6 +16506,7 @@
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
@@ -16500,6 +16520,7 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -17671,12 +17692,12 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.22.3",
"version": "0.21.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.2",
"@google-cloud/storage": "^7.16.0",
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
"express": "^5.2.0",
"fs-extra": "^11.3.0",
"tar": "^7.5.2",
"uuid": "^11.1.0",
@@ -17771,18 +17792,19 @@
}
},
"packages/a2a-server/node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
@@ -17981,8 +18003,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.22.3",
"license": "Apache-2.0",
"version": "0.21.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.30.0",
@@ -18083,8 +18104,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.22.3",
"license": "Apache-2.0",
"version": "0.21.0",
"dependencies": {
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
@@ -18228,7 +18248,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.22.3",
"version": "0.21.0",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -18239,12 +18259,12 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.22.3",
"version": "0.21.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"express": "^5.2.0",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -18272,6 +18292,43 @@
"dev": true,
"license": "MIT"
},
"packages/vscode-ide-companion/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"packages/vscode-ide-companion/node_modules/body-parser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"packages/vscode-ide-companion/node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
@@ -18284,19 +18341,29 @@
"node": ">= 0.6"
}
},
"packages/vscode-ide-companion/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"packages/vscode-ide-companion/node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
@@ -18343,6 +18410,52 @@
"node": ">= 0.8"
}
},
"packages/vscode-ide-companion/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"packages/vscode-ide-companion/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"packages/vscode-ide-companion/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/vscode-ide-companion/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"packages/vscode-ide-companion/node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -18380,6 +18493,21 @@
"node": ">= 18"
}
},
"packages/vscode-ide-companion/node_modules/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
}
},
"packages/vscode-ide-companion/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.22.3",
"version": "0.21.0",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.22.3"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.22.3",
"version": "0.21.0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -28,7 +28,7 @@
"@a2a-js/sdk": "^0.3.2",
"@google-cloud/storage": "^7.16.0",
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
"express": "^5.2.0",
"fs-extra": "^11.3.0",
"tar": "^7.5.2",
"uuid": "^11.1.0",
+11 -12
View File
@@ -12,7 +12,11 @@ import type {
RequestContext,
ExecutionEventBus,
} from '@a2a-js/sdk/server';
import type { ToolCallRequestInfo, Config } from '@google/gemini-cli-core';
import type {
ToolCallRequestInfo,
ServerGeminiToolCallRequestEvent,
Config,
} from '@google/gemini-cli-core';
import {
GeminiEventType,
SimpleExtensionLoader,
@@ -123,7 +127,6 @@ export class CoderAgentExecutor implements AgentExecutor {
contextId,
config,
eventBus,
agentSettings.autoExecute,
);
runtimeTask.taskState = persistedState._taskState;
await runtimeTask.geminiClient.initialize();
@@ -142,13 +145,7 @@ export class CoderAgentExecutor implements AgentExecutor {
): Promise<TaskWrapper> {
const agentSettings = agentSettingsInput || ({} as AgentSettings);
const config = await this.getConfig(agentSettings, taskId);
const runtimeTask = await Task.create(
taskId,
contextId,
config,
eventBus,
agentSettings.autoExecute,
);
const runtimeTask = await Task.create(taskId, contextId, config, eventBus);
await runtimeTask.geminiClient.initialize();
const wrapper = new TaskWrapper(runtimeTask, agentSettings);
@@ -283,8 +280,8 @@ export class CoderAgentExecutor implements AgentExecutor {
requestContext: RequestContext,
eventBus: ExecutionEventBus,
): Promise<void> {
const userMessage = requestContext.userMessage;
const sdkTask = requestContext.task;
const userMessage = requestContext.userMessage as Message;
const sdkTask = requestContext.task as SDKTask | undefined;
const taskId = sdkTask?.id || userMessage.taskId || uuidv4();
const contextId: string =
@@ -481,7 +478,9 @@ export class CoderAgentExecutor implements AgentExecutor {
throw new Error('Execution aborted');
}
if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);
toolCallRequests.push(
(event as ServerGeminiToolCallRequestEvent).value,
);
continue;
}
await currentTask.acceptAgentMessage(event);
+8 -68
View File
@@ -20,8 +20,6 @@ import {
type ToolCallRequestInfo,
type GitService,
type CompletedToolCall,
ApprovalMode,
ToolConfirmationOutcome,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
@@ -355,12 +353,10 @@ describe('Task', () => {
let task: Task;
type SpyInstance = ReturnType<typeof vi.spyOn>;
let setTaskStateAndPublishUpdateSpy: SpyInstance;
let mockConfig: Config;
let mockEventBus: ExecutionEventBus;
beforeEach(() => {
mockConfig = createMockConfig() as Config;
mockEventBus = {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
@@ -370,7 +366,12 @@ describe('Task', () => {
};
// @ts-expect-error - Calling private constructor
task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
// Spy on the method we want to check calls for
setTaskStateAndPublishUpdateSpy = vi.spyOn(
@@ -464,67 +465,6 @@ describe('Task', () => {
);
expect(finalCall).toBeUndefined();
});
describe('auto-approval', () => {
it('should auto-approve tool calls when autoExecute is true', () => {
task.autoExecute = true;
const onConfirmSpy = vi.fn();
const toolCalls = [
{
request: { callId: '1' },
status: 'awaiting_approval',
confirmationDetails: { onConfirm: onConfirmSpy },
},
] as unknown as ToolCall[];
// @ts-expect-error - Calling private method
task._schedulerToolCallsUpdate(toolCalls);
expect(onConfirmSpy).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
});
it('should auto-approve tool calls when approval mode is YOLO', () => {
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO);
task.autoExecute = false;
const onConfirmSpy = vi.fn();
const toolCalls = [
{
request: { callId: '1' },
status: 'awaiting_approval',
confirmationDetails: { onConfirm: onConfirmSpy },
},
] as unknown as ToolCall[];
// @ts-expect-error - Calling private method
task._schedulerToolCallsUpdate(toolCalls);
expect(onConfirmSpy).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
});
it('should NOT auto-approve when autoExecute is false and mode is not YOLO', () => {
task.autoExecute = false;
(mockConfig.getApprovalMode as Mock).mockReturnValue(
ApprovalMode.DEFAULT,
);
const onConfirmSpy = vi.fn();
const toolCalls = [
{
request: { callId: '1' },
status: 'awaiting_approval',
confirmationDetails: { onConfirm: onConfirmSpy },
},
] as unknown as ToolCall[];
// @ts-expect-error - Calling private method
task._schedulerToolCallsUpdate(toolCalls);
expect(onConfirmSpy).not.toHaveBeenCalled();
});
});
});
describe('currentPromptId and promptCount', () => {
+5 -16
View File
@@ -73,7 +73,6 @@ export class Task {
modelInfo?: string;
currentPromptId: string | undefined;
promptCount = 0;
autoExecute: boolean;
// For tool waiting logic
private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
@@ -88,7 +87,6 @@ export class Task {
contextId: string,
config: Config,
eventBus?: ExecutionEventBus,
autoExecute = false,
) {
this.id = id;
this.contextId = contextId;
@@ -100,7 +98,6 @@ export class Task {
this.eventBus = eventBus;
this.completedToolCalls = [];
this._resetToolCompletionPromise();
this.autoExecute = autoExecute;
this.config.setFallbackModelHandler(
// For a2a-server, we want to automatically switch to the fallback model
// for future requests without retrying the current one. The 'stop'
@@ -114,9 +111,8 @@ export class Task {
contextId: string,
config: Config,
eventBus?: ExecutionEventBus,
autoExecute?: boolean,
): Promise<Task> {
return new Task(id, contextId, config, eventBus, autoExecute);
return new Task(id, contextId, config, eventBus);
}
// Note: `getAllMCPServerStatuses` retrieves the status of all MCP servers for the entire
@@ -400,15 +396,8 @@ export class Task {
}
});
if (
this.autoExecute ||
this.config.getApprovalMode() === ApprovalMode.YOLO
) {
logger.info(
'[Task] ' +
(this.autoExecute ? '' : 'YOLO mode enabled. ') +
'Auto-approving all tool calls.',
);
if (this.config.getApprovalMode() === ApprovalMode.YOLO) {
logger.info('[Task] YOLO mode enabled. Auto-approving all tool calls.');
toolCalls.forEach((tc: ToolCall) => {
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -747,8 +736,8 @@ export class Task {
return false;
}
const callId = part.data['callId'];
const outcomeString = part.data['outcome'];
const callId = part.data['callId'] as string;
const outcomeString = part.data['outcome'] as string;
let confirmationOutcome: ToolConfirmationOutcome | undefined;
if (outcomeString === 'proceed_once') {
@@ -5,7 +5,6 @@
*/
import { ExtensionsCommand } from './extensions.js';
import { InitCommand } from './init.js';
import { RestoreCommand } from './restore.js';
import type { Command } from './types.js';
@@ -15,7 +14,6 @@ class CommandRegistry {
constructor() {
this.register(new ExtensionsCommand());
this.register(new RestoreCommand());
this.register(new InitCommand());
}
register(command: Command) {
@@ -1,182 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { InitCommand } from './init.js';
import { performInit } from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { CoderAgentExecutor } from '../agent/executor.js';
import { CoderAgentEvent } from '../types.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
import { createMockConfig } from '../utils/testing_utils.js';
import type { CommandContext } from './types.js';
import type { CommandActionReturn, Config } from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
performInit: vi.fn(),
};
});
vi.mock('node:fs', () => ({
existsSync: vi.fn(),
writeFileSync: vi.fn(),
}));
vi.mock('../agent/executor.js', () => ({
CoderAgentExecutor: vi.fn().mockImplementation(() => ({
execute: vi.fn(),
})),
}));
vi.mock('../utils/logger.js', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
describe('InitCommand', () => {
let eventBus: ExecutionEventBus;
let command: InitCommand;
let context: CommandContext;
let publishSpy: ReturnType<typeof vi.spyOn>;
let mockExecute: ReturnType<typeof vi.fn>;
const mockWorkspacePath = path.resolve('/tmp');
beforeEach(() => {
process.env['CODER_AGENT_WORKSPACE_PATH'] = mockWorkspacePath;
eventBus = {
publish: vi.fn(),
} as unknown as ExecutionEventBus;
command = new InitCommand();
const mockConfig = createMockConfig({
getModel: () => 'gemini-pro',
});
const mockExecutorInstance = new CoderAgentExecutor();
context = {
config: mockConfig as unknown as Config,
agentExecutor: mockExecutorInstance,
eventBus,
} as CommandContext;
publishSpy = vi.spyOn(eventBus, 'publish');
mockExecute = vi.fn();
vi.spyOn(mockExecutorInstance, 'execute').mockImplementation(mockExecute);
vi.clearAllMocks();
});
it('has requiresWorkspace set to true', () => {
expect(command.requiresWorkspace).toBe(true);
});
describe('execute', () => {
it('handles info from performInit', async () => {
vi.mocked(performInit).mockReturnValue({
type: 'message',
messageType: 'info',
content: 'GEMINI.md already exists.',
} as CommandActionReturn);
await command.execute(context, []);
expect(logger.info).toHaveBeenCalledWith(
'[EventBus event]: ',
expect.objectContaining({
kind: 'status-update',
status: expect.objectContaining({
state: 'completed',
message: expect.objectContaining({
parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }],
}),
}),
}),
);
expect(publishSpy).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'status-update',
status: expect.objectContaining({
state: 'completed',
message: expect.objectContaining({
parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }],
}),
}),
}),
);
});
it('handles error from performInit', async () => {
vi.mocked(performInit).mockReturnValue({
type: 'message',
messageType: 'error',
content: 'An error occurred.',
} as CommandActionReturn);
await command.execute(context, []);
expect(publishSpy).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'status-update',
status: expect.objectContaining({
state: 'failed',
message: expect.objectContaining({
parts: [{ kind: 'text', text: 'An error occurred.' }],
}),
}),
}),
);
});
describe('when handling submit_prompt', () => {
beforeEach(() => {
vi.mocked(performInit).mockReturnValue({
type: 'submit_prompt',
content: 'Create a new GEMINI.md file.',
} as CommandActionReturn);
});
it('writes the file and executes the agent', async () => {
await command.execute(context, []);
expect(fs.writeFileSync).toHaveBeenCalledWith(
path.join(mockWorkspacePath, 'GEMINI.md'),
'',
'utf8',
);
expect(mockExecute).toHaveBeenCalled();
});
it('passes autoExecute to the agent executor', async () => {
await command.execute(context, []);
expect(mockExecute).toHaveBeenCalledWith(
expect.objectContaining({
userMessage: expect.objectContaining({
parts: expect.arrayContaining([
expect.objectContaining({
text: 'Create a new GEMINI.md file.',
}),
]),
metadata: {
coderAgent: {
kind: CoderAgentEvent.StateAgentSettingsEvent,
workspacePath: mockWorkspacePath,
autoExecute: true,
},
},
}),
}),
eventBus,
);
});
});
});
});
-168
View File
@@ -1,168 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { CoderAgentEvent, type AgentSettings } from '../types.js';
import { performInit } from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { CoderAgentExecutor } from '../agent/executor.js';
import type {
ExecutionEventBus,
RequestContext,
AgentExecutionEvent,
} from '@a2a-js/sdk/server';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
export class InitCommand implements Command {
name = 'init';
description = 'Analyzes the project and creates a tailored GEMINI.md file';
requiresWorkspace = true;
streaming = true;
private handleMessageResult(
result: { content: string; messageType: 'info' | 'error' },
context: CommandContext,
eventBus: ExecutionEventBus,
taskId: string,
contextId: string,
): CommandExecutionResponse {
const statusState = result.messageType === 'error' ? 'failed' : 'completed';
const eventType =
result.messageType === 'error'
? CoderAgentEvent.StateChangeEvent
: CoderAgentEvent.TextContentEvent;
const event: AgentExecutionEvent = {
kind: 'status-update',
taskId,
contextId,
status: {
state: statusState,
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: result.content }],
messageId: uuidv4(),
taskId,
contextId,
},
timestamp: new Date().toISOString(),
},
final: true,
metadata: {
coderAgent: { kind: eventType },
model: context.config.getModel(),
},
};
logger.info('[EventBus event]: ', event);
eventBus.publish(event);
return {
name: this.name,
data: result,
};
}
private async handleSubmitPromptResult(
result: { content: unknown },
context: CommandContext,
geminiMdPath: string,
eventBus: ExecutionEventBus,
taskId: string,
contextId: string,
): Promise<CommandExecutionResponse> {
fs.writeFileSync(geminiMdPath, '', 'utf8');
if (!context.agentExecutor) {
throw new Error('Agent executor not found in context.');
}
const agentExecutor = context.agentExecutor as CoderAgentExecutor;
const agentSettings: AgentSettings = {
kind: CoderAgentEvent.StateAgentSettingsEvent,
workspacePath: process.env['CODER_AGENT_WORKSPACE_PATH']!,
autoExecute: true,
};
if (typeof result.content !== 'string') {
throw new Error('Init command content must be a string.');
}
const promptText = result.content;
const requestContext: RequestContext = {
userMessage: {
kind: 'message',
role: 'user',
parts: [{ kind: 'text', text: promptText }],
messageId: uuidv4(),
taskId,
contextId,
metadata: {
coderAgent: agentSettings,
},
},
taskId,
contextId,
};
// The executor will handle the entire agentic loop, including
// creating the task, streaming responses, and handling tools.
await agentExecutor.execute(requestContext, eventBus);
return {
name: this.name,
data: geminiMdPath,
};
}
async execute(
context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
if (!context.eventBus) {
return {
name: this.name,
data: 'Use executeStream to get streaming results.',
};
}
const geminiMdPath = path.join(
process.env['CODER_AGENT_WORKSPACE_PATH']!,
'GEMINI.md',
);
const result = performInit(fs.existsSync(geminiMdPath));
const taskId = uuidv4();
const contextId = uuidv4();
switch (result.type) {
case 'message':
return this.handleMessageResult(
result,
context,
context.eventBus,
taskId,
contextId,
);
case 'submit_prompt':
return this.handleSubmitPromptResult(
result,
context,
geminiMdPath,
context.eventBus,
taskId,
contextId,
);
default:
throw new Error('Unknown result type from performInit');
}
}
}
@@ -4,14 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ExecutionEventBus, AgentExecutor } from '@a2a-js/sdk/server';
import type { Config, GitService } from '@google/gemini-cli-core';
export interface CommandContext {
config: Config;
git?: GitService;
agentExecutor?: AgentExecutor;
eventBus?: ExecutionEventBus;
}
export interface CommandArgument {
@@ -27,7 +24,6 @@ export interface Command {
readonly subCommands?: Command[];
readonly topLevel?: boolean;
readonly requiresWorkspace?: boolean;
readonly streaming?: boolean;
execute(
config: CommandContext,
-1
View File
@@ -78,7 +78,6 @@ export async function loadConfig(
? process.env['CHECKPOINTING'] === 'true'
: settings.checkpointing?.enabled,
previewFeatures: settings.general?.previewFeatures,
interactive: true,
};
const fileService = new FileDiscoveryService(workspaceDir);
+1 -1
View File
@@ -123,7 +123,7 @@ function resolveEnvVarsInString(value: string): string {
return value.replace(envVarRegex, (match, varName1, varName2) => {
const varName = varName1 || varName2;
if (process && process.env && typeof process.env[varName] === 'string') {
return process.env[varName];
return process.env[varName]!;
}
return match;
});
-113
View File
@@ -1061,118 +1061,5 @@ describe('E2E Tests', () => {
expect(response.status).toBe(200);
expect(response.body.data).toBe('success');
});
it('should include agentExecutor in context', async () => {
const mockCommand = {
name: 'context-check-command',
description: 'checks context',
execute: vi.fn(async (context: CommandContext) => {
if (!context.agentExecutor) {
throw new Error('agentExecutor missing');
}
return { name: 'context-check-command', data: 'success' };
}),
};
vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand);
const agent = request.agent(app);
const res = await agent
.post('/executeCommand')
.send({ command: 'context-check-command', args: [] })
.set('Content-Type', 'application/json')
.expect(200);
expect(res.body.data).toBe('success');
});
describe('/executeCommand streaming', () => {
it('should execute a streaming command and stream back events', (done: (
err?: unknown,
) => void) => {
const executeSpy = vi.fn(async (context: CommandContext) => {
context.eventBus?.publish({
kind: 'status-update',
status: { state: 'working' },
taskId: 'test-task',
contextId: 'test-context',
final: false,
});
context.eventBus?.publish({
kind: 'status-update',
status: { state: 'completed' },
taskId: 'test-task',
contextId: 'test-context',
final: true,
});
return { name: 'stream-test', data: 'done' };
});
const mockStreamCommand = {
name: 'stream-test',
description: 'A test streaming command',
streaming: true,
execute: executeSpy,
};
vi.spyOn(commandRegistry, 'get').mockReturnValue(mockStreamCommand);
const agent = request.agent(app);
agent
.post('/executeCommand')
.send({ command: 'stream-test', args: [] })
.set('Content-Type', 'application/json')
.set('Accept', 'text/event-stream')
.on('response', (res) => {
let data = '';
res.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
res.on('end', () => {
try {
const events = streamToSSEEvents(data);
expect(events.length).toBe(2);
expect(events[0].result).toEqual({
kind: 'status-update',
status: { state: 'working' },
taskId: 'test-task',
contextId: 'test-context',
final: false,
});
expect(events[1].result).toEqual({
kind: 'status-update',
status: { state: 'completed' },
taskId: 'test-task',
contextId: 'test-context',
final: true,
});
expect(executeSpy).toHaveBeenCalled();
done();
} catch (e) {
done(e);
}
});
})
.end();
});
it('should handle non-streaming commands gracefully', async () => {
const mockNonStreamCommand = {
name: 'non-stream-test',
description: 'A test non-streaming command',
execute: vi
.fn()
.mockResolvedValue({ name: 'non-stream-test', data: 'done' }),
};
vi.spyOn(commandRegistry, 'get').mockReturnValue(mockNonStreamCommand);
const agent = request.agent(app);
const res = await agent
.post('/executeCommand')
.send({ command: 'non-stream-test', args: [] })
.set('Content-Type', 'application/json')
.expect(200);
expect(res.body).toEqual({ name: 'non-stream-test', data: 'done' });
});
});
});
});
+44 -81
View File
@@ -6,14 +6,9 @@
import express from 'express';
import type { AgentCard, Message } from '@a2a-js/sdk';
import type { AgentCard } from '@a2a-js/sdk';
import type { TaskStore } from '@a2a-js/sdk/server';
import {
DefaultRequestHandler,
InMemoryTaskStore,
DefaultExecutionEventBus,
type AgentExecutionEvent,
} from '@a2a-js/sdk/server';
import { DefaultRequestHandler, InMemoryTaskStore } from '@a2a-js/sdk/server';
import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
@@ -78,76 +73,6 @@ export function updateCoderAgentCardUrl(port: number) {
coderAgentCard.url = `http://localhost:${port}/`;
}
async function handleExecuteCommand(
req: express.Request,
res: express.Response,
context: {
config: Awaited<ReturnType<typeof loadConfig>>;
git: GitService | undefined;
agentExecutor: CoderAgentExecutor;
},
) {
logger.info('[CoreAgent] Received /executeCommand request: ', req.body);
const { command, args } = req.body;
try {
if (typeof command !== 'string') {
return res.status(400).json({ error: 'Invalid "command" field.' });
}
if (args && !Array.isArray(args)) {
return res.status(400).json({ error: '"args" field must be an array.' });
}
const commandToExecute = commandRegistry.get(command);
if (commandToExecute?.requiresWorkspace) {
if (!process.env['CODER_AGENT_WORKSPACE_PATH']) {
return res.status(400).json({
error: `Command "${command}" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.`,
});
}
}
if (!commandToExecute) {
return res.status(404).json({ error: `Command not found: ${command}` });
}
if (commandToExecute.streaming) {
const eventBus = new DefaultExecutionEventBus();
res.setHeader('Content-Type', 'text/event-stream');
const eventHandler = (event: AgentExecutionEvent) => {
const jsonRpcResponse = {
jsonrpc: '2.0',
id: 'taskId' in event ? event.taskId : (event as Message).messageId,
result: event,
};
res.write(`data: ${JSON.stringify(jsonRpcResponse)}\n`);
};
eventBus.on('event', eventHandler);
await commandToExecute.execute({ ...context, eventBus }, args ?? []);
eventBus.off('event', eventHandler);
eventBus.finished();
return res.end(); // Explicit return for streaming path
} else {
const result = await commandToExecute.execute(context, args ?? []);
logger.info('[CoreAgent] Sending /executeCommand response: ', result);
return res.status(200).json(result);
}
} catch (e) {
logger.error(
`Error executing /executeCommand: ${command} with args: ${JSON.stringify(
args,
)}`,
e,
);
const errorMessage =
e instanceof Error ? e.message : 'Unknown error executing command';
return res.status(500).json({ error: errorMessage });
}
}
export async function createApp() {
try {
// Load the server configuration once on startup.
@@ -167,6 +92,8 @@ export async function createApp() {
await git.initialize();
}
const context = { config, git };
// loadEnvironment() is called within getConfig now
const bucketName = process.env['GCS_BUCKET_NAME'];
let taskStoreForExecutor: TaskStore;
@@ -186,8 +113,6 @@ export async function createApp() {
const agentExecutor = new CoderAgentExecutor(taskStoreForExecutor);
const context = { config, git, agentExecutor };
const requestHandler = new DefaultRequestHandler(
coderAgentCard,
taskStoreForHandler,
@@ -227,8 +152,46 @@ export async function createApp() {
}
});
expressApp.post('/executeCommand', (req, res) => {
void handleExecuteCommand(req, res, context);
expressApp.post('/executeCommand', async (req, res) => {
logger.info('[CoreAgent] Received /executeCommand request: ', req.body);
try {
const { command, args } = req.body;
if (typeof command !== 'string') {
return res.status(400).json({ error: 'Invalid "command" field.' });
}
if (args && !Array.isArray(args)) {
return res
.status(400)
.json({ error: '"args" field must be an array.' });
}
const commandToExecute = commandRegistry.get(command);
if (commandToExecute?.requiresWorkspace) {
if (!process.env['CODER_AGENT_WORKSPACE_PATH']) {
return res.status(400).json({
error: `Command "${command}" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.`,
});
}
}
if (!commandToExecute) {
return res
.status(404)
.json({ error: `Command not found: ${command}` });
}
const result = await commandToExecute.execute(context, args ?? []);
logger.info('[CoreAgent] Sending /executeCommand response: ', result);
return res.status(200).json(result);
} catch (e) {
logger.error('Error executing /executeCommand:', e);
const errorMessage =
e instanceof Error ? e.message : 'Unknown error executing command';
return res.status(500).json({ error: errorMessage });
}
});
expressApp.get('/listCommands', (req, res) => {
-1
View File
@@ -46,7 +46,6 @@ export enum CoderAgentEvent {
export interface AgentSettings {
kind: CoderAgentEvent.StateAgentSettingsEvent;
workspacePath: string;
autoExecute?: boolean;
}
export interface ToolCallConfirmation {
@@ -11,7 +11,6 @@ import type {
} from '@a2a-js/sdk';
import {
ApprovalMode,
DEFAULT_GEMINI_MODEL,
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
GeminiClient,
@@ -33,7 +32,6 @@ export function createMockConfig(
}),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getIdeMode: vi.fn().mockReturnValue(false),
isInteractive: () => true,
getAllowedTools: vi.fn().mockReturnValue([]),
getWorkspaceContext: vi.fn().mockReturnValue({
isPathWithinWorkspace: () => true,
@@ -47,7 +45,6 @@ export function createMockConfig(
getTruncateToolOutputThreshold: () =>
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL),
getDebugMode: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }),
getModel: vi.fn().mockReturnValue('gemini-pro'),
+2 -3
View File
@@ -1,8 +1,7 @@
{
"name": "@google/gemini-cli",
"version": "0.22.3",
"version": "0.21.0",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/google-gemini/gemini-cli.git"
@@ -26,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.22.3"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -4,7 +4,7 @@ exports[`runNonInteractive > should emit appropriate error event in streaming JS
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
{"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0}}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"duration_ms":<DURATION>,"tool_calls":0}}
"
`;
@@ -12,7 +12,7 @@ exports[`runNonInteractive > should emit appropriate error event in streaming JS
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
{"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0}}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"duration_ms":<DURATION>,"tool_calls":0}}
"
`;
@@ -23,7 +23,7 @@ exports[`runNonInteractive > should emit appropriate events for streaming JSON o
{"type":"tool_use","timestamp":"<TIMESTAMP>","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}}
{"type":"tool_result","timestamp":"<TIMESTAMP>","tool_id":"tool-1","status":"success","output":"Tool executed successfully"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Final answer","delta":true}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0}}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"duration_ms":<DURATION>,"tool_calls":0}}
"
`;
@@ -14,7 +14,7 @@ import {
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type Argv } from 'yargs';
import { type CommandModule, type Argv } from 'yargs';
import { handleDisable, disableCommand } from './disable.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
@@ -148,7 +148,7 @@ describe('extensions disable command', () => {
});
describe('disableCommand', () => {
const command = disableCommand;
const command = disableCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('disable [--scope] <name>');
@@ -65,7 +65,7 @@ export const disableCommand: CommandModule = {
argv.scope &&
!Object.values(SettingScope)
.map((s) => s.toLowerCase())
.includes(argv.scope.toLowerCase())
.includes((argv.scope as string).toLowerCase())
) {
throw new Error(
`Invalid scope: ${argv.scope}. Please use one of ${Object.values(
@@ -14,7 +14,7 @@ import {
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type Argv } from 'yargs';
import { type CommandModule, type Argv } from 'yargs';
import { handleEnable, enableCommand } from './enable.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
@@ -137,7 +137,7 @@ describe('extensions enable command', () => {
});
describe('enableCommand', () => {
const command = enableCommand;
const command = enableCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('enable [--scope] <name>');
@@ -70,7 +70,7 @@ export const enableCommand: CommandModule = {
argv.scope &&
!Object.values(SettingScope)
.map((s) => s.toLowerCase())
.includes(argv.scope.toLowerCase())
.includes((argv.scope as string).toLowerCase())
) {
throw new Error(
`Invalid scope: ${argv.scope}. Please use one of ${Object.values(
@@ -4,7 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
@@ -82,7 +90,7 @@ describe('MCP Server Example', () => {
json: vi.fn().mockResolvedValue(mockPosts),
});
const toolFn = mockRegisterTool.mock.calls[0][2];
const toolFn = (mockRegisterTool as Mock).mock.calls[0][2];
const result = await toolFn();
expect(global.fetch).toHaveBeenCalledWith(
@@ -101,7 +109,7 @@ describe('MCP Server Example', () => {
describe('poem-writer prompt implementation', () => {
it('should generate a prompt with a title', () => {
const promptFn = mockRegisterPrompt.mock.calls[0][2];
const promptFn = (mockRegisterPrompt as Mock).mock.calls[0][2];
const result = promptFn({ title: 'My Poem' });
expect(result).toEqual({
messages: [
@@ -117,7 +125,7 @@ describe('MCP Server Example', () => {
});
it('should generate a prompt with a title and mood', () => {
const promptFn = mockRegisterPrompt.mock.calls[0][2];
const promptFn = (mockRegisterPrompt as Mock).mock.calls[0][2];
const result = promptFn({ title: 'My Poem', mood: 'sad' });
expect(result).toEqual({
messages: [
@@ -14,7 +14,7 @@ import {
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type Argv } from 'yargs';
import { type CommandModule, type Argv } from 'yargs';
import { handleLink, linkCommand } from './link.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
@@ -126,7 +126,7 @@ describe('extensions link command', () => {
});
describe('linkCommand', () => {
const command = linkCommand;
const command = linkCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('link <path>');
@@ -6,6 +6,7 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { format } from 'node:util';
import { type CommandModule } from 'yargs';
import { handleList, listCommand } from './list.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
@@ -123,7 +124,7 @@ describe('extensions list command', () => {
});
describe('listCommand', () => {
const command = listCommand;
const command = listCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('list');
@@ -14,7 +14,7 @@ import {
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type Argv } from 'yargs';
import { type CommandModule, type Argv } from 'yargs';
import { handleUninstall, uninstallCommand } from './uninstall.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
@@ -233,7 +233,7 @@ describe('extensions uninstall command', () => {
});
describe('uninstallCommand', () => {
const command = uninstallCommand;
const command = uninstallCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('uninstall <names..>');
@@ -62,7 +62,7 @@ export const uninstallCommand: CommandModule = {
array: true,
})
.check((argv) => {
if (!argv.names || argv.names.length === 0) {
if (!argv.names || (argv.names as string[]).length === 0) {
throw new Error(
'Please include at least one extension name to uninstall as a positional argument.',
);
@@ -14,7 +14,7 @@ import {
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type Argv } from 'yargs';
import { type CommandModule, type Argv } from 'yargs';
import { handleUpdate, updateCommand } from './update.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
@@ -155,7 +155,7 @@ describe('extensions update command', () => {
});
describe('updateCommand', () => {
const command = updateCommand;
const command = updateCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('update [<name>] [--all]');
+2 -2
View File
@@ -60,7 +60,7 @@ vi.mock('fs', async (importOriginal) => {
if (mockPaths.has(p.toString())) {
return { isDirectory: () => true } as unknown as import('fs').Stats;
}
return actualFs.statSync(p as unknown as string);
return (actualFs as typeof import('fs')).statSync(p as unknown as string);
}),
realpathSync: vi.fn((p) => p),
};
@@ -1283,7 +1283,7 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('auto-gemini-2.5');
expect(config.getModel()).toBe('auto');
});
it('always prefers model from argv', async () => {
+3 -6
View File
@@ -31,11 +31,10 @@ import {
debugLogger,
loadServerHierarchicalMemory,
WEB_FETCH_TOOL_NAME,
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
import { appEvents } from '../utils/events.js';
@@ -289,7 +288,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
}
yargsInstance
.version(await getVersion()) // This will enable the --version flag based on package.json
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
.alias('h', 'help')
@@ -570,9 +569,7 @@ export async function loadCliConfig(
extraExcludes.length > 0 ? extraExcludes : undefined,
);
const defaultModel = settings.general?.previewFeatures
? PREVIEW_GEMINI_MODEL_AUTO
: DEFAULT_GEMINI_MODEL_AUTO;
const defaultModel = DEFAULT_GEMINI_MODEL_AUTO;
const resolvedModel: string =
argv.model ||
process.env['GEMINI_MODEL'] ||
+6 -13
View File
@@ -123,7 +123,7 @@ export class ExtensionManager extends ExtensionLoader {
'Extensions not yet loaded, must call `loadExtensions` first',
);
}
return this.loadedExtensions;
return this.loadedExtensions!;
}
async installOrUpdateExtension(
@@ -319,7 +319,7 @@ export class ExtensionManager extends ExtensionLoader {
// TODO: Gracefully handle this call failing, we should back up the old
// extension prior to overwriting it and then restore and restart it.
extension = await this.loadExtension(destinationPath);
extension = await this.loadExtension(destinationPath)!;
if (!extension) {
throw new Error(`Extension not found`);
}
@@ -327,7 +327,6 @@ export class ExtensionManager extends ExtensionLoader {
await logExtensionUpdateEvent(
this.telemetryConfig,
new ExtensionUpdateEvent(
newExtensionConfig.name,
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
@@ -340,7 +339,6 @@ export class ExtensionManager extends ExtensionLoader {
await logExtensionInstallEvent(
this.telemetryConfig,
new ExtensionInstallEvent(
newExtensionConfig.name,
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
@@ -348,10 +346,8 @@ export class ExtensionManager extends ExtensionLoader {
'success',
),
);
await this.enableExtension(
newExtensionConfig.name,
SettingScope.User,
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.enableExtension(newExtensionConfig.name, SettingScope.User);
}
} finally {
if (tempDir) {
@@ -377,7 +373,6 @@ export class ExtensionManager extends ExtensionLoader {
await logExtensionUpdateEvent(
this.telemetryConfig,
new ExtensionUpdateEvent(
config?.name ?? '',
hashValue(config?.name ?? ''),
extensionId ?? '',
newExtensionConfig?.version ?? '',
@@ -390,7 +385,6 @@ export class ExtensionManager extends ExtensionLoader {
await logExtensionInstallEvent(
this.telemetryConfig,
new ExtensionInstallEvent(
newExtensionConfig?.name ?? '',
hashValue(newExtensionConfig?.name ?? ''),
extensionId ?? '',
newExtensionConfig?.version ?? '',
@@ -438,7 +432,6 @@ export class ExtensionManager extends ExtensionLoader {
await logExtensionUninstall(
this.telemetryConfig,
new ExtensionUninstallEvent(
extension.name,
hashValue(extension.name),
extension.id,
'success',
@@ -721,7 +714,7 @@ export class ExtensionManager extends ExtensionLoader {
}
await logExtensionDisable(
this.telemetryConfig,
new ExtensionDisableEvent(name, hashValue(name), extension.id, scope),
new ExtensionDisableEvent(hashValue(name), extension.id, scope),
);
if (!this.config || this.config.getEnableExtensionReloading()) {
// Only toggle the isActive state if we are actually going to disable it
@@ -756,7 +749,7 @@ export class ExtensionManager extends ExtensionLoader {
}
await logExtensionEnable(
this.telemetryConfig,
new ExtensionEnableEvent(name, hashValue(name), extension.id, scope),
new ExtensionEnableEvent(hashValue(name), extension.id, scope),
);
if (!this.config || this.config.getEnableExtensionReloading()) {
// Only toggle the isActive state if we are actually going to disable it
@@ -1771,7 +1771,6 @@ This extension will run the following MCP servers:
} else {
expect(mockLogExtensionUninstall).toHaveBeenCalled();
expect(ExtensionUninstallEvent).toHaveBeenCalledWith(
'my-local-extension',
hashValue('my-local-extension'),
hashValue(userExtensionsDir),
'success',
@@ -1819,7 +1818,6 @@ This extension will run the following MCP servers:
expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(mockLogExtensionUninstall).toHaveBeenCalled();
expect(ExtensionUninstallEvent).toHaveBeenCalledWith(
'gemini-sql-extension',
hashValue('gemini-sql-extension'),
hashValue('https://github.com/google/gemini-sql-extension'),
'success',
@@ -1937,7 +1935,6 @@ This extension will run the following MCP servers:
expect(mockLogExtensionDisable).toHaveBeenCalled();
expect(ExtensionDisableEvent).toHaveBeenCalledWith(
'ext1',
hashValue('ext1'),
hashValue(userExtensionsDir),
SettingScope.Workspace,
@@ -2005,7 +2002,6 @@ This extension will run the following MCP servers:
expect(mockLogExtensionEnable).toHaveBeenCalled();
expect(ExtensionEnableEvent).toHaveBeenCalledWith(
'ext1',
hashValue('ext1'),
hashValue(userExtensionsDir),
SettingScope.Workspace,
@@ -397,48 +397,6 @@ describe('extensionSettings', () => {
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).toBe('VAR1="a value with spaces"\n');
});
it('should not attempt to clear secrets if keychain is unavailable', async () => {
// Arrange
const mockIsAvailable = vi.fn().mockResolvedValue(false);
const mockListSecrets = vi.fn();
vi.mocked(KeychainTokenStorage).mockImplementation(
() =>
({
isAvailable: mockIsAvailable,
listSecrets: mockListSecrets,
deleteSecret: vi.fn(),
getSecret: vi.fn(),
setSecret: vi.fn(),
}) as unknown as KeychainTokenStorage,
);
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [], // Empty settings triggers clearSettings
};
const previousConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
// Act
await maybePromptForSettings(
config,
'12345',
mockRequestSetting,
previousConfig,
undefined,
);
// Assert
expect(mockIsAvailable).toHaveBeenCalled();
expect(mockListSecrets).not.toHaveBeenCalled();
});
});
describe('promptForSetting', () => {
@@ -289,7 +289,7 @@ async function clearSettings(
if (fsSync.existsSync(envFilePath)) {
await fs.writeFile(envFilePath, '');
}
if (!(await keychain.isAvailable())) {
if (!keychain.isAvailable()) {
return;
}
const secrets = await keychain.listSecrets();
@@ -31,7 +31,7 @@ export async function fetchJson<T>(
if (!res.headers.location) {
return reject(new Error('No location header in redirect response'));
}
fetchJson<T>(res.headers.location, redirectCount++)
fetchJson<T>(res.headers.location!, redirectCount++)
.then(resolve)
.catch(reject);
return;
@@ -1,398 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/// <reference types="vitest/globals" />
import { describe, it, expect } from 'vitest';
import {
validateSettings,
formatValidationError,
settingsZodSchema,
} from './settings-validation.js';
import { z } from 'zod';
describe('settings-validation', () => {
describe('validateSettings', () => {
it('should accept valid settings with correct model.name as string', () => {
const validSettings = {
model: {
name: 'gemini-2.0-flash-exp',
maxSessionTurns: 10,
},
ui: {
theme: 'dark',
},
};
const result = validateSettings(validSettings);
expect(result.success).toBe(true);
});
it('should reject model.name as object instead of string', () => {
const invalidSettings = {
model: {
name: {
skipNextSpeakerCheck: true,
},
},
};
const result = validateSettings(invalidSettings);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
if (result.error) {
const issues = result.error.issues;
expect(issues.length).toBeGreaterThan(0);
expect(issues[0]?.path).toEqual(['model', 'name']);
expect(issues[0]?.code).toBe('invalid_type');
}
});
it('should accept valid model.summarizeToolOutput structure', () => {
const validSettings = {
model: {
summarizeToolOutput: {
run_shell_command: {
tokenBudget: 500,
},
},
},
};
const result = validateSettings(validSettings);
expect(result.success).toBe(true);
});
it('should reject invalid model.summarizeToolOutput structure', () => {
const invalidSettings = {
model: {
summarizeToolOutput: {
run_shell_command: {
tokenBudget: 500,
},
},
},
};
// First test with valid structure
let result = validateSettings(invalidSettings);
expect(result.success).toBe(true);
// Now test with wrong type (string instead of object)
const actuallyInvalidSettings = {
model: {
summarizeToolOutput: 'invalid',
},
};
result = validateSettings(actuallyInvalidSettings);
expect(result.success).toBe(false);
if (result.error) {
expect(result.error.issues.length).toBeGreaterThan(0);
}
});
it('should accept empty settings object', () => {
const emptySettings = {};
const result = validateSettings(emptySettings);
expect(result.success).toBe(true);
});
it('should accept unknown top-level keys (for migration compatibility)', () => {
const settingsWithUnknownKey = {
unknownKey: 'some value',
};
const result = validateSettings(settingsWithUnknownKey);
expect(result.success).toBe(true);
// Unknown keys are allowed via .passthrough() for migration scenarios
});
it('should accept nested valid settings', () => {
const validSettings = {
ui: {
theme: 'dark',
hideWindowTitle: true,
footer: {
hideCWD: false,
hideModelInfo: true,
},
},
tools: {
sandbox: 'inherit',
autoAccept: false,
},
};
const result = validateSettings(validSettings);
expect(result.success).toBe(true);
});
it('should validate array types correctly', () => {
const validSettings = {
tools: {
allowed: ['git', 'npm'],
exclude: ['dangerous-tool'],
},
context: {
includeDirectories: ['/path/1', '/path/2'],
},
};
const result = validateSettings(validSettings);
expect(result.success).toBe(true);
});
it('should reject invalid types in arrays', () => {
const invalidSettings = {
tools: {
allowed: ['git', 123],
},
};
const result = validateSettings(invalidSettings);
expect(result.success).toBe(false);
});
it('should validate boolean fields correctly', () => {
const validSettings = {
general: {
vimMode: true,
disableAutoUpdate: false,
},
};
const result = validateSettings(validSettings);
expect(result.success).toBe(true);
});
it('should reject non-boolean values for boolean fields', () => {
const invalidSettings = {
general: {
vimMode: 'yes',
},
};
const result = validateSettings(invalidSettings);
expect(result.success).toBe(false);
});
it('should validate number fields correctly', () => {
const validSettings = {
model: {
maxSessionTurns: 50,
compressionThreshold: 0.2,
},
};
const result = validateSettings(validSettings);
expect(result.success).toBe(true);
});
it('should validate complex nested mcpServers configuration', () => {
const invalidSettings = {
mcpServers: {
'my-server': {
command: 123, // Should be string
args: ['arg1'],
env: {
VAR: 'value',
},
},
},
};
const result = validateSettings(invalidSettings);
expect(result.success).toBe(false);
if (result.error) {
expect(result.error.issues.length).toBeGreaterThan(0);
// Path should be mcpServers.my-server.command
const issue = result.error.issues.find((i) =>
i.path.includes('command'),
);
expect(issue).toBeDefined();
expect(issue?.code).toBe('invalid_type');
}
});
it('should validate complex nested customThemes configuration', () => {
const invalidSettings = {
ui: {
customThemes: {
'my-theme': {
type: 'custom',
// Missing 'name' property which is required
text: {
primary: '#ffffff',
},
},
},
},
};
const result = validateSettings(invalidSettings);
expect(result.success).toBe(false);
if (result.error) {
expect(result.error.issues.length).toBeGreaterThan(0);
// Should complain about missing 'name'
const issue = result.error.issues.find(
(i) => i.code === 'invalid_type' && i.message.includes('Required'),
);
expect(issue).toBeDefined();
}
});
});
describe('formatValidationError', () => {
it('should format error with file path and helpful message for model.name', () => {
const invalidSettings = {
model: {
name: {
skipNextSpeakerCheck: true,
},
},
};
const result = validateSettings(invalidSettings);
expect(result.success).toBe(false);
if (result.error) {
const formatted = formatValidationError(
result.error,
'/path/to/settings.json',
);
expect(formatted).toContain('/path/to/settings.json');
expect(formatted).toContain('model.name');
expect(formatted).toContain('Expected: string, but received: object');
expect(formatted).toContain(
'Please fix the configuration and try again.',
);
expect(formatted).toContain(
'https://github.com/google-gemini/gemini-cli',
);
}
});
it('should format error for model.summarizeToolOutput', () => {
const invalidSettings = {
model: {
summarizeToolOutput: 'wrong type',
},
};
const result = validateSettings(invalidSettings);
expect(result.success).toBe(false);
if (result.error) {
const formatted = formatValidationError(
result.error,
'~/.gemini/settings.json',
);
expect(formatted).toContain('~/.gemini/settings.json');
expect(formatted).toContain('model.summarizeToolOutput');
}
});
it('should include link to documentation', () => {
const invalidSettings = {
model: {
name: { invalid: 'object' }, // model.name should be a string
},
};
const result = validateSettings(invalidSettings);
expect(result.success).toBe(false);
if (result.error) {
const formatted = formatValidationError(result.error, 'test.json');
expect(formatted).toContain(
'https://github.com/google-gemini/gemini-cli',
);
expect(formatted).toContain('configuration.md');
}
});
it('should list all validation errors', () => {
const invalidSettings = {
model: {
name: { invalid: 'object' },
maxSessionTurns: 'not a number',
},
};
const result = validateSettings(invalidSettings);
expect(result.success).toBe(false);
if (result.error) {
const formatted = formatValidationError(result.error, 'test.json');
// Should have multiple errors listed
expect(formatted.match(/Error in:/g)?.length).toBeGreaterThan(1);
}
});
it('should format array paths correctly (e.g. tools.allowed[0])', () => {
const invalidSettings = {
tools: {
allowed: ['git', 123], // 123 is invalid, expected string
},
};
const result = validateSettings(invalidSettings);
expect(result.success).toBe(false);
if (result.error) {
const formatted = formatValidationError(result.error, 'test.json');
expect(formatted).toContain('tools.allowed[1]');
}
});
it('should limit the number of displayed errors', () => {
const invalidSettings = {
tools: {
// Create 6 invalid items to trigger the limit
allowed: [1, 2, 3, 4, 5, 6],
},
};
const result = validateSettings(invalidSettings);
expect(result.success).toBe(false);
if (result.error) {
const formatted = formatValidationError(result.error, 'test.json');
// Should see the first 5
expect(formatted).toContain('tools.allowed[0]');
expect(formatted).toContain('tools.allowed[4]');
// Should NOT see the 6th
expect(formatted).not.toContain('tools.allowed[5]');
// Should see the summary
expect(formatted).toContain('...and 1 more errors.');
}
});
});
describe('settingsZodSchema', () => {
it('should be a valid Zod object schema', () => {
expect(settingsZodSchema).toBeInstanceOf(z.ZodObject);
});
it('should have optional fields', () => {
// All top-level fields should be optional
const shape = settingsZodSchema.shape;
expect(shape['model']).toBeDefined();
expect(shape['ui']).toBeDefined();
expect(shape['tools']).toBeDefined();
// Test that empty object is valid (all fields optional)
const result = settingsZodSchema.safeParse({});
expect(result.success).toBe(true);
});
});
});
@@ -1,331 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { z } from 'zod';
import {
getSettingsSchema,
type SettingDefinition,
type SettingCollectionDefinition,
SETTINGS_SCHEMA_DEFINITIONS,
} from './settingsSchema.js';
// Helper to build Zod schema from the JSON-schema-like definitions
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
if (def.anyOf) {
return z.union(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
def.anyOf.map((d: any) => buildZodSchemaFromJsonSchema(d)),
);
}
if (def.type === 'string') {
if (def.enum) return z.enum(def.enum as [string, ...string[]]);
return z.string();
}
if (def.type === 'number') return z.number();
if (def.type === 'boolean') return z.boolean();
if (def.type === 'array') {
if (def.items) {
return z.array(buildZodSchemaFromJsonSchema(def.items));
}
return z.array(z.unknown());
}
if (def.type === 'object') {
let schema;
if (def.properties) {
const shape: Record<string, z.ZodTypeAny> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const [key, propDef] of Object.entries(def.properties) as any) {
let propSchema = buildZodSchemaFromJsonSchema(propDef);
if (
def.required &&
Array.isArray(def.required) &&
def.required.includes(key)
) {
// keep it required
} else {
propSchema = propSchema.optional();
}
shape[key] = propSchema;
}
schema = z.object(shape).passthrough();
} else {
schema = z.object({}).passthrough();
}
if (def.additionalProperties === false) {
schema = schema.strict();
} else if (typeof def.additionalProperties === 'object') {
schema = schema.catchall(
buildZodSchemaFromJsonSchema(def.additionalProperties),
);
}
return schema;
}
return z.unknown();
}
/**
* Builds a Zod enum schema from options array
*/
function buildEnumSchema(
options: ReadonlyArray<{ value: string | number | boolean; label: string }>,
): z.ZodTypeAny {
if (!options || options.length === 0) {
throw new Error(
`Enum type must have options defined. Check your settings schema definition.`,
);
}
const values = options.map((opt) => opt.value);
if (values.every((v) => typeof v === 'string')) {
return z.enum(values as [string, ...string[]]);
} else if (values.every((v) => typeof v === 'number')) {
return z.union(
values.map((v) => z.literal(v)) as [
z.ZodLiteral<number>,
z.ZodLiteral<number>,
...Array<z.ZodLiteral<number>>,
],
);
} else {
return z.union(
values.map((v) => z.literal(v)) as [
z.ZodLiteral<unknown>,
z.ZodLiteral<unknown>,
...Array<z.ZodLiteral<unknown>>,
],
);
}
}
/**
* Builds a Zod object shape from properties record
*/
function buildObjectShapeFromProperties(
properties: Record<string, SettingDefinition>,
): Record<string, z.ZodTypeAny> {
const shape: Record<string, z.ZodTypeAny> = {};
for (const [key, childDef] of Object.entries(properties)) {
shape[key] = buildZodSchemaFromDefinition(childDef);
}
return shape;
}
/**
* Builds a Zod schema for primitive types (string, number, boolean)
*/
function buildPrimitiveSchema(
type: 'string' | 'number' | 'boolean',
): z.ZodTypeAny {
switch (type) {
case 'string':
return z.string();
case 'number':
return z.number();
case 'boolean':
return z.boolean();
default:
return z.unknown();
}
}
const REF_SCHEMAS: Record<string, z.ZodTypeAny> = {};
// Initialize REF_SCHEMAS
for (const [name, def] of Object.entries(SETTINGS_SCHEMA_DEFINITIONS)) {
REF_SCHEMAS[name] = buildZodSchemaFromJsonSchema(def);
}
/**
* Recursively builds a Zod schema from a SettingDefinition
*/
function buildZodSchemaFromDefinition(
definition: SettingDefinition,
): z.ZodTypeAny {
let baseSchema: z.ZodTypeAny;
// Special handling for TelemetrySettings which can be boolean or object
if (definition.ref === 'TelemetrySettings') {
const objectSchema = REF_SCHEMAS['TelemetrySettings'];
if (objectSchema) {
return z.union([z.boolean(), objectSchema]).optional();
}
}
// Handle refs using registry
if (definition.ref && definition.ref in REF_SCHEMAS) {
return REF_SCHEMAS[definition.ref].optional();
}
switch (definition.type) {
case 'string':
case 'number':
case 'boolean':
baseSchema = buildPrimitiveSchema(definition.type);
break;
case 'enum': {
baseSchema = buildEnumSchema(definition.options!);
break;
}
case 'array':
if (definition.items) {
const itemSchema = buildZodSchemaFromCollection(definition.items);
baseSchema = z.array(itemSchema);
} else {
baseSchema = z.array(z.unknown());
}
break;
case 'object':
if (definition.properties) {
const shape = buildObjectShapeFromProperties(definition.properties);
baseSchema = z.object(shape).passthrough();
if (definition.additionalProperties) {
const additionalSchema = buildZodSchemaFromCollection(
definition.additionalProperties,
);
baseSchema = z.object(shape).catchall(additionalSchema);
}
} else if (definition.additionalProperties) {
const valueSchema = buildZodSchemaFromCollection(
definition.additionalProperties,
);
baseSchema = z.record(z.string(), valueSchema);
} else {
baseSchema = z.record(z.string(), z.unknown());
}
break;
default:
baseSchema = z.unknown();
}
// Make all fields optional since settings are partial
return baseSchema.optional();
}
/**
* Builds a Zod schema from a SettingCollectionDefinition
*/
function buildZodSchemaFromCollection(
collection: SettingCollectionDefinition,
): z.ZodTypeAny {
if (collection.ref && collection.ref in REF_SCHEMAS) {
return REF_SCHEMAS[collection.ref];
}
switch (collection.type) {
case 'string':
case 'number':
case 'boolean':
return buildPrimitiveSchema(collection.type);
case 'enum': {
return buildEnumSchema(collection.options!);
}
case 'array':
if (collection.properties) {
const shape = buildObjectShapeFromProperties(collection.properties);
return z.array(z.object(shape));
}
return z.array(z.unknown());
case 'object':
if (collection.properties) {
const shape = buildObjectShapeFromProperties(collection.properties);
return z.object(shape).passthrough();
}
return z.record(z.string(), z.unknown());
default:
return z.unknown();
}
}
/**
* Builds the complete Zod schema for Settings from SETTINGS_SCHEMA
*/
function buildSettingsZodSchema(): z.ZodObject<Record<string, z.ZodTypeAny>> {
const schema = getSettingsSchema();
const shape: Record<string, z.ZodTypeAny> = {};
for (const [key, definition] of Object.entries(schema)) {
shape[key] = buildZodSchemaFromDefinition(definition);
}
return z.object(shape).passthrough();
}
export const settingsZodSchema = buildSettingsZodSchema();
/**
* Validates settings data against the Zod schema
*/
export function validateSettings(data: unknown): {
success: boolean;
data?: unknown;
error?: z.ZodError;
} {
const result = settingsZodSchema.safeParse(data);
return result;
}
/**
* Format a Zod error into a helpful error message
*/
export function formatValidationError(
error: z.ZodError,
filePath: string,
): string {
const lines: string[] = [];
lines.push(`Invalid configuration in ${filePath}:`);
lines.push('');
const MAX_ERRORS_TO_DISPLAY = 5;
const displayedIssues = error.issues.slice(0, MAX_ERRORS_TO_DISPLAY);
for (const issue of displayedIssues) {
const path = issue.path.reduce(
(acc, curr) =>
typeof curr === 'number'
? `${acc}[${curr}]`
: `${acc ? acc + '.' : ''}${curr}`,
'',
);
lines.push(`Error in: ${path || '(root)'}`);
lines.push(` ${issue.message}`);
if (issue.code === 'invalid_type') {
const expected = issue.expected;
const received = issue.received;
lines.push(`Expected: ${expected}, but received: ${received}`);
}
lines.push('');
}
if (error.issues.length > MAX_ERRORS_TO_DISPLAY) {
lines.push(
`...and ${error.issues.length - MAX_ERRORS_TO_DISPLAY} more errors.`,
);
lines.push('');
}
lines.push('Please fix the configuration and try again.');
lines.push(
'See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md',
);
return lines.join('\n');
}
+23 -50
View File
@@ -291,7 +291,7 @@ describe('Settings Loading and Merging', () => {
theme: 'legacy-dark',
vimMode: true,
contextFileName: 'LEGACY_CONTEXT.md',
model: 'gemini-2.5-pro',
model: 'gemini-pro',
mcpServers: {
'legacy-server-1': {
command: 'npm',
@@ -329,7 +329,7 @@ describe('Settings Loading and Merging', () => {
fileName: 'LEGACY_CONTEXT.md',
},
model: {
name: 'gemini-2.5-pro',
name: 'gemini-pro',
},
mcpServers: {
'legacy-server-1': {
@@ -371,37 +371,6 @@ describe('Settings Loading and Merging', () => {
expect((settings.merged as TestSettings)['allowedTools']).toBeUndefined();
});
it('should allow V2 settings to override V1 settings when both are present (zombie setting fix)', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const mixedSettingsContent = {
// V1 setting (migrates to ui.accessibility.screenReader = true)
accessibility: {
screenReader: true,
},
// V2 setting (explicitly set to false)
ui: {
accessibility: {
screenReader: false,
},
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(mixedSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// We expect the V2 setting (false) to win, NOT the migrated V1 setting (true)
expect(settings.merged.ui?.accessibility?.screenReader).toBe(false);
});
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const legacyUserSettings = {
@@ -1960,7 +1929,7 @@ describe('Settings Loading and Merging', () => {
usageStatisticsEnabled: false,
},
model: {
name: 'gemini-2.5-pro',
name: 'gemini-pro',
},
context: {
fileName: 'CONTEXT.md',
@@ -1999,7 +1968,7 @@ describe('Settings Loading and Merging', () => {
vimMode: true,
theme: 'dark',
usageStatisticsEnabled: false,
model: 'gemini-2.5-pro',
model: 'gemini-pro',
contextFileName: 'CONTEXT.md',
includeDirectories: ['/src'],
sandbox: true,
@@ -2262,7 +2231,7 @@ describe('Settings Loading and Merging', () => {
beforeEach(() => {
vi.resetAllMocks();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockFsExistsSync.mockReturnValue(true);
(mockFsExistsSync as Mock).mockReturnValue(true);
mockFsReadFileSync = vi.mocked(fs.readFileSync);
mockFsReadFileSync.mockReturnValue('{}');
vi.mocked(isWorkspaceTrusted).mockReturnValue({
@@ -2287,13 +2256,15 @@ describe('Settings Loading and Merging', () => {
},
};
mockFsReadFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
});
(mockFsReadFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
@@ -2358,13 +2329,15 @@ describe('Settings Loading and Merging', () => {
someOtherSetting: 'value',
};
mockFsReadFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
});
(mockFsReadFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
+2 -34
View File
@@ -33,10 +33,6 @@ import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import type { ExtensionManager } from './extension-manager.js';
import {
validateSettings,
formatValidationError,
} from './settings-validation.js';
import { SettingPaths } from './settingPaths.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
@@ -274,7 +270,7 @@ export function needsMigration(settings: Record<string, unknown>): boolean {
if (v1Key === v2Path || !(v1Key in settings)) {
return false;
}
// If a key exists that is a V1 key and a V2 container (like 'model'),
// If a key exists that is both a V1 key and a V2 container (like 'model'),
// we need to check the type. If it's an object, it's a V2 container and not
// a V1 key that needs migration.
if (
@@ -302,19 +298,6 @@ function migrateSettingsToV2(
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
if (flatKeys.has(oldKey)) {
// If the key exists and is a V2 container (like 'model'), and the value is an object,
// it is likely already migrated or partially migrated. We should not move it
// to the mapped V2 path (e.g. 'model' -> 'model.name').
// Instead, let it fall through to the "Carry over" section to be merged.
if (
KNOWN_V2_CONTAINERS.has(oldKey) &&
typeof flatSettings[oldKey] === 'object' &&
flatSettings[oldKey] !== null &&
!Array.isArray(flatSettings[oldKey])
) {
continue;
}
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
flatKeys.delete(oldKey);
}
@@ -344,8 +327,8 @@ function migrateSettingsToV2(
v2Settings[remainingKey] = customDeepMerge(
pathAwareGetStrategy,
{},
existingValue as MergeableObject,
newValue as MergeableObject,
existingValue as MergeableObject,
);
} else {
v2Settings[remainingKey] = newValue;
@@ -687,24 +670,9 @@ export function loadSettings(
settingsObject = migratedSettings;
}
}
// Validate settings structure with Zod after migration
const validationResult = validateSettings(settingsObject);
if (!validationResult.success && validationResult.error) {
const errorMessage = formatValidationError(
validationResult.error,
filePath,
);
throw new FatalConfigError(errorMessage);
}
return { settings: settingsObject as Settings, rawJson: content };
}
} catch (error: unknown) {
// Preserve FatalConfigError with formatted validation messages
if (error instanceof FatalConfigError) {
throw error;
}
settingsErrors.push({
message: getErrorMessage(error),
path: filePath,
@@ -33,7 +33,7 @@ describe('SettingsSchema', () => {
];
expectedSettings.forEach((setting) => {
expect(getSettingsSchema()[setting]).toBeDefined();
expect(getSettingsSchema()[setting as keyof Settings]).toBeDefined();
});
});
@@ -66,7 +66,9 @@ describe('SettingsSchema', () => {
];
nestedSettings.forEach((setting) => {
const definition = getSettingsSchema()[setting] as SettingDefinition;
const definition = getSettingsSchema()[
setting as keyof Settings
] as SettingDefinition;
expect(definition.type).toBe('object');
expect(definition.properties).toBeDefined();
expect(typeof definition.properties).toBe('object');
@@ -140,7 +142,7 @@ describe('SettingsSchema', () => {
it('should have consistent default values for boolean settings', () => {
const checkBooleanDefaults = (schema: SettingsSchema) => {
Object.entries(schema).forEach(([, definition]) => {
const def = definition;
const def = definition as SettingDefinition;
if (def.type === 'boolean') {
// Boolean settings can have boolean or undefined defaults (for optional settings)
expect(['boolean', 'undefined']).toContain(typeof def.default);
+2 -22
View File
@@ -18,8 +18,8 @@ import type {
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_GEMINI_MODEL,
DEFAULT_MODEL_CONFIGS,
GEMINI_MODEL_ALIAS_AUTO,
} from '@google/gemini-cli-core';
import type { CustomTheme } from '../ui/themes/theme.js';
import type { SessionRetentionSettings } from './settings.js';
@@ -742,16 +742,6 @@ const SETTINGS_SCHEMA = {
'Custom named presets for model configs. These are merged with (and override) the built-in aliases.',
showInDialog: false,
},
customOverrides: {
type: 'array',
label: 'Custom Model Config Overrides',
category: 'Model',
requiresRestart: false,
default: [],
description:
'Custom model config overrides. These are merged with (and added to) the built-in overrides.',
showInDialog: false,
},
overrides: {
type: 'array',
label: 'Model Config Overrides',
@@ -1164,16 +1154,6 @@ const SETTINGS_SCHEMA = {
description: 'Disable YOLO mode, even if enabled by a flag.',
showInDialog: true,
},
enablePermanentToolApproval: {
type: 'boolean',
label: 'Allow Permanent Tool Approval',
category: 'Security',
requiresRestart: false,
default: false,
description:
'Enable the "Allow for all future sessions" option in tool confirmation dialogs.',
showInDialog: true,
},
blockGitExtensions: {
type: 'boolean',
label: 'Blocks extensions from Git',
@@ -1404,7 +1384,7 @@ const SETTINGS_SCHEMA = {
label: 'Model',
category: 'Experimental',
requiresRestart: true,
default: GEMINI_MODEL_ALIAS_AUTO,
default: DEFAULT_GEMINI_MODEL,
description:
'The model to use for the Codebase Investigator agent.',
showInDialog: false,
@@ -1,200 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/// <reference types="vitest/globals" />
// Mock 'os' first.
import * as osActual from 'node:os';
vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof osActual>();
return {
...actualOs,
homedir: vi.fn(() => '/mock/home/user'),
platform: vi.fn(() => 'linux'),
};
});
// Mock './settings.js' to ensure it uses the mocked 'os.homedir()' for its internal constants.
vi.mock('./settings.js', async (importActual) => {
const originalModule = await importActual<typeof import('./settings.js')>();
return {
__esModule: true,
...originalModule,
};
});
// Mock trustedFolders
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi
.fn()
.mockReturnValue({ isTrusted: true, source: 'file' }),
}));
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
type Mock,
} from 'vitest';
import * as fs from 'node:fs';
import stripJsonComments from 'strip-json-comments';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof fs>();
return {
...actualFs,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
renameSync: vi.fn(),
realpathSync: (p: string) => p,
};
});
vi.mock('./extension.js');
const mockCoreEvents = vi.hoisted(() => ({
emitFeedback: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: mockCoreEvents,
};
});
vi.mock('../utils/commentJson.js', () => ({
updateSettingsFilePreservingFormat: vi.fn(),
}));
vi.mock('strip-json-comments', () => ({
default: vi.fn((content) => content),
}));
describe('Settings Repro', () => {
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
let mockFsMkdirSync: Mocked<typeof fs.mkdirSync>;
beforeEach(() => {
vi.resetAllMocks();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockFsMkdirSync = vi.mocked(fs.mkdirSync);
mockStripJsonComments = vi.mocked(stripJsonComments);
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
(mockStripJsonComments as unknown as Mock).mockImplementation(
(jsonString: string) => jsonString,
);
(mockFsExistsSync as Mock).mockReturnValue(false);
(fs.readFileSync as Mock).mockReturnValue('{}');
(mockFsMkdirSync as Mock).mockImplementation(() => undefined);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should handle the problematic settings.json without crashing', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const problemSettingsContent = {
accessibility: {
screenReader: true,
},
ide: {
enabled: false,
hasSeenNudge: true,
},
general: {
debugKeystrokeLogging: false,
enablePromptCompletion: false,
preferredEditor: 'vim',
vimMode: false,
previewFeatures: false,
},
security: {
auth: {
selectedType: 'gemini-api-key',
},
folderTrust: {
enabled: true,
},
},
tools: {
useRipgrep: true,
shell: {
showColor: true,
enableInteractiveShell: true,
},
enableMessageBusIntegration: true,
},
experimental: {
useModelRouter: false,
enableSubagents: false,
codebaseInvestigatorSettings: {
enabled: true,
},
},
ui: {
accessibility: {
screenReader: false,
},
showMemoryUsage: true,
showStatusInTitle: true,
showCitations: true,
useInkScrolling: true,
footer: {
hideContextPercentage: false,
hideModelInfo: false,
},
},
useWriteTodos: true,
output: {
format: 'text',
},
model: {
compressionThreshold: 0.8,
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(problemSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// If it doesn't throw, check if it merged correctly.
// The model.compressionThreshold should be present.
// And model.name should probably be undefined or default, but certainly NOT { compressionThreshold: 0.8 }
expect(settings.merged.model?.compressionThreshold).toBe(0.8);
expect(typeof settings.merged.model?.name).not.toBe('object');
});
});
+6 -3
View File
@@ -70,7 +70,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
disableMouseEvents: vi.fn(),
enterAlternateScreen: vi.fn(),
disableLineWrapping: vi.fn(),
getVersion: vi.fn(() => Promise.resolve('1.0.0')),
};
});
@@ -1295,6 +1294,10 @@ describe('startInteractiveUI', () => {
geminiMdFileCount: 0,
};
vi.mock('./utils/version.js', () => ({
getCliVersion: vi.fn(() => Promise.resolve('1.0.0')),
}));
vi.mock('./ui/utils/kittyProtocolDetector.js', () => ({
detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve(true)),
isKittyProtocolSupported: vi.fn(() => true),
@@ -1396,7 +1399,7 @@ describe('startInteractiveUI', () => {
});
it('should perform all startup tasks in correct order', async () => {
const { getVersion } = await import('@google/gemini-cli-core');
const { getCliVersion } = await import('./utils/version.js');
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
const { registerCleanup } = await import('./utils/cleanup.js');
@@ -1410,7 +1413,7 @@ describe('startInteractiveUI', () => {
);
// Verify all startup tasks were called
expect(getVersion).toHaveBeenCalledTimes(1);
expect(getCliVersion).toHaveBeenCalledTimes(1);
expect(registerCleanup).toHaveBeenCalledTimes(3);
// Verify cleanup handler is registered with unmount function
+2 -2
View File
@@ -33,6 +33,7 @@ import {
runExitCleanup,
registerTelemetryConfig,
} from './utils/cleanup.js';
import { getCliVersion } from './utils/version.js';
import {
type Config,
type ResumedSessionData,
@@ -62,7 +63,6 @@ import {
SessionEndReason,
fireSessionStartHook,
fireSessionEndHook,
getVersion,
} from '@google/gemini-cli-core';
import {
initializeApp,
@@ -196,7 +196,7 @@ export async function startInteractiveUI(
});
}
const version = await getVersion();
const version = await getCliVersion();
setWindowTitle(basename(workspaceRoot), settings);
const consolePatcher = new ConsolePatcher({
@@ -198,6 +198,7 @@ describe('runNonInteractive', () => {
);
vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({
processedQuery: [{ text: query }],
shouldProceed: true,
}));
});
@@ -572,6 +573,7 @@ describe('runNonInteractive', () => {
// 3. Setup the mock to return the processed parts
mockHandleAtCommand.mockResolvedValue({
processedQuery: processedParts,
shouldProceed: true,
});
// Mock a simple stream response from the Gemini client
+4 -4
View File
@@ -222,7 +222,7 @@ export async function runNonInteractive({
settings,
);
// If a slash command is found and returns a prompt, use it.
// Otherwise, slashCommandResult falls through to the default prompt
// Otherwise, slashCommandResult fall through to the default prompt
// handling.
if (slashCommandResult) {
query = slashCommandResult as Part[];
@@ -230,7 +230,7 @@ export async function runNonInteractive({
}
if (!query) {
const { processedQuery, error } = await handleAtCommand({
const { processedQuery, shouldProceed } = await handleAtCommand({
query: input,
config,
addItem: (_item, _timestamp) => 0,
@@ -239,11 +239,11 @@ export async function runNonInteractive({
signal: abortController.signal,
});
if (error || !processedQuery) {
if (!shouldProceed || !processedQuery) {
// An error occurred during @include processing (e.g., file not found).
// The error message is already logged by handleAtCommand.
throw new FatalInputError(
error || 'Exiting due to an error processing the @ command.',
'Exiting due to an error processing the @ command.',
);
}
query = processedQuery as Part[];
@@ -238,7 +238,7 @@ describe('FileCommandLoader', () => {
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
expect(commands[0].name).toBe('gcp:pipelines:run');
expect(commands[0]!.name).toBe('gcp:pipelines:run');
});
it('creates namespaces from nested directories', async () => {
-1
View File
@@ -91,7 +91,6 @@ const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getEnableInteractiveShell: () => true,
getPreviewFeatures: () => false,
};
const configProxy = new Proxy(mockConfig, {
+57 -184
View File
@@ -17,7 +17,7 @@ import {
import { render } from '../test-utils/render.js';
import { waitFor } from '../test-utils/async.js';
import { cleanup } from 'ink-testing-library';
import { act, useContext, type ReactElement } from 'react';
import { act, useContext } from 'react';
import { AppContainer } from './AppContainer.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import {
@@ -71,14 +71,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
})),
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
FileDiscoveryService: vi.fn().mockImplementation(() => ({
initialize: vi.fn(),
})),
startupProfiler: {
flush: vi.fn(),
start: vi.fn(),
end: vi.fn(),
},
};
});
import ansiEscapes from 'ansi-escapes';
@@ -352,7 +344,7 @@ describe('AppContainer State Management', () => {
// Add other properties if AppContainer uses them
});
mockedUseLogger.mockReturnValue({
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
getPreviousUserMessages: vi.fn().mockReturnValue(new Promise(() => {})),
});
mockedUseInputHistoryStore.mockReturnValue({
inputHistory: [],
@@ -369,8 +361,6 @@ describe('AppContainer State Management', () => {
// Mock config's getTargetDir to return consistent workspace directory
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
vi.spyOn(mockConfig, 'getDebugMode').mockReturnValue(false);
mockExtensionManager = vi.mockObject({
getExtensions: vi.fn().mockReturnValue([]),
@@ -413,25 +403,17 @@ describe('AppContainer State Management', () => {
describe('Basic Rendering', () => {
it('renders without crashing with minimal props', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
unmount();
});
it('renders with startup warnings', async () => {
const startupWarnings = ['Warning 1', 'Warning 2'];
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ startupWarnings });
unmount = result.unmount;
});
const { unmount } = renderAppContainer({ startupWarnings });
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
unmount();
});
});
@@ -442,15 +424,11 @@ describe('AppContainer State Management', () => {
themeError: 'Failed to load theme',
};
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
initResult: initResultWithError,
});
unmount = result.unmount;
const { unmount } = renderAppContainer({
initResult: initResultWithError,
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
unmount();
});
it('handles debug mode state', () => {
@@ -465,45 +443,29 @@ describe('AppContainer State Management', () => {
describe('Context Providers', () => {
it('provides AppContext with correct values', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ version: '2.0.0' });
unmount = result.unmount;
});
const { unmount } = renderAppContainer({ version: '2.0.0' });
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Should render and unmount cleanly
expect(() => unmount!()).not.toThrow();
expect(() => unmount()).not.toThrow();
});
it('provides UIStateContext with state management', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
unmount();
});
it('provides UIActionsContext with action handlers', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
unmount();
});
it('provides ConfigContext with config object', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
unmount();
});
});
@@ -518,13 +480,9 @@ describe('AppContainer State Management', () => {
},
} as unknown as LoadedSettings;
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ settings: settingsAllHidden });
unmount = result.unmount;
});
const { unmount } = renderAppContainer({ settings: settingsAllHidden });
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
unmount();
});
it('handles settings with memory usage enabled', async () => {
@@ -537,13 +495,9 @@ describe('AppContainer State Management', () => {
},
} as unknown as LoadedSettings;
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ settings: settingsWithMemory });
unmount = result.unmount;
});
const { unmount } = renderAppContainer({ settings: settingsWithMemory });
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
unmount();
});
});
@@ -551,13 +505,9 @@ describe('AppContainer State Management', () => {
it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])(
'handles version format: %s',
async (version) => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ version });
unmount = result.unmount;
});
const { unmount } = renderAppContainer({ version });
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
unmount();
},
);
});
@@ -579,13 +529,9 @@ describe('AppContainer State Management', () => {
merged: {},
} as LoadedSettings;
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({ settings: undefinedSettings });
unmount = result.unmount;
});
const { unmount } = renderAppContainer({ settings: undefinedSettings });
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
unmount();
});
});
@@ -914,16 +860,12 @@ describe('AppContainer State Management', () => {
describe('Quota and Fallback Integration', () => {
it('passes a null proQuotaRequest to UIStateContext by default', async () => {
// The default mock from beforeEach already sets proQuotaRequest to null
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => {
// Assert that the context value is as expected
expect(capturedUIState.proQuotaRequest).toBeNull();
});
unmount!();
unmount();
});
it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => {
@@ -939,16 +881,12 @@ describe('AppContainer State Management', () => {
});
// Act: Render the container
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => {
// Assert: The mock request is correctly passed through the context
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
});
unmount!();
unmount();
});
it('passes the handleProQuotaChoice function to UIActionsContext', async () => {
@@ -960,11 +898,7 @@ describe('AppContainer State Management', () => {
});
// Act: Render the container
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => {
// Assert: The action in the context is the mock handler we provided
expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler);
@@ -975,7 +909,7 @@ describe('AppContainer State Management', () => {
capturedUIActions.handleProQuotaChoice('retry_later');
});
expect(mockHandler).toHaveBeenCalledWith('retry_later');
unmount!();
unmount();
});
});
@@ -1393,17 +1327,13 @@ describe('AppContainer State Management', () => {
activePtyId: 'some-id',
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
const lastCall =
resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1];
// Check the height argument specifically
expect(lastCall[2]).toBe(1);
unmount!();
unmount();
});
});
@@ -1742,15 +1672,11 @@ describe('AppContainer State Management', () => {
closeModelDialog: vi.fn(),
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
expect(capturedUIState.isModelDialogOpen).toBe(true);
unmount!();
unmount();
});
it('should provide model dialog actions in the UIActionsContext', async () => {
@@ -1762,11 +1688,7 @@ describe('AppContainer State Management', () => {
closeModelDialog: mockCloseModelDialog,
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Verify that the actions are correctly passed through context
@@ -1774,17 +1696,13 @@ describe('AppContainer State Management', () => {
capturedUIActions.closeModelDialog();
});
expect(mockCloseModelDialog).toHaveBeenCalled();
unmount!();
unmount();
});
});
describe('CoreEvents Integration', () => {
it('subscribes to UserFeedback and drains backlog on mount', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
expect(mockCoreEvents.on).toHaveBeenCalledWith(
@@ -1792,18 +1710,14 @@ describe('AppContainer State Management', () => {
expect.any(Function),
);
expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1);
unmount!();
unmount();
});
it('unsubscribes from UserFeedback on unmount', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
unmount();
expect(mockCoreEvents.off).toHaveBeenCalledWith(
CoreEvent.UserFeedback,
@@ -1812,11 +1726,7 @@ describe('AppContainer State Management', () => {
});
it('adds history item when UserFeedback event is received', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
// Get the registered handler
@@ -1841,18 +1751,14 @@ describe('AppContainer State Management', () => {
}),
expect.any(Number),
);
unmount!();
unmount();
});
it('updates currentModel when ModelChanged event is received', async () => {
// Arrange: Mock initial model
vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model');
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => {
expect(capturedUIState?.currentModel).toBe('initial-model');
});
@@ -1864,15 +1770,13 @@ describe('AppContainer State Management', () => {
expect(handler).toBeDefined();
// Act: Simulate ModelChanged event
// Update config mock to return new model since the handler reads from config
vi.spyOn(mockConfig, 'getModel').mockReturnValue('new-model');
act(() => {
handler({ model: 'new-model' });
});
// Assert: Verify model is updated
expect(capturedUIState.currentModel).toBe('new-model');
unmount!();
unmount();
});
});
@@ -1895,14 +1799,10 @@ describe('AppContainer State Management', () => {
});
// The main assertion is that the render does not throw.
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
unmount!();
unmount();
});
});
describe('Banner Text', () => {
@@ -1912,14 +1812,10 @@ describe('AppContainer State Management', () => {
authType: AuthType.USE_GEMINI,
apiKey: 'fake-key',
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => {
expect(capturedUIState.bannerData.defaultText).toBeDefined();
unmount!();
unmount();
});
});
});
@@ -1942,11 +1838,7 @@ describe('AppContainer State Management', () => {
});
it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() => expect(capturedUIState).toBeTruthy());
const { onCancelSubmit } = extractUseGeminiStreamArgs(
@@ -1959,7 +1851,7 @@ describe('AppContainer State Management', () => {
expect(mockSetText).toHaveBeenCalledWith('');
unmount!();
unmount();
});
it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => {
@@ -1970,11 +1862,7 @@ describe('AppContainer State Management', () => {
initializeFromLogger: vi.fn(),
});
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
await waitFor(() =>
expect(capturedUIState.userMessages).toContain('previous message'),
);
@@ -1989,7 +1877,7 @@ describe('AppContainer State Management', () => {
expect(mockSetText).toHaveBeenCalledWith('previous message');
unmount!();
unmount();
});
it('input history is independent from conversation history (survives /clear)', async () => {
@@ -2002,13 +1890,7 @@ describe('AppContainer State Management', () => {
initializeFromLogger: vi.fn(),
});
let rerender: (tree: ReactElement) => void;
let unmount;
await act(async () => {
const result = renderAppContainer();
rerender = result.rerender;
unmount = result.unmount;
});
const { unmount } = renderAppContainer();
// Verify userMessages is populated from inputHistory
await waitFor(() =>
@@ -2026,17 +1908,12 @@ describe('AppContainer State Management', () => {
loadHistory: vi.fn(),
});
await act(async () => {
// Rerender to apply the new mock.
rerender(getAppContainer());
});
// Verify that userMessages still contains the input history
// (it should not be affected by clearing conversation history)
expect(capturedUIState.userMessages).toContain('first prompt');
expect(capturedUIState.userMessages).toContain('second prompt');
unmount!();
unmount();
});
});
@@ -2051,11 +1928,7 @@ describe('AppContainer State Management', () => {
// Clear previous calls
mocks.mockStdout.write.mockClear();
let compUnmount: () => void = () => {};
await act(async () => {
const { unmount } = renderAppContainer();
compUnmount = unmount;
});
const { unmount } = renderAppContainer();
// Allow async effects to run
await waitFor(() => expect(capturedUIState).toBeTruthy());
@@ -2071,7 +1944,7 @@ describe('AppContainer State Management', () => {
);
expect(clearTerminalCalls).toHaveLength(0);
compUnmount();
unmount();
});
});
});
+19 -12
View File
@@ -35,6 +35,7 @@ import {
type IdeContext,
type UserTierId,
type UserFeedbackPayload,
DEFAULT_GEMINI_FLASH_MODEL,
IdeClient,
ideContextStore,
getErrorMessage,
@@ -49,6 +50,7 @@ import {
coreEvents,
CoreEvent,
refreshServerHierarchicalMemory,
type ModelChangedPayload,
type MemoryChangedPayload,
writeToStdout,
disableMouseEvents,
@@ -254,7 +256,12 @@ export const AppContainer = (props: AppContainerProps) => {
);
// Helper to determine the effective model, considering the fallback state.
const getEffectiveModel = useCallback(() => config.getModel(), [config]);
const getEffectiveModel = useCallback(() => {
if (config.isInFallbackMode()) {
return DEFAULT_GEMINI_FLASH_MODEL;
}
return config.getModel();
}, [config]);
const [currentModel, setCurrentModel] = useState(getEffectiveModel());
@@ -333,15 +340,22 @@ export const AppContainer = (props: AppContainerProps) => {
// Subscribe to fallback mode and model changes from core
useEffect(() => {
const handleModelChanged = () => {
setCurrentModel(config.getModel());
const handleFallbackModeChanged = () => {
const effectiveModel = getEffectiveModel();
setCurrentModel(effectiveModel);
};
const handleModelChanged = (payload: ModelChangedPayload) => {
setCurrentModel(payload.model);
};
coreEvents.on(CoreEvent.FallbackModeChanged, handleFallbackModeChanged);
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
return () => {
coreEvents.off(CoreEvent.FallbackModeChanged, handleFallbackModeChanged);
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
};
}, [getEffectiveModel, config]);
}, [getEffectiveModel]);
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();
@@ -568,13 +582,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
) {
// We skip validation for Gemini API key here because it might be stored
// in the keychain, which we can't check synchronously.
// The useAuth hook handles validation for this case.
if (settings.merged.security.auth.selectedType === AuthType.USE_GEMINI) {
return;
}
const error = validateAuthMethod(
settings.merged.security.auth.selectedType,
);
@@ -1424,7 +1431,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
authType === AuthType.USE_VERTEX_AI
) {
setDefaultBannerText(
'Gemini 3 Flash and Pro are now available. \nEnable "Preview features" in /settings. \nLearn more at https://goo.gle/enable-preview-features',
'Gemini 3 is now available.\nTo use Gemini 3, enable "Preview features" in /settings\nLearn more at https://goo.gle/enable-preview-features',
);
}
}
@@ -12,18 +12,8 @@ import {
useTextBuffer,
type TextBuffer,
} from '../components/shared/text-buffer.js';
import { clearApiKey } from '@google/gemini-cli-core';
// Mocks
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
clearApiKey: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
@@ -47,8 +37,7 @@ describe('ApiAuthDialog', () => {
let mockBuffer: TextBuffer;
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('GEMINI_API_KEY', '');
vi.resetAllMocks();
mockBuffer = {
text: '',
lines: [''],
@@ -102,9 +91,7 @@ describe('ApiAuthDialog', () => {
({ keyName, sequence, expectedCall, args }) => {
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
// calls[1] is the TextInput's useKeypress (typing handler)
const keypressHandler = mockedUseKeypress.mock.calls[1][0];
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
name: keyName,
@@ -130,20 +117,4 @@ describe('ApiAuthDialog', () => {
expect(lastFrame()).toContain('Invalid API Key');
});
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await keypressHandler({
name: 'c',
ctrl: true,
meta: false,
shift: false,
});
expect(clearApiKey).toHaveBeenCalled();
expect(mockBuffer.setText).toHaveBeenCalledWith('');
});
});
+3 -53
View File
@@ -5,15 +5,11 @@
*/
import type React from 'react';
import { useRef, useEffect } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { TextInput } from '../components/shared/TextInput.js';
import { useTextBuffer } from '../components/shared/text-buffer.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { clearApiKey, debugLogger } from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
interface ApiAuthDialogProps {
onSubmit: (apiKey: string) => void;
@@ -31,20 +27,9 @@ export function ApiAuthDialog({
const { mainAreaWidth } = useUIState();
const viewportWidth = mainAreaWidth - 8;
const pendingPromise = useRef<{ cancel: () => void } | null>(null);
useEffect(
() => () => {
pendingPromise.current?.cancel();
},
[],
);
const initialApiKey = defaultValue;
const buffer = useTextBuffer({
initialText: initialApiKey || '',
initialCursorOffset: initialApiKey?.length || 0,
initialText: defaultValue || '',
initialCursorOffset: defaultValue?.length || 0,
viewport: {
width: viewportWidth,
height: 4,
@@ -59,41 +44,6 @@ export function ApiAuthDialog({
onSubmit(value);
};
const handleClear = () => {
pendingPromise.current?.cancel();
let isCancelled = false;
const wrappedPromise = new Promise<void>((resolve, reject) => {
clearApiKey().then(
() => !isCancelled && resolve(),
(error) => !isCancelled && reject(error),
);
});
pendingPromise.current = {
cancel: () => {
isCancelled = true;
},
};
return wrappedPromise
.then(() => {
buffer.setText('');
})
.catch((err) => {
debugLogger.debug('Failed to clear API key:', err);
});
};
useKeypress(
async (key) => {
if (keyMatchers[Command.CLEAR_INPUT](key)) {
await handleClear();
}
},
{ isActive: true },
);
return (
<Box
borderStyle="round"
@@ -139,7 +89,7 @@ export function ApiAuthDialog({
)}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
(Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key)
(Press Enter to submit, Esc to cancel)
</Text>
</Box>
</Box>
+2 -17
View File
@@ -232,21 +232,6 @@ describe('AuthDialog', () => {
);
});
it('skips API key dialog if env var is present but empty', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
process.env['GEMINI_API_KEY'] = ''; // Empty string
// props.settings.merged.security.auth.selectedType is undefined here
renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
);
});
it('shows API key dialog on initial setup if no env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
// process.env['GEMINI_API_KEY'] is not set
@@ -262,7 +247,7 @@ describe('AuthDialog', () => {
);
});
it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => {
it('shows API key dialog on re-auth to allow editing', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
process.env['GEMINI_API_KEY'] = 'test-key-from-env';
// Simulate that the user has already authenticated once
@@ -275,7 +260,7 @@ describe('AuthDialog', () => {
await handleAuthSelect(AuthType.USE_GEMINI);
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
AuthState.AwaitingApiKeyInput,
);
});
+4 -1
View File
@@ -116,6 +116,9 @@ export function AuthDialog({
return;
}
if (authType) {
const isInitialAuthSelection =
!settings.merged.security?.auth?.selectedType;
await clearCachedCredentialFile();
settings.setValue(scope, 'security.auth.selectedType', authType);
@@ -132,7 +135,7 @@ export function AuthDialog({
}
if (authType === AuthType.USE_GEMINI) {
if (process.env['GEMINI_API_KEY'] !== undefined) {
if (isInitialAuthSelection && process.env['GEMINI_API_KEY']) {
setAuthState(AuthState.Unauthenticated);
return;
} else {
@@ -12,7 +12,7 @@ exports[`ApiAuthDialog > renders correctly 1`] = `
│ │ Paste your API key here │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key)
│ (Press Enter to submit, Esc to cancel)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
+2 -2
View File
@@ -40,8 +40,8 @@ vi.mock('../../config/auth.js', () => ({
describe('useAuth', () => {
beforeEach(() => {
vi.resetAllMocks();
delete process.env['GEMINI_API_KEY'];
delete process.env['GEMINI_DEFAULT_AUTH_TYPE'];
process.env['GEMINI_API_KEY'] = '';
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = '';
});
afterEach(() => {
+4 -8
View File
@@ -55,15 +55,11 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
);
const reloadApiKey = useCallback(async () => {
const envKey = process.env['GEMINI_API_KEY'];
if (envKey !== undefined) {
setApiKeyDefaultValue(envKey);
return envKey;
}
const storedKey = (await loadApiKey()) ?? '';
setApiKeyDefaultValue(storedKey);
return storedKey;
const envKey = process.env['GEMINI_API_KEY'] ?? '';
const key = envKey || storedKey;
setApiKeyDefaultValue(key);
return key; // Return the key for immediate use
}, []);
useEffect(() => {
@@ -8,8 +8,9 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { aboutCommand } from './aboutCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import * as versionUtils from '../../utils/version.js';
import { MessageType } from '../types.js';
import { IdeClient, getVersion } from '@google/gemini-cli-core';
import { IdeClient } from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -24,10 +25,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
UserAccountManager: vi.fn().mockImplementation(() => ({
getCachedGoogleAccount: vi.fn().mockReturnValue('test-email@example.com'),
})),
getVersion: vi.fn(),
};
});
vi.mock('../../utils/version.js', () => ({
getCliVersion: vi.fn(),
}));
describe('aboutCommand', () => {
let mockContext: CommandContext;
const originalPlatform = process.platform;
@@ -55,7 +59,7 @@ describe('aboutCommand', () => {
},
} as unknown as CommandContext);
vi.mocked(getVersion).mockResolvedValue('test-version');
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue(
'test-model',
);
+2 -2
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { getCliVersion } from '../../utils/version.js';
import type { CommandContext, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import process from 'node:process';
@@ -12,7 +13,6 @@ import {
IdeClient,
UserAccountManager,
debugLogger,
getVersion,
} from '@google/gemini-cli-core';
export const aboutCommand: SlashCommand = {
@@ -31,7 +31,7 @@ export const aboutCommand: SlashCommand = {
})`;
}
const modelVersion = context.services.config?.getModel() || 'Unknown';
const cliVersion = await getVersion();
const cliVersion = await getCliVersion();
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
@@ -8,12 +8,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import open from 'open';
import { bugCommand } from './bugCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { getVersion } from '@google/gemini-cli-core';
import { getCliVersion } from '../../utils/version.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js';
// Mock dependencies
vi.mock('open');
vi.mock('../../utils/version.js');
vi.mock('../utils/formatters.js');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -26,7 +27,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
}),
},
sessionId: 'test-session-id',
getVersion: vi.fn(),
};
});
vi.mock('node:process', () => ({
@@ -41,7 +41,7 @@ vi.mock('node:process', () => ({
describe('bugCommand', () => {
beforeEach(() => {
vi.mocked(getVersion).mockResolvedValue('0.1.0');
vi.mocked(getCliVersion).mockResolvedValue('0.1.0');
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
vi.stubEnv('SANDBOX', 'gemini-test');
});
+3 -2
View File
@@ -14,7 +14,8 @@ import {
import { MessageType } from '../types.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import { IdeClient, sessionId, getVersion } from '@google/gemini-cli-core';
import { getCliVersion } from '../../utils/version.js';
import { IdeClient, sessionId } from '@google/gemini-cli-core';
export const bugCommand: SlashCommand = {
name: 'bug',
@@ -35,7 +36,7 @@ export const bugCommand: SlashCommand = {
})`;
}
const modelVersion = config?.getModel() || 'Unknown';
const cliVersion = await getVersion();
const cliVersion = await getCliVersion();
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
const ideClient = await getIdeClientName(context);
@@ -326,7 +326,7 @@ describe('chatCommand', () => {
const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json'];
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles) as unknown as typeof fsPromises.readdir,
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
);
mockFs.stat.mockImplementation(
@@ -346,7 +346,7 @@ describe('chatCommand', () => {
const date = new Date();
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles) as unknown as typeof fsPromises.readdir,
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
);
mockFs.stat.mockImplementation((async (
path: string,
@@ -406,7 +406,7 @@ describe('chatCommand', () => {
const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json'];
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles) as unknown as typeof fsPromises.readdir,
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
);
mockFs.stat.mockImplementation(

Some files were not shown because too many files have changed in this diff Show More