mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79fa7dcb6d | |||
| 1f1bbc462f | |||
| 32f536b76b | |||
| 5977ce8d62 | |||
| f47b44e70d | |||
| dcf2317adf | |||
| 61c7c95374 | |||
| ccee6a71df | |||
| 73add0fda7 | |||
| cc5c6bd90a | |||
| d9e79331ef | |||
| 68b7714d9b | |||
| 04dece3e3c | |||
| 2a3513bcdb |
Binary file not shown.
|
Before Width: | Height: | Size: 387 KiB |
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.log
|
||||
+1
-3
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
We’re 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. We’ve
|
||||
onboarded everyone who signed up to the previously available waitlist.
|
||||
- If you were not on our waitlist, we’re rolling out additional access gradually
|
||||
to ensure the experience remains fast and reliable. Stay tuned for more
|
||||
details.
|
||||
**Note:** Whether you’re automatically granted access or accepted from the
|
||||
waitlist, you’ll 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. You’ll 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 you’ve confirmed your version is 0.21.1 or later:
|
||||
Users will be onboarded in batches, subject to availability. When you’ve been
|
||||
granted access to Gemini 3 Pro, you’ll 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 email–or if you are automatically granted
|
||||
access–you 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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
|
||||
@@ -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,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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Generated
+150
-22
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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]');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'] ||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -91,7 +91,6 @@ const mockConfig = {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getEnableInteractiveShell: () => true,
|
||||
getPreviewFeatures: () => false,
|
||||
};
|
||||
|
||||
const configProxy = new Proxy(mockConfig, {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user