Compare commits

..

17 Commits

Author SHA1 Message Date
g-samroberts e0500ec891 Update website issue template to pre-fill title. 2026-03-18 17:57:27 -07:00
g-samroberts 7cfb85f951 Final linter run 2026-03-18 16:37:14 -07:00
Sam Roberts c52ea61af1 Update configuration.md 2026-03-18 16:31:50 -07:00
Sam Roberts 8adc855c48 Update commands.md 2026-03-18 16:29:21 -07:00
Sam Roberts ea5eef7b90 Update index.md 2026-03-18 16:26:43 -07:00
Sam Roberts d0157c4869 Update authentication.md 2026-03-18 16:23:15 -07:00
Sam Roberts c0f1c9cc5b Update plan-mode.md 2026-03-18 16:18:07 -07:00
Sam Roberts 4df6d27ac2 Update troubleshooting.md 2026-03-18 16:16:06 -07:00
Sam Roberts e3a6b6ab60 Update telemetry.md 2026-03-18 16:08:52 -07:00
Sam Roberts a2ad637398 Update notifications.md 2026-03-18 15:31:25 -07:00
Sam Roberts a5b8ea4418 Update model-steering.md 2026-03-18 15:30:25 -07:00
Sam Roberts ac6b1b888d Update plan-mode-steering.md 2026-03-18 15:28:06 -07:00
Sam Roberts d6eeb2f99e Update SKILL.md 2026-03-18 15:26:28 -07:00
Sam Roberts c08903ba83 Merge branch 'main' into doc-skill-callouts 2026-03-18 15:22:55 -07:00
Sam Roberts bfa762bcbb Update configuration.md
Update configuration.md to remove subagents YOLO mode warning based on https://github.com/google-gemini/gemini-cli/pull/22386
2026-03-16 17:59:04 -07:00
Sam Roberts 0e5978ee47 Merge branch 'main' into doc-skill-callouts 2026-03-16 17:57:07 -07:00
g-samroberts 3502249e0c Update callouts to work on github. 2026-03-12 17:10:08 -07:00
568 changed files with 8296 additions and 18949 deletions
+1 -12
View File
@@ -2,20 +2,9 @@
"experimental": {
"plan": true,
"extensionReloading": true,
"modelSteering": true,
"memoryManager": true
"modelSteering": true
},
"general": {
"devtools": true
},
"agents": {
"overrides": {
"browser_agent": { "enabled": true }
},
"browser": {
"headless": true,
"sessionMode": "isolated",
"allowedDomains": ["*.com"]
}
}
}
-1
View File
@@ -1 +0,0 @@
packages/core/src/services/scripts/*.exe
+1 -1
View File
@@ -1,6 +1,6 @@
name: 'Website issue'
description: 'Report an issue with the Gemini CLI Website and Gemini CLI Extensions Gallery'
title: 'GeminiCLI.com Feedback: [ISSUE]'
title: "GeminiCLI.com Feedback: [ISSUE]"
labels:
- 'area/extensions'
- 'area/documentation'
-69
View File
@@ -1,69 +0,0 @@
name: 'Evals: PR Guidance'
on:
pull_request:
paths:
- 'packages/core/src/**/*.ts'
- '!**/*.test.ts'
- '!**/*.test.tsx'
permissions:
pull-requests: 'write'
contents: 'read'
jobs:
provide-guidance:
name: 'Model Steering Guidance'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
with:
fetch-depth: 0
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Detect Steering Changes'
id: 'detect'
run: |
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Analyze PR Content'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
id: 'analysis'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
# Check for behavioral eval changes
EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true)
if [ -z "$EVAL_CHANGES" ]; then
echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT"
fi
# Check if user is a maintainer (has write/admin access)
USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission')
if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then
echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT"
fi
- name: 'Post Guidance Comment'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3
with:
comment-tag: 'eval-guidance-bot'
message: |
### 🧠 Model Steering Guidance
This PR modifies files that affect the model's behavior (prompts, tools, or instructions).
${{ steps.analysis.outputs.MISSING_EVALS == 'true' && '- ⚠️ **Consider adding Evals:** No behavioral evaluations (`evals/*.eval.ts`) were added or updated in this PR. Consider adding a test case to verify the new behavior and prevent regressions.' || '' }}
${{ steps.analysis.outputs.IS_MAINTAINER == 'true' && '- 🚀 **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging.' || '' }}
---
*This is an automated guidance message triggered by steering logic signatures.*
-1
View File
@@ -61,7 +61,6 @@ jobs:
GEMINI_MODEL: '${{ matrix.model }}'
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
VITEST_RETRY: 0
run: |
CMD="npm run test:all_evals"
PATTERN="${TEST_NAME_PATTERN}"
-61
View File
@@ -106,67 +106,6 @@ organization.
ensures users maintain final control over which permitted servers are actually
active in their environment.
#### Required MCP Servers (preview)
**Default**: empty
Allows administrators to define MCP servers that are **always injected** into
the user's environment. Unlike the allowlist (which filters user-configured
servers), required servers are automatically added regardless of the user's
local configuration.
**Required Servers Format:**
```json
{
"requiredMcpServers": {
"corp-compliance-tool": {
"url": "https://mcp.corp/compliance",
"type": "http",
"trust": true,
"description": "Corporate compliance tool"
},
"internal-registry": {
"url": "https://registry.corp/mcp",
"type": "sse",
"authProviderType": "google_credentials",
"oauth": {
"scopes": ["https://www.googleapis.com/auth/scope"]
}
}
}
}
```
**Supported Fields:**
- `url`: (Required) The full URL of the MCP server endpoint.
- `type`: (Required) The connection type (`sse` or `http`).
- `trust`: (Optional) If set to `true`, tool execution will not require user
approval. Defaults to `true` for required servers.
- `description`: (Optional) Human-readable description of the server.
- `authProviderType`: (Optional) Authentication provider (`dynamic_discovery`,
`google_credentials`, or `service_account_impersonation`).
- `oauth`: (Optional) OAuth configuration including `scopes`, `clientId`, and
`clientSecret`.
- `targetAudience`: (Optional) OAuth target audience for service-to-service
auth.
- `targetServiceAccount`: (Optional) Service account email to impersonate.
- `headers`: (Optional) Additional HTTP headers to send with requests.
- `includeTools` / `excludeTools`: (Optional) Tool filtering lists.
- `timeout`: (Optional) Timeout in milliseconds for MCP requests.
**Client Enforcement Logic:**
- Required servers are injected **after** allowlist filtering, so they are
always available even if the allowlist is active.
- If a required server has the **same name** as a locally configured server, the
admin configuration **completely overrides** the local one.
- Required servers only support remote transports (`sse`, `http`). Local
execution fields (`command`, `args`, `env`, `cwd`) are not supported.
- Required servers can coexist with allowlisted servers — both features work
independently.
### Unmanaged Capabilities
**Enabled/Disabled** | Default: disabled
+3 -7
View File
@@ -1,6 +1,6 @@
# Preview release: v0.35.0-preview.2
# Preview release: v0.35.0-preview.1
Released: March 19, 2026
Released: March 17, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -33,10 +33,6 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch
version v0.35.0-preview.1 and create version 0.35.0-preview.2 by
@gemini-cli-robot in
[#23134](https://github.com/google-gemini/gemini-cli/pull/23134)
- feat(cli): customizable keyboard shortcuts by @scidomino in
[#21945](https://github.com/google-gemini/gemini-cli/pull/21945)
- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in
@@ -377,4 +373,4 @@ npm install -g @google/gemini-cli@preview
[#22815](https://github.com/google-gemini/gemini-cli/pull/22815)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.2
https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.1
-1
View File
@@ -50,7 +50,6 @@ These commands are available within the interactive REPL.
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. |
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. |
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
-107
View File
@@ -1,107 +0,0 @@
# Git Worktrees (experimental)
When working on multiple tasks at once, you can use Git worktrees to give each
Gemini session its own copy of the codebase. Git worktrees create separate
working directories that each have their own files and branch while sharing the
same repository history. This prevents changes in one session from colliding
with another.
Learn more about [session management](./session-management.md).
<!-- prettier-ignore -->
> [!NOTE]
> This is an experimental feature currently under active development. Your
> feedback is invaluable as we refine this feature. If you have ideas,
> suggestions, or encounter issues:
>
> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) on GitHub.
> - Use the **/bug** command within Gemini CLI to file an issue.
Learn more in the official Git worktree
[documentation](https://git-scm.com/docs/git-worktree).
## How to enable Git worktrees
Git worktrees are an experimental feature. You must enable them in your settings
using the `/settings` command or by manually editing your `settings.json` file.
1. Use the `/settings` command.
2. Search for and set **Enable Git Worktrees** to `true`.
Alternatively, add the following to your `settings.json`:
```json
{
"experimental": {
"worktrees": true
}
}
```
## How to use Git worktrees
Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini
CLI in it.
- **Start with a specific name:** The value you pass becomes both the directory
name (within `.gemini/worktrees/`) and the branch name.
```bash
gemini --worktree feature-search
```
- **Start with a random name:** If you omit the name, Gemini generates a random
one automatically (for example, `worktree-a1b2c3d4`).
```bash
gemini --worktree
```
<!-- prettier-ignore -->
> [!NOTE]
> Remember to initialize your development environment in each new
> worktree according to your project's setup. Depending on your stack, this
> might include running dependency installation (`npm install`, `yarn`), setting
> up virtual environments, or following your project's standard build process.
## How to exit a Git worktree session
When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini leaves the
worktree intact so your work is not lost. This includes your uncommitted changes
(modified files, staged changes, or untracked files) and any new commits you
have made.
Gemini prioritizes a fast and safe exit: it **does not automatically delete**
your worktree or branch. You are responsible for cleaning up your worktrees
manually once you are finished with them.
When you exit, Gemini displays instructions on how to resume your work or how to
manually remove the worktree if you no longer need it.
## Resuming work in a Git worktree
To resume a session in a worktree, navigate to the worktree directory and start
Gemini CLI with the `--resume` flag and the session ID:
```bash
cd .gemini/worktrees/feature-search
gemini --resume <session_id>
```
## Managing Git worktrees manually
For more control over worktree location and branch configuration, or to clean up
a preserved worktree, you can use Git directly:
- **Clean up a preserved Git worktree:**
```bash
git worktree remove .gemini/worktrees/feature-search --force
git branch -D worktree-feature-search
```
- **Create a Git worktree manually:**
```bash
git worktree add ../project-feature-search -b feature-search
cd ../project-feature-search && gemini
```
[Open an issue]: https://github.com/google-gemini/gemini-cli/issues
+1 -19
View File
@@ -50,25 +50,7 @@ Cross-platform sandboxing with complete process isolation.
**Note**: Requires building the sandbox image locally or using a published image
from your organization's registry.
### 3. Windows Native Sandbox (Windows only)
... **Troubleshooting and Side Effects:**
The Windows Native sandbox uses the `icacls` command to set a "Low Mandatory
Level" on files and directories it needs to write to.
- **Persistence**: These integrity level changes are persistent on the
filesystem. Even after the sandbox session ends, files created or modified by
the sandbox will retain their "Low" integrity level.
- **Manual Reset**: If you need to reset the integrity level of a file or
directory, you can use:
```powershell
icacls "C:\path\to\dir" /setintegritylevel Medium
```
- **System Folders**: The sandbox manager automatically skips setting integrity
levels on system folders (like `C:\Windows`) for safety.
### 4. gVisor / runsc (Linux only)
### 3. gVisor / runsc (Linux only)
Strongest isolation available: runs containers inside a user-space kernel via
[gVisor](https://github.com/google/gvisor). gVisor intercepts all container
-6
View File
@@ -96,12 +96,6 @@ Compatibility aliases:
- `/chat ...` works for the same commands.
- `/resume checkpoints ...` also remains supported during migration.
## Parallel sessions with Git worktrees
When working on multiple tasks at once, you can use
[Git worktrees](./git-worktrees.md) to give each Gemini session its own copy of
the codebase. This prevents changes in one session from colliding with another.
## Managing sessions
You can list and delete sessions to keep your history organized and manage disk
-11
View File
@@ -101,13 +101,6 @@ they appear in the UI.
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
### Agents
| UI Label | Setting | Description | Default |
| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------- | ------- |
| Confirm Sensitive Actions | `agents.browser.confirmSensitiveActions` | Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script). | `false` |
| Block File Uploads | `agents.browser.blockFileUploads` | Hard-block file upload requests from the browser agent. | `false` |
### Context
| UI Label | Setting | Description | Default |
@@ -124,8 +117,6 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Sandbox Allowed Paths | `tools.sandboxAllowedPaths` | List of additional paths that the sandbox is allowed to access. | `[]` |
| Sandbox Network Access | `tools.sandboxNetworkAccess` | Whether the sandbox is allowed to access the network. | `false` |
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
@@ -158,13 +149,11 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
### Skills
-29
View File
@@ -306,7 +306,6 @@ Emitted at startup with the CLI configuration.
- `extension_ids` (string)
- `extensions_count` (int)
- `auth_type` (string)
- `worktree_active` (boolean)
- `github_workflow_name` (string, optional)
- `github_repository_hash` (string, optional)
- `github_event_name` (string, optional)
@@ -904,20 +903,6 @@ Logs keychain availability checks.
- `available` (boolean)
##### `gemini_cli.startup_stats`
Logs detailed startup performance statistics.
<details>
<summary>Attributes</summary>
- `phases` (json array of startup phases)
- `os_platform` (string)
- `os_release` (string)
- `is_docker` (boolean)
</details>
</details>
### Metrics
@@ -934,20 +919,6 @@ Gemini CLI exports several custom metrics.
Incremented once per CLI startup.
##### Onboarding
Tracks onboarding flow from authentication to the user
- `gemini_cli.onboarding.start` (Counter, Int): Incremented when the
authentication flow begins.
- `gemini_cli.onboarding.success` (Counter, Int): Incremented when the user
onboarding flow completes successfully.
<details>
<summary>Attributes (Success)</summary>
- `user_tier` (string)
##### Tools
##### `gemini_cli.tool.call.count`
+5 -5
View File
@@ -7,9 +7,9 @@ create files, and control what Gemini CLI can see.
## Prerequisites
- Gemini CLI installed and authenticated.
- A project directory to work with (for example, a git repository).
- A project directory to work with (e.g., a git repository).
## Providing context by reading files
## How to give the agent context (Reading files)
Gemini CLI will generally try to read relevant files, sometimes prompting you
for access (depending on your settings). To ensure that Gemini CLI uses a file,
@@ -58,7 +58,7 @@ You know there's a `UserProfile` component, but you don't know where it lives.
```
Gemini uses the `glob` or `list_directory` tools to search your project
structure. It will return the specific path (for example,
structure. It will return the specific path (e.g.,
`src/components/UserProfile.tsx`), which you can then use with `@` in your next
turn.
@@ -113,8 +113,8 @@ or, better yet, run your project's tests.
`Run the tests for the UserProfile component.`
```
Gemini CLI uses the `run_shell_command` tool to execute your test runner (for
example, `npm test` or `jest`). This ensures the changes didn't break existing
Gemini CLI uses the `run_shell_command` tool to execute your test runner (e.g.,
`npm test` or `jest`). This ensures the changes didn't break existing
functionality.
## Advanced: Controlling what Gemini sees
+6 -6
View File
@@ -11,8 +11,8 @@ persistent facts, and inspect the active context.
## Why manage context?
Gemini CLI is powerful but general. It doesn't know your preferred testing
framework, your indentation style, or your preference against `any` in
Out of the box, Gemini CLI is smart but generic. It doesn't know your preferred
testing framework, your indentation style, or that you hate using `any` in
TypeScript. Context management solves this by giving the agent persistent
memory.
@@ -109,11 +109,11 @@ immediately. Force a reload with:
## Best practices
- **Keep it focused:** Avoid adding excessive content to `GEMINI.md`. Keep
instructions actionable and relevant to code generation.
- **Keep it focused:** Don't dump your entire internal wiki into `GEMINI.md`.
Keep instructions actionable and relevant to code generation.
- **Use negative constraints:** Explicitly telling the agent what _not_ to do
(for example, "Do not use class components") is often more effective than
vague positive instructions.
(e.g., "Do not use class components") is often more effective than vague
positive instructions.
- **Review often:** Periodically check your `GEMINI.md` files to remove outdated
rules.
+3 -3
View File
@@ -7,7 +7,7 @@ automate complex workflows, and manage background processes safely.
## Prerequisites
- Gemini CLI installed and authenticated.
- Basic familiarity with your system's shell (Bash, Zsh, PowerShell, and so on).
- Basic familiarity with your system's shell (Bash, Zsh, PowerShell, etc.).
## How to run commands directly (`!`)
@@ -49,7 +49,7 @@ You want to run tests and fix any failures.
6. Gemini uses `replace` to fix the bug.
7. Gemini runs `npm test` again to verify the fix.
This loop lets Gemini work autonomously.
This loop turns Gemini into an autonomous engineer.
## How to manage background processes
@@ -75,7 +75,7 @@ confirmation prompts) by streaming the output to you. However, for highly
interactive tools (like `vim` or `top`), it's often better to run them yourself
in a separate terminal window or use the `!` prefix.
## Safety features
## Safety first
Giving an AI access to your shell is powerful but risky. Gemini CLI includes
several safety layers.
+1 -2
View File
@@ -23,7 +23,7 @@ Gemini CLI creates a copy of the extension during installation. You must run
GitHub, you must have `git` installed on your machine.
```bash
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent] [--skip-settings]
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent]
```
- `<source>`: The GitHub URL or local path of the extension.
@@ -31,7 +31,6 @@ gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release]
- `--auto-update`: Enable automatic updates for this extension.
- `--pre-release`: Enable installation of pre-release versions.
- `--consent`: Acknowledge security risks and skip the confirmation prompt.
- `--skip-settings`: Skip the configuration on install process.
### Uninstall an extension
+8 -8
View File
@@ -42,8 +42,8 @@ Select the authentication method that matches your situation in the table below:
If you run Gemini CLI on your local machine, the simplest authentication method
is logging in with your Google account. This method requires a web browser on a
machine that can communicate with the terminal running Gemini CLI (for example,
your local machine).
machine that can communicate with the terminal running Gemini CLI (e.g., your
local machine).
If you are a **Google AI Pro** or **Google AI Ultra** subscriber, use the Google
account associated with your subscription.
@@ -134,7 +134,7 @@ For example:
**macOS/Linux**
```bash
# Replace with your project ID and desired location (for example, us-central1)
# Replace with your project ID and desired location (e.g., us-central1)
export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"
```
@@ -142,7 +142,7 @@ export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"
**Windows (PowerShell)**
```powershell
# Replace with your project ID and desired location (for example, us-central1)
# Replace with your project ID and desired location (e.g., us-central1)
$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
$env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"
```
@@ -333,14 +333,14 @@ persist them with the following methods:
1. **Add your environment variables to your shell configuration file:** Append
the environment variable commands to your shell's startup file.
**macOS/Linux** (for example, `~/.bashrc`, `~/.zshrc`, or `~/.profile`):
**macOS/Linux** (e.g., `~/.bashrc`, `~/.zshrc`, or `~/.profile`):
```bash
echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc
source ~/.bashrc
```
**Windows (PowerShell)** (for example, `$PROFILE`):
**Windows (PowerShell)** (e.g., `$PROFILE`):
```powershell
Add-Content -Path $PROFILE -Value '$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"'
@@ -356,8 +356,8 @@ persist them with the following methods:
2. **Use a `.env` file:** Create a `.gemini/.env` file in your project
directory or home directory. Gemini CLI automatically loads variables from
the first `.env` file it finds, searching up from the current directory,
then in your home directory's `.gemini/.env` (for example, `~/.gemini/.env`
or `%USERPROFILE%\.gemini\.env`).
then in your home directory's `.gemini/.env` (e.g., `~/.gemini/.env` or
`%USERPROFILE%\.gemini\.env`).
Example for user-wide settings:
+2 -2
View File
@@ -27,7 +27,7 @@ Get started by upgrading Gemini CLI to the latest version:
npm install -g @google/gemini-cli@latest
```
If your version is 0.21.1 or later:
After youve confirmed your version is 0.21.1 or later:
1. Run `/model`.
2. Select **Auto (Gemini 3)**.
@@ -115,7 +115,7 @@ then:
Restart Gemini CLI and you should have access to Gemini 3.
## Next steps
## Need help?
If you need help, we recommend searching for an existing
[GitHub issue](https://github.com/google-gemini/gemini-cli/issues). If you
+2 -2
View File
@@ -470,5 +470,5 @@ console.error('Consolidating memories for session end...');
While project-level hooks are great for specific repositories, you can share
your hooks across multiple projects by packaging them as a
[Gemini CLI extension](../extensions/index.md). This provides version control,
easy distribution, and centralized management.
[Gemini CLI extension](https://www.google.com/search?q=../extensions/index.md).
This provides version control, easy distribution, and centralized management.
+3 -210
View File
@@ -686,16 +686,6 @@ their corresponding top-level category object in your `settings.json` file.
```json
{
"gemini-3.1-flash-lite-preview": {
"tier": "flash-lite",
"family": "gemini-3",
"isPreview": true,
"isVisible": true,
"features": {
"thinking": false,
"multimodalToolUse": true
}
},
"gemini-3.1-pro-preview": {
"tier": "pro",
"family": "gemini-3",
@@ -807,7 +797,7 @@ their corresponding top-level category object in your `settings.json` file.
"tier": "auto",
"isPreview": true,
"isVisible": true,
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash",
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash",
"features": {
"thinking": true,
"multimodalToolUse": false
@@ -836,39 +826,6 @@ their corresponding top-level category object in your `settings.json` file.
```json
{
"gemini-3.1-pro-preview": {
"default": "gemini-3.1-pro-preview",
"contexts": [
{
"condition": {
"hasAccessToPreview": false
},
"target": "gemini-2.5-pro"
}
]
},
"gemini-3.1-pro-preview-customtools": {
"default": "gemini-3.1-pro-preview-customtools",
"contexts": [
{
"condition": {
"hasAccessToPreview": false
},
"target": "gemini-2.5-pro"
}
]
},
"gemini-3-flash-preview": {
"default": "gemini-3-flash-preview",
"contexts": [
{
"condition": {
"hasAccessToPreview": false
},
"target": "gemini-2.5-flash"
}
]
},
"gemini-3-pro-preview": {
"default": "gemini-3-pro-preview",
"contexts": [
@@ -1040,132 +997,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Requires restart:** Yes
- **`modelConfigs.modelChains`** (object):
- **Description:** Availability policy chains defining fallback behavior for
models.
- **Default:**
```json
{
"preview": [
{
"model": "gemini-3-pro-preview",
"actions": {
"terminal": "prompt",
"transient": "prompt",
"not_found": "prompt",
"unknown": "prompt"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
},
{
"model": "gemini-3-flash-preview",
"isLastResort": true,
"actions": {
"terminal": "prompt",
"transient": "prompt",
"not_found": "prompt",
"unknown": "prompt"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
}
],
"default": [
{
"model": "gemini-2.5-pro",
"actions": {
"terminal": "prompt",
"transient": "prompt",
"not_found": "prompt",
"unknown": "prompt"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
},
{
"model": "gemini-2.5-flash",
"isLastResort": true,
"actions": {
"terminal": "prompt",
"transient": "prompt",
"not_found": "prompt",
"unknown": "prompt"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
}
],
"lite": [
{
"model": "gemini-2.5-flash-lite",
"actions": {
"terminal": "silent",
"transient": "silent",
"not_found": "silent",
"unknown": "silent"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
},
{
"model": "gemini-2.5-flash",
"actions": {
"terminal": "silent",
"transient": "silent",
"not_found": "silent",
"unknown": "silent"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
},
{
"model": "gemini-2.5-pro",
"isLastResort": true,
"actions": {
"terminal": "silent",
"transient": "silent",
"not_found": "silent",
"unknown": "silent"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
}
]
}
```
- **Requires restart:** Yes
#### `agents`
- **`agents.overrides`** (object):
@@ -1210,17 +1041,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Disable user input on browser window during automation.
- **Default:** `true`
- **`agents.browser.confirmSensitiveActions`** (boolean):
- **Description:** Require manual confirmation for sensitive browser actions
(e.g., fill_form, evaluate_script).
- **Default:** `false`
- **Requires restart:** Yes
- **`agents.browser.blockFileUploads`** (boolean):
- **Description:** Hard-block file upload requests from the browser agent.
- **Default:** `false`
- **Requires restart:** Yes
#### `context`
- **`context.fileName`** (string | string[]):
@@ -1287,21 +1107,10 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Legacy full-process sandbox execution environment. Set to a
boolean to enable or disable the sandbox, provide a string path to a sandbox
profile, or specify an explicit sandbox command (e.g., "docker", "podman",
"lxc", "windows-native").
"lxc").
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.sandboxAllowedPaths`** (array):
- **Description:** List of additional paths that the sandbox is allowed to
access.
- **Default:** `[]`
- **Requires restart:** Yes
- **`tools.sandboxNetworkAccess`** (boolean):
- **Description:** Whether the sandbox is allowed to access the network.
- **Default:** `false`
- **Requires restart:** Yes
- **`tools.shell.enableInteractiveShell`** (boolean):
- **Description:** Use node-pty for an interactive shell experience. Fallback
to child_process still applies.
@@ -1538,11 +1347,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.worktrees`** (boolean):
- **Description:** Enable automated Git worktree management for parallel work.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionManagement`** (boolean):
- **Description:** Enable extension management features.
- **Default:** `true`
@@ -1629,13 +1433,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `"gemma3-1b-gpu-custom"`
- **Requires restart:** Yes
- **`experimental.memoryManager`** (boolean):
- **Description:** Replace the built-in save_memory tool with a memory manager
subagent that supports adding, removing, de-duplicating, and organizing
memories.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.topicUpdateNarration`** (boolean):
- **Description:** Enable the experimental Topic & Update communication model
for reduced chattiness and structured progress reporting.
@@ -1744,11 +1541,7 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **`admin.mcp.config`** (object):
- **Description:** Admin-configured MCP servers (allowlist).
- **Default:** `{}`
- **`admin.mcp.requiredConfig`** (object):
- **Description:** Admin-required MCP servers that are always injected.
- **Description:** Admin-configured MCP servers.
- **Default:** `{}`
- **`admin.skills.enabled`** (boolean):
+9 -14
View File
@@ -262,8 +262,8 @@ Here is a breakdown of the fields available in a TOML policy rule:
# A unique name for the tool, or an array of names.
toolName = "run_shell_command"
# (Optional) The name of a subagent. If provided, the rule only applies to tool
# calls made by this specific subagent.
# (Optional) The name of a subagent. If provided, the rule only applies to tool calls
# made by this specific subagent.
subagent = "generalist"
# (Optional) The name of an MCP server. Can be combined with toolName
@@ -278,17 +278,14 @@ toolAnnotations = { readOnlyHint = true }
argsPattern = '"command":"(git|npm)'
# (Optional) A string or array of strings that a shell command must start with.
# This is syntactic sugar for `toolName = "run_shell_command"` and an
# `argsPattern`.
# This is syntactic sugar for `toolName = "run_shell_command"` and an `argsPattern`.
commandPrefix = "git"
# (Optional) A regex to match against the entire shell command.
# This is also syntactic sugar for `toolName = "run_shell_command"`.
# Note: This pattern is tested against the JSON representation of the arguments
# (e.g., `{"command":"<your_command>"}`). Because it prepends `"command":"`,
# it effectively matches from the start of the command.
# Anchors like `^` or `$` apply to the full JSON string,
# so `^` should usually be avoided here.
# Note: This pattern is tested against the JSON representation of the arguments (e.g., `{"command":"<your_command>"}`).
# Because it prepends `"command":"`, it effectively matches from the start of the command.
# Anchors like `^` or `$` apply to the full JSON string, so `^` should usually be avoided here.
# You cannot use commandPrefix and commandRegex in the same rule.
commandRegex = "git (commit|push)"
@@ -298,16 +295,14 @@ decision = "ask_user"
# The priority of the rule, from 0 to 999.
priority = 10
# (Optional) A custom message to display when a tool call is denied by this
# rule. This message is returned to the model and user,
# useful for explaining *why* it was denied.
# (Optional) A custom message to display when a tool call is denied by this rule.
# This message is returned to the model and user, useful for explaining *why* it was denied.
deny_message = "Deletion is permanent"
# (Optional) An array of approval modes where this rule is active.
modes = ["autoEdit"]
# (Optional) A boolean to restrict the rule to interactive (true) or
# non-interactive (false) environments.
# (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments.
# If omitted, the rule applies to both.
interactive = true
```
-13
View File
@@ -58,19 +58,6 @@ your total token usage using the `/stats` command in Gemini CLI.
## Installation and updates
### How do I check which version of Gemini CLI I'm currently running?
You can check your current Gemini CLI version using one of these methods:
- Run `gemini --version` or `gemini -v` from your terminal
- Check the globally installed version using your package manager:
- npm: `npm list -g @google/gemini-cli`
- pnpm: `pnpm list -g @google/gemini-cli`
- yarn: `yarn global list @google/gemini-cli`
- bun: `bun pm ls -g @google/gemini-cli`
- homebrew: `brew list --versions gemini-cli`
- Inside an active Gemini CLI session, use the `/about` command
### How do I update Gemini CLI to the latest version?
If you installed it globally via `npm`, update it using the command
-5
View File
@@ -99,11 +99,6 @@
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{
"label": "Git worktrees",
"badge": "🔬",
"slug": "docs/cli/git-worktrees"
},
{
"label": "Hooks",
"collapsed": true,
+13 -16
View File
@@ -35,19 +35,13 @@ const commonRestrictedSyntaxRules = [
message:
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
},
{
selector:
'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
message:
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
},
];
export default tseslint.config(
{
// Global ignores
ignores: [
'**/node_modules/**',
'node_modules/*',
'eslint.config.js',
'packages/**/dist/**',
'bundle/**',
@@ -56,7 +50,7 @@ export default tseslint.config(
'dist/**',
'evals/**',
'packages/test-utils/**',
'.gemini/**',
'.gemini/skills/**',
'**/*.d.ts',
],
},
@@ -139,7 +133,16 @@ export default tseslint.config(
'no-cond-assign': 'error',
'no-debugger': 'error',
'no-duplicate-case': 'error',
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
'no-restricted-syntax': [
'error',
...commonRestrictedSyntaxRules,
{
selector:
'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
message:
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
},
],
'no-unsafe-finally': 'error',
'no-unused-expressions': 'off', // Disable base rule
'@typescript-eslint/no-unused-expressions': [
@@ -158,7 +161,6 @@ export default tseslint.config(
'@typescript-eslint/await-thenable': ['error'],
'@typescript-eslint/no-floating-promises': ['error'],
'@typescript-eslint/no-unnecessary-type-assertion': ['error'],
'@typescript-eslint/no-misused-spread': ['error'],
'no-restricted-imports': [
'error',
{
@@ -317,12 +319,7 @@ export default tseslint.config(
},
},
{
files: [
'./scripts/**/*.js',
'packages/*/scripts/**/*.js',
'esbuild.config.js',
'packages/core/scripts/**/*.{js,mjs}',
],
files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/core/scripts/**/*.{js,mjs}'],
languageOptions: {
globals: {
...globals.node,
+1 -18
View File
@@ -15,26 +15,9 @@ import fs from 'node:fs';
import path from 'node:path';
import { DEFAULT_GEMINI_MODEL } from '@google/gemini-cli-core';
/**
* Config overrides for evals, with tool-restriction fields explicitly
* forbidden. Evals must test against the full, default tool set to ensure
* realistic behavior.
*/
interface EvalConfigOverrides {
/** Restricting tools via excludeTools in evals is forbidden. */
excludeTools?: never;
/** Restricting tools via coreTools in evals is forbidden. */
coreTools?: never;
/** Restricting tools via allowedTools in evals is forbidden. */
allowedTools?: never;
/** Restricting tools via mainAgentTools in evals is forbidden. */
mainAgentTools?: never;
[key: string]: unknown;
}
export interface AppEvalCase {
name: string;
configOverrides?: EvalConfigOverrides;
configOverrides?: any;
prompt: string;
timeout?: number;
files?: Record<string, string>;
-25
View File
@@ -1,25 +0,0 @@
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('CliHelpAgent Delegation', () => {
evalTest('USUALLY_PASSES', {
name: 'should delegate to cli_help agent for subagent creation questions',
params: {
settings: {
experimental: {
enableAgents: true,
},
},
},
prompt: 'Help me create a subagent in this project',
timeout: 60000,
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs();
const toolCallIndex = toolLogs.findIndex(
(log) => log.toolRequest.name === 'cli_help',
);
expect(toolCallIndex).toBeGreaterThan(-1);
expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns
},
});
});
+4
View File
@@ -21,6 +21,7 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'file1.ts': 'console.log("no semi")',
@@ -64,6 +65,7 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'src/a.ts': 'export const a = 1;',
@@ -104,6 +106,7 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'README.md': 'This is a proyect.',
@@ -138,6 +141,7 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'src/VERSION': '1.2.3',
+4 -2
View File
@@ -12,9 +12,10 @@ import { appEvalTest } from './app-test-helper.js';
import { PolicyDecision } from '@google/gemini-cli-core';
describe('Model Steering Behavioral Evals', () => {
appEvalTest('USUALLY_PASSES', {
appEvalTest('ALWAYS_PASSES', {
name: 'Corrective Hint: Model switches task based on hint during tool turn',
configOverrides: {
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
modelSteering: true,
},
files: {
@@ -51,9 +52,10 @@ describe('Model Steering Behavioral Evals', () => {
},
});
appEvalTest('USUALLY_PASSES', {
appEvalTest('ALWAYS_PASSES', {
name: 'Suggestive Hint: Model incorporates user guidance mid-stream',
configOverrides: {
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
modelSteering: true,
},
files: {},
+60 -8
View File
@@ -16,7 +16,9 @@ describe('save_memory', () => {
const rememberingFavoriteColor = "Agent remembers user's favorite color";
evalTest('ALWAYS_PASSES', {
name: rememberingFavoriteColor,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `remember that my favorite color is blue.
what is my favorite color? tell me that and surround it with $ symbol`,
@@ -36,7 +38,9 @@ describe('save_memory', () => {
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
evalTest('USUALLY_PASSES', {
name: rememberingCommandRestrictions,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `I don't want you to ever run npm commands.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -55,7 +59,9 @@ describe('save_memory', () => {
const rememberingWorkflow = 'Agent remembers workflow preferences';
evalTest('USUALLY_PASSES', {
name: rememberingWorkflow,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `I want you to always lint after building.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -75,7 +81,9 @@ describe('save_memory', () => {
'Agent ignores temporary conversation details';
evalTest('ALWAYS_PASSES', {
name: ignoringTemporaryInformation,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `I'm going to get a coffee.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
@@ -98,7 +106,9 @@ describe('save_memory', () => {
const rememberingPetName = "Agent remembers user's pet's name";
evalTest('ALWAYS_PASSES', {
name: rememberingPetName,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `Please remember that my dog's name is Buddy.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -117,7 +127,9 @@ describe('save_memory', () => {
const rememberingCommandAlias = 'Agent remembers custom command aliases';
evalTest('ALWAYS_PASSES', {
name: rememberingCommandAlias,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `When I say 'start server', you should run 'npm run dev'.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -137,6 +149,18 @@ describe('save_memory', () => {
"Agent ignores workspace's database schema location";
evalTest('USUALLY_PASSES', {
name: ignoringDbSchemaLocation,
params: {
settings: {
tools: {
core: [
'save_memory',
'list_directory',
'read_file',
'run_shell_command',
],
},
},
},
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
@@ -156,7 +180,9 @@ describe('save_memory', () => {
"Agent remembers user's coding style preference";
evalTest('ALWAYS_PASSES', {
name: rememberingCodingStyle,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `I prefer to use tabs instead of spaces for indentation.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -176,6 +202,18 @@ describe('save_memory', () => {
'Agent ignores workspace build artifact location';
evalTest('USUALLY_PASSES', {
name: ignoringBuildArtifactLocation,
params: {
settings: {
tools: {
core: [
'save_memory',
'list_directory',
'read_file',
'run_shell_command',
],
},
},
},
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
@@ -194,6 +232,18 @@ describe('save_memory', () => {
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
evalTest('USUALLY_PASSES', {
name: ignoringMainEntryPoint,
params: {
settings: {
tools: {
core: [
'save_memory',
'list_directory',
'read_file',
'run_shell_command',
],
},
},
},
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
@@ -212,7 +262,9 @@ describe('save_memory', () => {
const rememberingBirthday = "Agent remembers user's birthday";
evalTest('ALWAYS_PASSES', {
name: rememberingBirthday,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `My birthday is on June 15th.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
+1 -17
View File
@@ -197,25 +197,9 @@ export function symlinkNodeModules(testDir: string) {
}
}
/**
* Settings that are forbidden in evals. Evals should never restrict which
* tools are available they must test against the full, default tool set
* to ensure realistic behavior.
*/
interface ForbiddenToolSettings {
tools?: {
/** Restricting core tools in evals is forbidden. */
core?: never;
[key: string]: unknown;
};
}
export interface EvalCase {
name: string;
params?: {
settings?: ForbiddenToolSettings & Record<string, unknown>;
[key: string]: unknown;
};
params?: Record<string, any>;
prompt: string;
timeout?: number;
files?: Record<string, string>;
-4
View File
@@ -16,10 +16,6 @@ export default defineConfig({
},
test: {
testTimeout: 300000, // 5 minutes
// Retry in CI but not nightly to avoid blocking on API error.
retry: process.env['VITEST_RETRY']
? parseInt(process.env['VITEST_RETRY'], 10)
: 3,
reporters: ['default', 'json'],
outputFile: {
json: 'evals/logs/report.json',
@@ -1,4 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have opened the page and the title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The task is complete. The page title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":20,"totalTokenCount":320}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Done."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":400,"candidatesTokenCount":5,"totalTokenCount":405}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title of https://example.com is \"Example Domain\". The browser session has been completed and cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
@@ -1,5 +0,0 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you with that."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and check if there is a heading"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"new_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"success":true,"summary":"SUCCESS_POLICY_TEST_COMPLETED"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Task completed successfully. The page has the heading \"Example Domain\"."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]}
-210
View File
@@ -1,210 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, poll } from './test-helper.js';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
import stripAnsi from 'strip-ansi';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const chromeAvailable = (() => {
try {
if (process.platform === 'darwin') {
execSync(
'test -d "/Applications/Google Chrome.app" || test -d "/Applications/Chromium.app"',
{
stdio: 'ignore',
},
);
} else if (process.platform === 'linux') {
execSync(
'which google-chrome || which chromium-browser || which chromium',
{ stdio: 'ignore' },
);
} else if (process.platform === 'win32') {
const chromePaths = [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
`${process.env['LOCALAPPDATA'] ?? ''}\\Google\\Chrome\\Application\\chrome.exe`,
];
const found = chromePaths.some((p) => existsSync(p));
if (!found) {
execSync('where chrome || where chromium', { stdio: 'ignore' });
}
} else {
return false;
}
return true;
} catch {
return false;
}
})();
describe.skipIf(!chromeAvailable)('browser-policy', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => {
await rig.cleanup();
});
it('should skip confirmation when "Allow all server tools for this session" is chosen', async () => {
rig.setup('browser-policy-skip-confirmation', {
fakeResponsesPath: join(__dirname, 'browser-policy.responses'),
settings: {
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: true,
sessionMode: 'isolated',
allowedDomains: ['example.com'],
},
},
},
});
// Manually trust the folder to avoid the dialog and enable option 3
const geminiDir = join(rig.homeDir!, '.gemini');
mkdirSync(geminiDir, { recursive: true });
// Write to trustedFolders.json
const trustedFoldersPath = join(geminiDir, 'trustedFolders.json');
const trustedFolders = {
[rig.testDir!]: 'TRUST_FOLDER',
};
writeFileSync(trustedFoldersPath, JSON.stringify(trustedFolders, null, 2));
// Force confirmation for browser agent.
// NOTE: We don't force confirm browser tools here because "Allow all server tools"
// adds a rule with ALWAYS_ALLOW_PRIORITY (3.9x) which would be overshadowed by
// a rule in the user tier (4.x) like the one from this TOML.
// By removing the explicit mcp rule, the first MCP tool will still prompt
// due to default approvalMode = 'default', and then "Allow all" will correctly
// bypass subsequent tools.
const policyFile = join(rig.testDir!, 'force-confirm.toml');
writeFileSync(
policyFile,
`
[[rule]]
name = "Force confirm browser_agent"
toolName = "browser_agent"
decision = "ask_user"
priority = 200
`,
);
// Update settings.json in both project and home directories to point to the policy file
for (const baseDir of [rig.testDir!, rig.homeDir!]) {
const settingsPath = join(baseDir, '.gemini', 'settings.json');
if (existsSync(settingsPath)) {
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
settings.policyPaths = [policyFile];
// Ensure folder trust is enabled
settings.security = settings.security || {};
settings.security.folderTrust = settings.security.folderTrust || {};
settings.security.folderTrust.enabled = true;
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
}
}
const run = await rig.runInteractive({
approvalMode: 'default',
env: {
GEMINI_CLI_INTEGRATION_TEST: 'true',
},
});
await run.sendKeys(
'Open https://example.com and check if there is a heading\r',
);
await run.sendKeys('\r');
// Handle confirmations.
// 1. Initial browser_agent delegation (likely only 3 options, so use option 1: Allow once)
await poll(
() => stripAnsi(run.output).toLowerCase().includes('action required'),
60000,
1000,
);
await run.sendKeys('1\r');
await new Promise((r) => setTimeout(r, 2000));
// Handle privacy notice
await poll(
() => stripAnsi(run.output).toLowerCase().includes('privacy notice'),
5000,
100,
);
await run.sendKeys('1\r');
await new Promise((r) => setTimeout(r, 5000));
// new_page (MCP tool, should have 4 options, use option 3: Allow all server tools)
await poll(
() => {
const stripped = stripAnsi(run.output).toLowerCase();
return (
stripped.includes('new_page') &&
stripped.includes('allow all server tools for this session')
);
},
60000,
1000,
);
// Select "Allow all server tools for this session" (option 3)
await run.sendKeys('3\r');
await new Promise((r) => setTimeout(r, 30000));
const output = stripAnsi(run.output).toLowerCase();
expect(output).toContain('browser_agent');
expect(output).toContain('completed successfully');
});
it('should show the visible warning when browser agent starts in existing session mode', async () => {
rig.setup('browser-session-warning', {
fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
settings: {
general: {
enableAutoUpdateNotification: false,
},
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
sessionMode: 'existing',
headless: true,
},
},
},
});
const stdout = await rig.runCommand(['Open https://example.com'], {
env: {
GEMINI_API_KEY: 'fake-key',
GEMINI_TELEMETRY_DISABLED: 'true',
DEV: 'true',
},
});
expect(stdout).toContain('saved logins will be visible');
});
});
+3 -7
View File
@@ -34,20 +34,16 @@ describe('extension install', () => {
writeFileSync(testServerPath, extension);
try {
const result = await rig.runCommand(
['--debug', 'extensions', 'install', `${rig.testDir!}`],
['extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension-install');
const listResult = await rig.runCommand([
'--debug',
'extensions',
'list',
]);
const listResult = await rig.runCommand(['extensions', 'list']);
expect(listResult).toContain('test-extension-install');
writeFileSync(testServerPath, extensionUpdate);
const updateResult = await rig.runCommand(
['--debug', 'extensions', 'update', `test-extension-install`],
['extensions', 'update', `test-extension-install`],
{ stdin: 'y\n' },
);
expect(updateResult).toContain('0.0.2');
+1 -1
View File
@@ -66,7 +66,7 @@ describe('extension reloading', () => {
}
const result = await rig.runCommand(
['--debug', 'extensions', 'install', `${rig.testDir!}`],
['extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
+3 -214
View File
@@ -7,10 +7,9 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, poll, normalizePath } from './test-helper.js';
import { join } from 'node:path';
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
import os from 'node:os';
import { writeFileSync } from 'node:fs';
describe('Hooks System Integration', { timeout: 120000 }, () => {
describe('Hooks System Integration', () => {
let rig: TestRig;
beforeEach(() => {
@@ -2017,10 +2016,6 @@ console.log(JSON.stringify({
// 3. Final setup with full settings
rig.setup('Hook Disabling Multiple Ops', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.disabled-via-command.responses',
),
settings: {
hooksConfig: {
enabled: true,
@@ -2235,7 +2230,7 @@ console.log(JSON.stringify({
// The hook should have stopped execution message (returned from tool)
expect(result).toContain(
'Agent execution stopped by hook: Emergency Stop triggered by hook',
'Agent execution stopped: Emergency Stop triggered by hook',
);
// Tool should NOT be called successfully (it was blocked/stopped)
@@ -2247,210 +2242,4 @@ console.log(JSON.stringify({
expect(writeFileCalls).toHaveLength(0);
});
});
describe('Hooks "ask" Decision Integration', () => {
it(
'should force confirmation prompt when hook returns "ask" decision even in YOLO mode',
{ timeout: 60000 },
async () => {
const testName =
'should force confirmation prompt when hook returns "ask" decision even in YOLO mode';
// 1. Setup hook script that returns 'ask' decision
const hookOutput = {
decision: 'ask',
systemMessage: 'Confirmation forced by security hook',
hookSpecificOutput: {
hookEventName: 'BeforeTool',
},
};
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
hookOutput,
)}));`;
// Create script path predictably
const scriptPath = join(os.tmpdir(), 'gemini-cli-tests-ask-hook.js');
writeFileSync(scriptPath, hookScript);
// 2. Setup rig with YOLO mode enabled but with the 'ask' hook
rig.setup(testName, {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.allow-tool.responses',
),
settings: {
debugMode: true,
tools: {
approval: 'yolo',
},
general: {
enableAutoUpdateNotification: false,
},
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
});
// Bypass terminal setup prompt and other startup banners
const stateDir = join(rig.homeDir!, '.gemini');
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, 'state.json'),
JSON.stringify({
terminalSetupPromptShown: true,
hasSeenScreenReaderNudge: true,
tipsShown: 100,
}),
);
// 3. Run interactive and verify prompt appears despite YOLO mode
const run = await rig.runInteractive();
// Wait for prompt to appear
await run.expectText('Type your message', 30000);
// Send prompt that will trigger write_file
await run.type('Create a file called ask-test.txt with content "test"');
await run.type('\r');
// Wait for the FORCED confirmation prompt to appear
// It should contain the system message from the hook
await run.expectText('Confirmation forced by security hook', 30000);
await run.expectText('Allow', 5000);
// 4. Approve the permission
await run.type('y');
await run.type('\r');
// Wait for command to execute
await run.expectText('approved.txt', 30000);
// Should find the tool call
const foundWriteFile = await rig.waitForToolCall('write_file');
expect(foundWriteFile).toBeTruthy();
// File should be created
const fileContent = rig.readFile('approved.txt');
expect(fileContent).toBe('Approved content');
},
);
it(
'should allow cancelling when hook forces "ask" decision',
{ timeout: 60000 },
async () => {
const testName =
'should allow cancelling when hook forces "ask" decision';
const hookOutput = {
decision: 'ask',
systemMessage: 'Confirmation forced for cancellation test',
hookSpecificOutput: {
hookEventName: 'BeforeTool',
},
};
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
hookOutput,
)}));`;
const scriptPath = join(
os.tmpdir(),
'gemini-cli-tests-ask-cancel-hook.js',
);
writeFileSync(scriptPath, hookScript);
rig.setup(testName, {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.allow-tool.responses',
),
settings: {
debugMode: true,
tools: {
approval: 'yolo',
},
general: {
enableAutoUpdateNotification: false,
},
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
});
// Bypass terminal setup prompt and other startup banners
const stateDir = join(rig.homeDir!, '.gemini');
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, 'state.json'),
JSON.stringify({
terminalSetupPromptShown: true,
hasSeenScreenReaderNudge: true,
tipsShown: 100,
}),
);
const run = await rig.runInteractive();
// Wait for prompt to appear
await run.expectText('Type your message', 30000);
await run.type(
'Create a file called cancel-test.txt with content "test"',
);
await run.type('\r');
await run.expectText(
'Confirmation forced for cancellation test',
30000,
);
// 4. Deny the permission using option 4
await run.type('4');
await run.type('\r');
// Wait for cancellation message
await run.expectText('Cancelled', 15000);
// Tool should NOT be called successfully
const toolLogs = rig.readToolLogs();
const writeFileCalls = toolLogs.filter(
(t) =>
t.toolRequest.name === 'write_file' &&
t.toolRequest.success === true,
);
expect(writeFileCalls).toHaveLength(0);
},
);
});
});
+5 -5
View File
@@ -22,7 +22,7 @@
"gemini": "bundle/gemini.js"
},
"devDependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
"@agentclientprotocol/sdk": "^0.12.0",
"@octokit/rest": "^22.0.0",
"@types/marked": "^5.0.2",
"@types/mime-types": "^3.0.1",
@@ -84,9 +84,9 @@
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.16.1.tgz",
"integrity": "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==",
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz",
"integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
@@ -17531,7 +17531,7 @@
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
"@agentclientprotocol/sdk": "^0.12.0",
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
+2 -2
View File
@@ -51,7 +51,7 @@
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
"lint": "eslint . --cache --max-warnings 0",
"lint": "eslint . --cache",
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
"lint:ci": "npm run lint:all",
"lint:all": "node scripts/lint.js",
@@ -87,7 +87,7 @@
"LICENSE"
],
"devDependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
"@agentclientprotocol/sdk": "^0.12.0",
"@octokit/rest": "^22.0.0",
"@types/marked": "^5.0.2",
"@types/mime-types": "^3.0.1",
+1 -1
View File
@@ -30,7 +30,7 @@
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
"@agentclientprotocol/sdk": "^0.12.0",
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
+8 -92
View File
@@ -177,9 +177,6 @@ describe('GeminiAgent', () => {
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
get config() {
return this;
},
} as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>;
mockSettings = {
merged: {
@@ -551,7 +548,7 @@ describe('GeminiAgent', () => {
});
expect(session.prompt).toHaveBeenCalled();
expect(result).toMatchObject({ stopReason: 'end_turn' });
expect(result).toEqual({ stopReason: 'end_turn' });
});
it('should delegate setMode to session', async () => {
@@ -659,12 +656,6 @@ describe('Session', () => {
getGitService: vi.fn().mockResolvedValue({} as GitService),
waitForMcpInit: vi.fn(),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
get config() {
return this;
},
get toolRegistry() {
return mockToolRegistry;
},
} as unknown as Mocked<Config>;
mockConnection = {
sessionUpdate: vi.fn(),
@@ -750,7 +741,7 @@ describe('Session', () => {
content: { type: 'text', text: 'Hello' },
},
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
expect(result).toEqual({ stopReason: 'end_turn' });
});
it('should handle /memory command', async () => {
@@ -767,7 +758,7 @@ describe('Session', () => {
prompt: [{ type: 'text', text: '/memory view' }],
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/memory view',
expect.any(Object),
@@ -789,7 +780,7 @@ describe('Session', () => {
prompt: [{ type: 'text', text: '/extensions list' }],
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/extensions list',
expect.any(Object),
@@ -811,7 +802,7 @@ describe('Session', () => {
prompt: [{ type: 'text', text: '/extensions explore' }],
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/extensions explore',
expect.any(Object),
@@ -833,7 +824,7 @@ describe('Session', () => {
prompt: [{ type: 'text', text: '/restore' }],
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/restore',
expect.any(Object),
@@ -855,7 +846,7 @@ describe('Session', () => {
prompt: [{ type: 'text', text: '/init' }],
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object));
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
@@ -903,13 +894,10 @@ describe('Session', () => {
update: expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'completed',
title: 'Test Tool',
locations: [],
kind: 'read',
}),
}),
);
expect(result).toMatchObject({ stopReason: 'end_turn' });
expect(result).toEqual({ stopReason: 'end_turn' });
});
it('should handle tool call permission request', async () => {
@@ -1318,18 +1306,6 @@ describe('Session', () => {
expect(path.resolve).toHaveBeenCalled();
expect(fs.stat).toHaveBeenCalled();
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'completed',
title: 'Read files',
locations: [],
kind: 'read',
}),
}),
);
// Verify ReadManyFilesTool was used (implicitly by checking if sendMessageStream was called with resolved content)
// Since we mocked ReadManyFilesTool to return specific content, we can check the args passed to sendMessageStream
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
@@ -1345,65 +1321,6 @@ describe('Session', () => {
);
});
it('should handle @path resolution error', async () => {
(path.resolve as unknown as Mock).mockReturnValue('/tmp/error.txt');
(fs.stat as unknown as Mock).mockResolvedValue({
isDirectory: () => false,
});
(isWithinRoot as unknown as Mock).mockReturnValue(true);
const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock;
MockReadManyFilesTool.mockImplementationOnce(() => ({
name: 'read_many_files',
kind: 'read',
build: vi.fn().mockReturnValue({
getDescription: () => 'Read files',
toolLocations: () => [],
execute: vi.fn().mockRejectedValue(new Error('File read failed')),
}),
}));
const stream = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream.mockResolvedValue(stream);
await expect(
session.prompt({
sessionId: 'session-1',
prompt: [
{ type: 'text', text: 'Read' },
{
type: 'resource_link',
uri: 'file://error.txt',
mimeType: 'text/plain',
name: 'error.txt',
},
],
}),
).rejects.toThrow('File read failed');
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'failed',
content: expect.arrayContaining([
expect.objectContaining({
content: expect.objectContaining({
text: expect.stringMatching(/File read failed/),
}),
}),
]),
kind: 'read',
}),
}),
);
});
it('should handle cancellation during prompt', async () => {
let streamController: ReadableStreamDefaultController<unknown>;
const stream = new ReadableStream({
@@ -1517,7 +1434,6 @@ describe('Session', () => {
content: expect.objectContaining({ text: 'Tool failed' }),
}),
]),
kind: 'read',
}),
}),
);
+36 -113
View File
@@ -47,7 +47,6 @@ import {
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
getDisplayString,
type AgentLoopContext,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -105,7 +104,7 @@ export class GeminiAgent {
private customHeaders: Record<string, string> | undefined;
constructor(
private context: AgentLoopContext,
private config: Config,
private settings: LoadedSettings,
private argv: CliArgs,
private connection: acp.AgentSideConnection,
@@ -149,7 +148,7 @@ export class GeminiAgent {
},
];
await this.context.config.initialize();
await this.config.initialize();
const version = await getVersion();
return {
protocolVersion: acp.PROTOCOL_VERSION,
@@ -221,7 +220,7 @@ export class GeminiAgent {
this.baseUrl = baseUrl;
this.customHeaders = headers;
await this.context.config.refreshAuth(
await this.config.refreshAuth(
method,
apiKey ?? this.apiKey,
baseUrl,
@@ -538,7 +537,7 @@ export class Session {
constructor(
private readonly id: string,
private readonly chat: GeminiChat,
private readonly context: AgentLoopContext,
private readonly config: Config,
private readonly connection: acp.AgentSideConnection,
private readonly settings: LoadedSettings,
) {}
@@ -553,15 +552,13 @@ export class Session {
}
setMode(modeId: acp.SessionModeId): acp.SetSessionModeResponse {
const availableModes = buildAvailableModes(
this.context.config.isPlanEnabled(),
);
const availableModes = buildAvailableModes(this.config.isPlanEnabled());
const mode = availableModes.find((m) => m.id === modeId);
if (!mode) {
throw new Error(`Invalid or unavailable mode: ${modeId}`);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this.context.config.setApprovalMode(mode.id as ApprovalMode);
this.config.setApprovalMode(mode.id as ApprovalMode);
return {};
}
@@ -582,7 +579,7 @@ export class Session {
}
setModel(modelId: acp.ModelId): acp.SetSessionModelResponse {
this.context.config.setModel(modelId);
this.config.setModel(modelId);
return {};
}
@@ -637,7 +634,7 @@ export class Session {
}
}
const tool = this.context.toolRegistry.getTool(toolCall.name);
const tool = this.config.getToolRegistry().getTool(toolCall.name);
await this.sendUpdate({
sessionUpdate: 'tool_call',
@@ -661,7 +658,7 @@ export class Session {
const pendingSend = new AbortController();
this.pendingPrompt = pendingSend;
await this.context.config.waitForMcpInit();
await this.config.waitForMcpInit();
const promptId = Math.random().toString(16).slice(2);
const chat = this.chat;
@@ -699,22 +696,10 @@ export class Session {
// It uses `parts` argument but effectively ignores it in current implementation
const handled = await this.handleCommand(commandText, parts);
if (handled) {
return {
stopReason: 'end_turn',
_meta: {
quota: {
token_count: { input_tokens: 0, output_tokens: 0 },
model_usage: [],
},
},
};
return { stopReason: 'end_turn' };
}
}
let totalInputTokens = 0;
let totalOutputTokens = 0;
const modelUsageMap = new Map<string, { input: number; output: number }>();
let nextMessage: Content | null = { role: 'user', parts };
while (nextMessage !== null) {
@@ -727,8 +712,8 @@ export class Session {
try {
const model = resolveModel(
this.context.config.getModel(),
(await this.context.config.getGemini31Launched?.()) ?? false,
this.config.getModel(),
(await this.config.getGemini31Launched?.()) ?? false,
);
const responseStream = await chat.sendMessageStream(
{ model },
@@ -739,25 +724,11 @@ export class Session {
);
nextMessage = null;
let turnInputTokens = 0;
let turnOutputTokens = 0;
let turnModelId = model;
for await (const resp of responseStream) {
if (pendingSend.signal.aborted) {
return { stopReason: CoreToolCallStatus.Cancelled };
}
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
turnInputTokens =
resp.value.usageMetadata.promptTokenCount ?? turnInputTokens;
turnOutputTokens =
resp.value.usageMetadata.candidatesTokenCount ?? turnOutputTokens;
if (resp.value.modelVersion) {
turnModelId = resp.value.modelVersion;
}
}
if (
resp.type === StreamEventType.CHUNK &&
resp.value.candidates &&
@@ -789,19 +760,6 @@ export class Session {
}
}
totalInputTokens += turnInputTokens;
totalOutputTokens += turnOutputTokens;
if (turnInputTokens > 0 || turnOutputTokens > 0) {
const existing = modelUsageMap.get(turnModelId) ?? {
input: 0,
output: 0,
};
existing.input += turnInputTokens;
existing.output += turnOutputTokens;
modelUsageMap.set(turnModelId, existing);
}
if (pendingSend.signal.aborted) {
return { stopReason: CoreToolCallStatus.Cancelled };
}
@@ -838,28 +796,7 @@ export class Session {
}
}
const modelUsageArray = Array.from(modelUsageMap.entries()).map(
([modelName, counts]) => ({
model: modelName,
token_count: {
input_tokens: counts.input,
output_tokens: counts.output,
},
}),
);
return {
stopReason: 'end_turn',
_meta: {
quota: {
token_count: {
input_tokens: totalInputTokens,
output_tokens: totalOutputTokens,
},
model_usage: modelUsageArray,
},
},
};
return { stopReason: 'end_turn' };
}
private async handleCommand(
@@ -867,9 +804,9 @@ export class Session {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
parts: Part[],
): Promise<boolean> {
const gitService = await this.context.config.getGitService();
const gitService = await this.config.getGitService();
const commandContext = {
agentContext: this.context,
config: this.config,
settings: this.settings,
git: gitService,
sendMessage: async (text: string) => {
@@ -905,7 +842,7 @@ export class Session {
const errorResponse = (error: Error) => {
const durationMs = Date.now() - startTime;
logToolCall(
this.context.config,
this.config,
new ToolCallEvent(
undefined,
fc.name ?? '',
@@ -935,7 +872,7 @@ export class Session {
return errorResponse(new Error('Missing function name'));
}
const toolRegistry = this.context.toolRegistry;
const toolRegistry = this.config.getToolRegistry();
const tool = toolRegistry.getTool(fc.name);
if (!tool) {
@@ -971,10 +908,7 @@ export class Session {
const params: acp.RequestPermissionRequest = {
sessionId: this.id,
options: toPermissionOptions(
confirmationDetails,
this.context.config,
),
options: toPermissionOptions(confirmationDetails, this.config),
toolCall: {
toolCallId: callId,
status: 'pending',
@@ -1032,15 +966,12 @@ export class Session {
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
title: invocation.getDescription(),
content: content ? [content] : [],
locations: invocation.toolLocations(),
kind: toAcpToolKind(tool.kind),
});
const durationMs = Date.now() - startTime;
logToolCall(
this.context.config,
this.config,
new ToolCallEvent(
undefined,
fc.name ?? '',
@@ -1054,7 +985,7 @@ export class Session {
),
);
this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
{
status: CoreToolCallStatus.Success,
request: {
@@ -1072,8 +1003,8 @@ export class Session {
fc.name,
callId,
toolResult.llmContent,
this.context.config.getActiveModel(),
this.context.config,
this.config.getActiveModel(),
this.config,
),
resultDisplay: toolResult.returnDisplay,
error: undefined,
@@ -1086,8 +1017,8 @@ export class Session {
fc.name,
callId,
toolResult.llmContent,
this.context.config.getActiveModel(),
this.context.config,
this.config.getActiveModel(),
this.config,
);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
@@ -1099,10 +1030,9 @@ export class Session {
content: [
{ type: 'content', content: { type: 'text', text: error.message } },
],
kind: toAcpToolKind(tool.kind),
});
this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
{
status: CoreToolCallStatus.Error,
request: {
@@ -1188,18 +1118,18 @@ export class Session {
const atPathToResolvedSpecMap = new Map<string, string>();
// Get centralized file discovery service
const fileDiscovery = this.context.config.getFileService();
const fileDiscovery = this.config.getFileService();
const fileFilteringOptions: FilterFilesOptions =
this.context.config.getFileFilteringOptions();
this.config.getFileFilteringOptions();
const pathSpecsToRead: string[] = [];
const contentLabelsForDisplay: string[] = [];
const ignoredPaths: string[] = [];
const toolRegistry = this.context.toolRegistry;
const toolRegistry = this.config.getToolRegistry();
const readManyFilesTool = new ReadManyFilesTool(
this.context.config,
this.context.messageBus,
this.config,
this.config.getMessageBus(),
);
const globTool = toolRegistry.getTool('glob');
@@ -1218,11 +1148,8 @@ export class Session {
let currentPathSpec = pathName;
let resolvedSuccessfully = false;
try {
const absolutePath = path.resolve(
this.context.config.getTargetDir(),
pathName,
);
if (isWithinRoot(absolutePath, this.context.config.getTargetDir())) {
const absolutePath = path.resolve(this.config.getTargetDir(), pathName);
if (isWithinRoot(absolutePath, this.config.getTargetDir())) {
const stats = await fs.stat(absolutePath);
if (stats.isDirectory()) {
currentPathSpec = pathName.endsWith('/')
@@ -1242,7 +1169,7 @@ export class Session {
}
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
if (this.context.config.getEnableRecursiveFileSearch() && globTool) {
if (this.config.getEnableRecursiveFileSearch() && globTool) {
this.debug(
`Path ${pathName} not found directly, attempting glob search.`,
);
@@ -1250,7 +1177,7 @@ export class Session {
const globResult = await globTool.buildAndExecute(
{
pattern: `**/*${pathName}*`,
path: this.context.config.getTargetDir(),
path: this.config.getTargetDir(),
},
abortSignal,
);
@@ -1264,7 +1191,7 @@ export class Session {
if (lines.length > 1 && lines[1]) {
const firstMatchAbsolute = lines[1].trim();
currentPathSpec = path.relative(
this.context.config.getTargetDir(),
this.config.getTargetDir(),
firstMatchAbsolute,
);
this.debug(
@@ -1397,10 +1324,7 @@ export class Session {
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
title: invocation.getDescription(),
content: content ? [content] : [],
locations: invocation.toolLocations(),
kind: toAcpToolKind(readManyFilesTool.kind),
});
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
@@ -1444,7 +1368,6 @@ export class Session {
},
},
],
kind: toAcpToolKind(readManyFilesTool.kind),
});
throw error;
@@ -1479,7 +1402,7 @@ export class Session {
}
debug(msg: string) {
if (this.context.config.getDebugMode()) {
if (this.config.getDebugMode()) {
debugLogger.warn(msg);
}
}
+2 -6
View File
@@ -97,9 +97,6 @@ describe('GeminiAgent Session Resume', () => {
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
get config() {
return this;
},
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
@@ -161,10 +158,9 @@ describe('GeminiAgent Session Resume', () => {
],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mockConfig as any).toolRegistry = {
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
};
});
(SessionSelector as unknown as Mock).mockImplementation(() => ({
resolveSession: vi.fn().mockResolvedValue({
+9 -10
View File
@@ -53,7 +53,7 @@ export class ListExtensionsCommand implements Command {
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensions = listExtensions(context.agentContext.config);
const extensions = listExtensions(context.config);
const data = extensions.length ? extensions : 'No extensions installed.';
return { name: this.name, data };
@@ -134,7 +134,7 @@ export class EnableExtensionCommand implements Command {
args: string[],
): Promise<CommandExecutionResponse> {
const enableContext = getEnableDisableContext(
context.agentContext.config,
context.config,
args,
'enable',
);
@@ -156,8 +156,7 @@ export class EnableExtensionCommand implements Command {
if (extension?.mcpServers) {
const mcpEnablementManager = McpServerEnablementManager.getInstance();
const mcpClientManager =
context.agentContext.config.getMcpClientManager();
const mcpClientManager = context.config.getMcpClientManager();
const enabledServers = await mcpEnablementManager.autoEnableServers(
Object.keys(extension.mcpServers),
);
@@ -192,7 +191,7 @@ export class DisableExtensionCommand implements Command {
args: string[],
): Promise<CommandExecutionResponse> {
const enableContext = getEnableDisableContext(
context.agentContext.config,
context.config,
args,
'disable',
);
@@ -224,7 +223,7 @@ export class InstallExtensionCommand implements Command {
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.agentContext.config.getExtensionLoader();
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
@@ -269,7 +268,7 @@ export class LinkExtensionCommand implements Command {
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.agentContext.config.getExtensionLoader();
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
@@ -314,7 +313,7 @@ export class UninstallExtensionCommand implements Command {
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.agentContext.config.getExtensionLoader();
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
@@ -370,7 +369,7 @@ export class RestartExtensionCommand implements Command {
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.agentContext.config.getExtensionLoader();
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return { name: this.name, data: 'Cannot restart extensions.' };
}
@@ -425,7 +424,7 @@ export class UpdateExtensionCommand implements Command {
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.agentContext.config.getExtensionLoader();
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return { name: this.name, data: 'Cannot update extensions.' };
}
+1 -1
View File
@@ -22,7 +22,7 @@ export class InitCommand implements Command {
context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
const targetDir = context.agentContext.config.getTargetDir();
const targetDir = context.config.getTargetDir();
if (!targetDir) {
throw new Error('Command requires a workspace.');
}
+6 -6
View File
@@ -49,7 +49,7 @@ export class ShowMemoryCommand implements Command {
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = showMemory(context.agentContext.config);
const result = showMemory(context.config);
return { name: this.name, data: result.content };
}
}
@@ -63,7 +63,7 @@ export class RefreshMemoryCommand implements Command {
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = await refreshMemory(context.agentContext.config);
const result = await refreshMemory(context.config);
return { name: this.name, data: result.content };
}
}
@@ -76,7 +76,7 @@ export class ListMemoryCommand implements Command {
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = listMemoryFiles(context.agentContext.config);
const result = listMemoryFiles(context.config);
return { name: this.name, data: result.content };
}
}
@@ -95,7 +95,7 @@ export class AddMemoryCommand implements Command {
return { name: this.name, data: result.content };
}
const toolRegistry = context.agentContext.toolRegistry;
const toolRegistry = context.config.getToolRegistry();
const tool = toolRegistry.getTool(result.toolName);
if (tool) {
const abortController = new AbortController();
@@ -106,10 +106,10 @@ export class AddMemoryCommand implements Command {
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
shellExecutionConfig: {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
sandboxManager: context.agentContext.sandboxManager,
sandboxManager: context.config.sandboxManager,
},
});
await refreshMemory(context.agentContext.config);
await refreshMemory(context.config);
return {
name: this.name,
data: `Added memory: "${textToAdd}"`,
+2 -3
View File
@@ -29,8 +29,7 @@ export class RestoreCommand implements Command {
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const { agentContext: agentContext, git: gitService } = context;
const { config } = agentContext;
const { config, git: gitService } = context;
const argsStr = args.join(' ');
try {
@@ -117,7 +116,7 @@ export class ListCheckpointsCommand implements Command {
readonly description = 'Lists all available checkpoints.';
async execute(context: CommandContext): Promise<CommandExecutionResponse> {
const { config } = context.agentContext;
const { config } = context;
try {
if (!config.getCheckpointingEnabled()) {
+2 -2
View File
@@ -4,11 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { AgentLoopContext, GitService } from '@google/gemini-cli-core';
import type { Config, GitService } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
export interface CommandContext {
agentContext: AgentLoopContext;
config: Config;
settings: LoadedSettings;
git?: GitService;
sendMessage: (text: string) => Promise<void>;
+1 -1
View File
@@ -14,7 +14,7 @@ export class AcpFileSystemService implements FileSystemService {
constructor(
private readonly connection: acp.AgentSideConnection,
private readonly sessionId: string,
private readonly capabilities: acp.FileSystemCapabilities,
private readonly capabilities: acp.FileSystemCapability,
private readonly fallback: FileSystemService,
) {}
@@ -12,46 +12,48 @@ import {
beforeEach,
afterEach,
type MockInstance,
type Mock,
} from 'vitest';
import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs';
import * as core from '@google/gemini-cli-core';
import {
ExtensionManager,
type inferInstallMetadata,
} from '../../config/extension-manager.js';
import type {
promptForConsentNonInteractive,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import type {
isWorkspaceTrusted,
loadTrustedFolders,
} from '../../config/trustedFolders.js';
import type * as fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
import * as path from 'node:path';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
const {
mockInstallOrUpdateExtension,
mockLoadExtensions,
mockExtensionManager,
mockRequestConsentNonInteractive,
mockPromptForConsentNonInteractive,
mockStat,
mockInferInstallMetadata,
mockIsWorkspaceTrusted,
mockLoadTrustedFolders,
mockDiscover,
} = vi.hoisted(() => {
const mockLoadExtensions = vi.fn();
const mockInstallOrUpdateExtension = vi.fn();
const mockExtensionManager = vi.fn().mockImplementation(() => ({
loadExtensions: mockLoadExtensions,
installOrUpdateExtension: mockInstallOrUpdateExtension,
}));
return {
mockLoadExtensions,
mockInstallOrUpdateExtension,
mockExtensionManager,
mockRequestConsentNonInteractive: vi.fn(),
mockPromptForConsentNonInteractive: vi.fn(),
mockStat: vi.fn(),
mockInferInstallMetadata: vi.fn(),
mockIsWorkspaceTrusted: vi.fn(),
mockLoadTrustedFolders: vi.fn(),
mockDiscover: vi.fn(),
};
});
const mockInstallOrUpdateExtension: Mock<
typeof ExtensionManager.prototype.installOrUpdateExtension
> = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive: Mock<
typeof requestConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockPromptForConsentNonInteractive: Mock<
typeof promptForConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
() => vi.fn(),
);
const mockIsWorkspaceTrusted: Mock<typeof isWorkspaceTrusted> = vi.hoisted(() =>
vi.fn(),
);
const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() =>
vi.fn(),
);
const mockDiscover: Mock<typeof core.FolderTrustDiscoveryService.discover> =
vi.hoisted(() => vi.fn());
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
@@ -82,7 +84,6 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
...(await importOriginal<
typeof import('../../config/extension-manager.js')
>()),
ExtensionManager: mockExtensionManager,
inferInstallMetadata: mockInferInstallMetadata,
}));
@@ -116,18 +117,19 @@ describe('handleInstall', () => {
let processSpy: MockInstance;
beforeEach(() => {
debugLogSpy = vi
.spyOn(core.debugLogger, 'log')
.mockImplementation(() => {});
debugErrorSpy = vi
.spyOn(core.debugLogger, 'error')
.mockImplementation(() => {});
debugLogSpy = vi.spyOn(core.debugLogger, 'log');
debugErrorSpy = vi.spyOn(core.debugLogger, 'error');
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockLoadExtensions.mockResolvedValue([]);
mockInstallOrUpdateExtension.mockReset();
vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue(
[],
);
vi.spyOn(
ExtensionManager.prototype,
'installOrUpdateExtension',
).mockImplementation(mockInstallOrUpdateExtension);
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
mockDiscover.mockResolvedValue({
@@ -161,7 +163,12 @@ describe('handleInstall', () => {
});
afterEach(() => {
mockInstallOrUpdateExtension.mockClear();
mockRequestConsentNonInteractive.mockClear();
mockStat.mockClear();
mockInferInstallMetadata.mockClear();
vi.clearAllMocks();
vi.restoreAllMocks();
});
function createMockExtension(
@@ -281,39 +288,6 @@ describe('handleInstall', () => {
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should pass promptForSetting when skipSettings is not provided', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'test-extension',
} as unknown as core.GeminiCLIExtension);
await handleInstall({
source: 'http://google.com',
});
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
requestSetting: promptForSetting,
}),
);
});
it('should pass null for requestSetting when skipSettings is true', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'test-extension',
} as unknown as core.GeminiCLIExtension);
await handleInstall({
source: 'http://google.com',
skipSettings: true,
});
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
requestSetting: null,
}),
);
});
it('should proceed if local path is already trusted', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
@@ -37,7 +37,6 @@ interface InstallArgs {
autoUpdate?: boolean;
allowPreRelease?: boolean;
consent?: boolean;
skipSettings?: boolean;
}
export async function handleInstall(args: InstallArgs) {
@@ -154,7 +153,7 @@ export async function handleInstall(args: InstallArgs) {
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent,
requestSetting: args.skipSettings ? null : promptForSetting,
requestSetting: promptForSetting,
settings,
});
await extensionManager.loadExtensions();
@@ -197,11 +196,6 @@ export const installCommand: CommandModule = {
type: 'boolean',
default: false,
})
.option('skip-settings', {
describe: 'Skip the configuration on install process.',
type: 'boolean',
default: false,
})
.check((argv) => {
if (!argv.source) {
throw new Error('The source argument must be provided.');
@@ -220,8 +214,6 @@ export const installCommand: CommandModule = {
allowPreRelease: argv['pre-release'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
skipSettings: argv['skip-settings'] as boolean | undefined,
});
await exitCli();
},
@@ -264,7 +264,6 @@ describe('mcp list command', () => {
config: {
'allowed-server': { url: 'http://allowed' },
},
requiredConfig: {},
},
};
-1
View File
@@ -54,7 +54,6 @@ export async function getMcpServersFromConfig(
return;
}
mcpServers[key] = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...server,
extension,
};
-72
View File
@@ -226,51 +226,6 @@ afterEach(() => {
});
describe('parseArguments', () => {
describe('worktree', () => {
it('should parse --worktree flag when provided with a name', async () => {
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
const settings = createTestMergedSettings();
settings.experimental.worktrees = true;
const argv = await parseArguments(settings);
expect(argv.worktree).toBe('my-feature');
});
it('should generate a random name when --worktree is provided without a name', async () => {
process.argv = ['node', 'script.js', '--worktree'];
const settings = createTestMergedSettings();
settings.experimental.worktrees = true;
const argv = await parseArguments(settings);
expect(argv.worktree).toBeDefined();
expect(argv.worktree).not.toBe('');
expect(typeof argv.worktree).toBe('string');
});
it('should throw an error when --worktree is used but experimental.worktrees is not enabled', async () => {
process.argv = ['node', 'script.js', '--worktree', 'feature'];
const settings = createTestMergedSettings();
settings.experimental.worktrees = false;
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments(settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
});
it.each([
{
description: 'long flags',
@@ -1716,7 +1671,6 @@ describe('loadCliConfig with admin.mcp.config', () => {
const serverA = config.getMcpServers()?.['serverA'];
expect(serverA).toEqual({
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...localMcpServers['serverA'],
type: 'sse',
url: 'https://admin-server-a.com/sse',
@@ -1767,7 +1721,6 @@ describe('loadCliConfig with admin.mcp.config', () => {
};
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
serverA: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...localMcpServers['serverA'],
includeTools: ['local_tool'],
timeout: 1234,
@@ -1810,7 +1763,6 @@ describe('loadCliConfig with admin.mcp.config', () => {
};
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
serverA: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...localMcpServers['serverA'],
includeTools: ['local_tool'],
},
@@ -2273,30 +2225,6 @@ describe('loadCliConfig tool exclusions', () => {
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should exclude ask_user in interactive mode when --acp is provided', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--acp'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
'test-session',
argv,
);
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should exclude ask_user in interactive mode when --experimental-acp is provided', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--experimental-acp'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
'test-session',
argv,
);
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => {
process.stdin.isTTY = false;
process.argv = [
+3 -144
View File
@@ -4,11 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import yargs from 'yargs';
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import * as path from 'node:path';
import { execa } from 'execa';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
import { skillsCommand } from '../commands/skills.js';
@@ -37,11 +36,7 @@ import {
Config,
resolveToRealPath,
applyAdminAllowlist,
applyRequiredServers,
getAdminBlockedMcpServersMessage,
getProjectRootForWorktree,
isGeminiWorktree,
type WorktreeSettings,
type HookDefinition,
type HookEventName,
type OutputFormat,
@@ -52,8 +47,6 @@ import {
type MergedSettings,
saveModelChange,
loadSettings,
isWorktreeEnabled,
type LoadedSettings,
} from './settings.js';
import { loadSandboxConfig } from './sandboxConfig.js';
@@ -80,7 +73,6 @@ export interface CliArgs {
debug: boolean | undefined;
prompt: string | undefined;
promptInteractive: string | undefined;
worktree?: string;
yolo: boolean | undefined;
approvalMode: string | undefined;
@@ -122,36 +114,6 @@ const coerceCommaSeparated = (values: string[]): string[] => {
);
};
/**
* Pre-parses the command line arguments to find the worktree flag.
* Used for early setup before full argument parsing with settings.
*/
export function getWorktreeArg(argv: string[]): string | undefined {
const result = yargs(hideBin(argv))
.help(false)
.version(false)
.option('worktree', { alias: 'w', type: 'string' })
.strict(false)
.exitProcess(false)
.parseSync();
if (result.worktree === undefined) return undefined;
return typeof result.worktree === 'string' ? result.worktree.trim() : '';
}
/**
* Checks if a worktree is requested via CLI and enabled in settings.
* Returns the requested name (can be empty string for auto-generated) or undefined.
*/
export function getRequestedWorktreeName(
settings: LoadedSettings,
): string | undefined {
if (!isWorktreeEnabled(settings)) {
return undefined;
}
return getWorktreeArg(process.argv);
}
export async function parseArguments(
settings: MergedSettings,
): Promise<CliArgs> {
@@ -195,20 +157,6 @@ export async function parseArguments(
description:
'Execute the provided prompt and continue in interactive mode',
})
.option('worktree', {
alias: 'w',
type: 'string',
skipValidation: true,
description:
'Start Gemini in a new git worktree. If no name is provided, one is generated automatically.',
coerce: (value: unknown): string => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed === '') {
return Math.random().toString(36).substring(2, 10);
}
return trimmed;
},
})
.option('sandbox', {
alias: 's',
type: 'boolean',
@@ -386,9 +334,6 @@ export async function parseArguments(
) {
return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`;
}
if (argv['worktree'] && !settings.experimental?.worktrees) {
return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.';
}
return true;
});
@@ -474,7 +419,6 @@ export interface LoadCliConfigOptions {
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
disabled?: string[];
};
worktreeSettings?: WorktreeSettings;
}
export async function loadCliConfig(
@@ -486,9 +430,6 @@ export async function loadCliConfig(
const { cwd = process.cwd(), projectHooks } = options;
const debugMode = isDebugMode(argv);
const worktreeSettings =
options.worktreeSettings ?? (await resolveWorktreeSettings(cwd));
if (argv.sandbox) {
process.env['GEMINI_SANDBOX'] = 'true';
}
@@ -707,16 +648,12 @@ export async function loadCliConfig(
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const isAcpMode = !!argv.acp || !!argv.experimentalAcp;
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive || isAcpMode) {
if (!interactive) {
// The Policy Engine natively handles headless safety by translating ASK_USER
// decisions to DENY. However, we explicitly block ask_user here to guarantee
// it can never be allowed via a high-priority policy rule when no human is present.
// We also exclude it in ACP mode as IDEs intercept tool calls and ask for permission,
// breaking conversational flows.
extraExcludes.push(ASK_USER_TOOL_NAME);
}
@@ -765,19 +702,6 @@ export async function loadCliConfig(
? defaultModel
: specifiedModel || defaultModel;
const sandboxConfig = await loadSandboxConfig(settings, argv);
if (sandboxConfig) {
const existingPaths = sandboxConfig.allowedPaths || [];
if (settings.tools.sandboxAllowedPaths?.length) {
sandboxConfig.allowedPaths = [
...new Set([...existingPaths, ...settings.tools.sandboxAllowedPaths]),
];
}
if (settings.tools.sandboxNetworkAccess !== undefined) {
sandboxConfig.networkAccess =
sandboxConfig.networkAccess || settings.tools.sandboxNetworkAccess;
}
}
const screenReader =
argv.screenReader !== undefined
? argv.screenReader
@@ -813,25 +737,7 @@ export async function loadCliConfig(
}
}
// Apply admin-required MCP servers (injected regardless of allowlist)
if (mcpEnabled) {
const requiredMcpConfig = settings.admin?.mcp?.requiredConfig;
if (requiredMcpConfig && Object.keys(requiredMcpConfig).length > 0) {
const requiredResult = applyRequiredServers(
mcpServers ?? {},
requiredMcpConfig,
);
mcpServers = requiredResult.mcpServers;
if (requiredResult.requiredServerNames.length > 0) {
coreEvents.emitConsoleLog(
'info',
`Admin-required MCP servers injected: ${requiredResult.requiredServerNames.join(', ')}`,
);
}
}
}
const isAcpMode = !!argv.acp || !!argv.experimentalAcp;
let clientName: string | undefined = undefined;
if (isAcpMode) {
const ide = detectIdeFromEnv();
@@ -860,7 +766,6 @@ export async function loadCliConfig(
importFormat: settings.context?.importFormat,
debugMode,
question,
worktreeSettings,
coreTools: settings.tools?.core || undefined,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
@@ -935,7 +840,6 @@ export async function loadCliConfig(
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
experimentalMemoryManager: settings.experimental?.memoryManager,
modelSteering: settings.experimental?.modelSteering,
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
toolOutputMasking: settings.experimental?.toolOutputMasking,
@@ -1002,48 +906,3 @@ function mergeExcludeTools(
]);
return Array.from(allExcludeTools);
}
async function resolveWorktreeSettings(
cwd: string,
): Promise<WorktreeSettings | undefined> {
let worktreePath: string | undefined;
try {
const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], {
cwd,
});
const toplevel = stdout.trim();
const projectRoot = await getProjectRootForWorktree(toplevel);
if (isGeminiWorktree(toplevel, projectRoot)) {
worktreePath = toplevel;
}
} catch (_e) {
return undefined;
}
if (!worktreePath) {
return undefined;
}
let worktreeBaseSha: string | undefined;
try {
const { stdout } = await execa('git', ['rev-parse', 'HEAD'], {
cwd: worktreePath,
});
worktreeBaseSha = stdout.trim();
} catch (e: unknown) {
debugLogger.debug(
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
);
}
if (!worktreeBaseSha) {
return undefined;
}
return {
name: path.basename(worktreePath),
path: worktreePath,
baseSha: worktreeBaseSha,
};
}
@@ -1,133 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { copyExtension } from './extension-manager.js';
describe('copyExtension permissions', () => {
let tempDir: string;
let sourceDir: string;
let destDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-permission-test-'));
sourceDir = path.join(tempDir, 'source');
destDir = path.join(tempDir, 'dest');
fs.mkdirSync(sourceDir);
});
afterEach(() => {
// Ensure we can delete the temp directory by making everything writable again
const makeWritableSync = (p: string) => {
try {
const stats = fs.lstatSync(p);
fs.chmodSync(p, stats.mode | 0o700);
if (stats.isDirectory()) {
fs.readdirSync(p).forEach((child) =>
makeWritableSync(path.join(p, child)),
);
}
} catch (_e) {
// Ignore errors during cleanup
}
};
if (fs.existsSync(tempDir)) {
makeWritableSync(tempDir);
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('should make destination writable even if source is read-only', async () => {
const fileName = 'test.txt';
const filePath = path.join(sourceDir, fileName);
fs.writeFileSync(filePath, 'hello');
// Make source read-only: 0o555 for directory, 0o444 for file
fs.chmodSync(filePath, 0o444);
fs.chmodSync(sourceDir, 0o555);
// Verify source is read-only
expect(() => fs.writeFileSync(filePath, 'fail')).toThrow();
// Perform copy
await copyExtension(sourceDir, destDir);
// Verify destination is writable
const destFilePath = path.join(destDir, fileName);
const destFileStats = fs.statSync(destFilePath);
const destDirStats = fs.statSync(destDir);
// Check that owner write bits are set (0o200)
expect(destFileStats.mode & 0o200).toBe(0o200);
expect(destDirStats.mode & 0o200).toBe(0o200);
// Verify we can actually write to the destination file
fs.writeFileSync(destFilePath, 'writable');
expect(fs.readFileSync(destFilePath, 'utf-8')).toBe('writable');
// Verify we can delete the destination (which requires write bit on destDir)
fs.rmSync(destFilePath);
expect(fs.existsSync(destFilePath)).toBe(false);
});
it('should handle nested directories with restrictive permissions', async () => {
const subDir = path.join(sourceDir, 'subdir');
fs.mkdirSync(subDir);
const fileName = 'nested.txt';
const filePath = path.join(subDir, fileName);
fs.writeFileSync(filePath, 'nested content');
// Make nested structure read-only
fs.chmodSync(filePath, 0o444);
fs.chmodSync(subDir, 0o555);
fs.chmodSync(sourceDir, 0o555);
// Perform copy
await copyExtension(sourceDir, destDir);
// Verify nested destination is writable
const destSubDir = path.join(destDir, 'subdir');
const destFilePath = path.join(destSubDir, fileName);
expect(fs.statSync(destSubDir).mode & 0o200).toBe(0o200);
expect(fs.statSync(destFilePath).mode & 0o200).toBe(0o200);
// Verify we can delete the whole destination tree
await fs.promises.rm(destDir, { recursive: true, force: true });
expect(fs.existsSync(destDir)).toBe(false);
});
it('should not follow symlinks or modify symlink targets', async () => {
const symlinkTarget = path.join(tempDir, 'external-target');
fs.writeFileSync(symlinkTarget, 'external content');
// Target is read-only
fs.chmodSync(symlinkTarget, 0o444);
const symlinkPath = path.join(sourceDir, 'symlink-file');
fs.symlinkSync(symlinkTarget, symlinkPath);
// Perform copy
await copyExtension(sourceDir, destDir);
const destSymlinkPath = path.join(destDir, 'symlink-file');
const destSymlinkStats = fs.lstatSync(destSymlinkPath);
// Verify it is still a symlink in the destination
expect(destSymlinkStats.isSymbolicLink()).toBe(true);
// Verify the target (external to the extension) was NOT modified
const targetStats = fs.statSync(symlinkTarget);
// Owner write bit should still NOT be set (0o200)
expect(targetStats.mode & 0o200).toBe(0o000);
// Clean up
fs.chmodSync(symlinkTarget, 0o644);
});
});
@@ -15,10 +15,6 @@ import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
const mockIntegrityManager = vi.hoisted(() => ({
verify: vi.fn().mockResolvedValue('verified'),
store: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
@@ -35,9 +31,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...actual,
homedir: mockHomedir,
ExtensionIntegrityManager: vi
.fn()
.mockImplementation(() => mockIntegrityManager),
loadAgentsFromDirectory: vi
.fn()
.mockImplementation(async () => ({ agents: [], errors: [] })),
@@ -71,7 +64,6 @@ describe('ExtensionManager skills validation', () => {
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn(),
workspaceDir: tempDir,
integrityManager: mockIntegrityManager,
});
});
@@ -147,7 +139,6 @@ describe('ExtensionManager skills validation', () => {
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: vi.fn(),
workspaceDir: tempDir,
integrityManager: mockIntegrityManager,
});
// 4. Load extensions
@@ -637,4 +637,64 @@ describe('ExtensionManager', () => {
);
});
});
describe('orphaned extension cleanup', () => {
it('should remove broken extension metadata on startup to allow re-installation', async () => {
const extName = 'orphaned-ext';
const sourceDir = path.join(tempHomeDir, 'valid-source');
fs.mkdirSync(sourceDir, { recursive: true });
fs.writeFileSync(
path.join(sourceDir, 'gemini-extension.json'),
JSON.stringify({ name: extName, version: '1.0.0' }),
);
// Link an extension successfully.
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceDir,
type: 'link',
});
const destinationPath = path.join(userExtensionsDir, extName);
const metadataPath = path.join(
destinationPath,
'.gemini-extension-install.json',
);
expect(fs.existsSync(metadataPath)).toBe(true);
// Simulate metadata corruption (e.g., pointing to a non-existent source).
fs.writeFileSync(
metadataPath,
JSON.stringify({ source: '/NON_EXISTENT_PATH', type: 'link' }),
);
// Simulate CLI startup. The manager should detect the broken link
// and proactively delete the orphaned metadata directory.
const newManager = new ExtensionManager({
settings: createTestMergedSettings(),
workspaceDir: tempWorkspaceDir,
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null,
integrityManager: mockIntegrityManager,
});
await newManager.loadExtensions();
// Verify the extension failed to load and was proactively cleaned up.
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
false,
);
expect(fs.existsSync(destinationPath)).toBe(false);
// Verify the system is self-healed and allows re-linking to the valid source.
await newManager.installOrUpdateExtension({
source: sourceDir,
type: 'link',
});
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
true,
);
});
});
});
+11 -25
View File
@@ -982,11 +982,18 @@ Would you like to attempt to install via "git clone" instead?`,
plan: config.plan,
};
} catch (e) {
debugLogger.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
e,
)}`,
const extName = path.basename(extensionDir);
debugLogger.warn(
`Warning: Removing broken extension ${extName}: ${getErrorMessage(e)}`,
);
try {
await fs.promises.rm(extensionDir, { recursive: true, force: true });
} catch (rmError) {
debugLogger.error(
`Failed to remove broken extension directory ${extensionDir}:`,
rmError,
);
}
return null;
}
}
@@ -1241,32 +1248,11 @@ function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
return Object.freeze(rest);
}
/**
* Recursively ensures that the owner has write permissions for all files
* and directories within the target path.
*/
async function makeWritableRecursive(targetPath: string): Promise<void> {
const stats = await fs.promises.lstat(targetPath);
if (stats.isDirectory()) {
// Ensure directory is rwx for the owner (0o700)
await fs.promises.chmod(targetPath, stats.mode | 0o700);
const children = await fs.promises.readdir(targetPath);
for (const child of children) {
await makeWritableRecursive(path.join(targetPath, child));
}
} else if (stats.isFile()) {
// Ensure file is rw for the owner (0o600)
await fs.promises.chmod(targetPath, stats.mode | 0o600);
}
}
export async function copyExtension(
source: string,
destination: string,
): Promise<void> {
await fs.promises.cp(source, destination, { recursive: true });
await makeWritableRecursive(destination);
}
function getContextFileNames(config: ExtensionConfig): string[] {
+10 -18
View File
@@ -249,10 +249,8 @@ describe('extension tests', () => {
expect(extensions[0].name).toBe('test-extension');
});
it('should skip the extension if a context file path is outside the extension directory and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should log a warning and remove the extension if a context file path is outside the extension directory', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'traversal-extension',
@@ -662,10 +660,8 @@ name = "yolo-checker"
expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}');
});
it('should skip an extension with invalid JSON config and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should remove an extension with invalid JSON config and log a warning', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Good extension
createExtension({
@@ -686,17 +682,15 @@ name = "yolo-checker"
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
`Warning: Removing broken extension bad-ext: Failed to load extension config from ${badConfigPath}`,
),
);
consoleSpy.mockRestore();
});
it('should skip an extension with missing "name" in config and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should remove an extension with missing "name" in config and log a warning', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Good extension
createExtension({
@@ -717,7 +711,7 @@ name = "yolo-checker"
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
`Warning: Removing broken extension bad-ext-no-name: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
),
);
@@ -743,10 +737,8 @@ name = "yolo-checker"
expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
});
it('should log an error for invalid extension names during loading', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should log a warning for invalid extension names during loading', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'bad_name',
@@ -59,9 +59,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
});
async function expectConsentSnapshot(consentString: string) {
const renderResult = await render(
React.createElement(Text, null, consentString),
);
const renderResult = render(React.createElement(Text, null, consentString));
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
}
@@ -36,8 +36,6 @@ vi.mock('node:fs', async (importOriginal) => {
rm: vi.fn(),
cp: vi.fn(),
readFile: vi.fn(),
lstat: vi.fn(),
chmod: vi.fn(),
},
};
});
@@ -145,11 +143,6 @@ describe('extensionUpdates', () => {
vi.mocked(fs.promises.rm).mockResolvedValue(undefined);
vi.mocked(fs.promises.cp).mockResolvedValue(undefined);
vi.mocked(fs.promises.readdir).mockResolvedValue([]);
vi.mocked(fs.promises.lstat).mockResolvedValue({
isDirectory: () => true,
mode: 0o755,
} as unknown as fs.Stats);
vi.mocked(fs.promises.chmod).mockResolvedValue(undefined);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
@@ -13,7 +13,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...actual,
Storage: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...actual.Storage,
getGlobalGeminiDir: () => '/virtual-home/.gemini',
},
@@ -516,9 +516,7 @@ describe('Policy Engine Integration Tests', () => {
);
expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server
const readOnlyToolRule = rules.find(
(r) => r.toolName === 'glob' && !r.subagent,
);
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5);
@@ -675,7 +673,7 @@ describe('Policy Engine Integration Tests', () => {
const server1Rule = rules.find((r) => r.toolName === 'mcp_server1_*');
expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier)
const globRule = rules.find((r) => r.toolName === 'glob' && !r.subagent);
const globRule = rules.find((r) => r.toolName === 'glob');
// Priority 70 in default tier → 1.07
expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only
@@ -338,8 +338,6 @@ describe('loadSandboxConfig', () => {
sandbox: {
enabled: true,
command: 'podman',
allowedPaths: [],
networkAccess: false,
},
},
},
@@ -355,8 +353,6 @@ describe('loadSandboxConfig', () => {
sandbox: {
enabled: true,
image: 'custom/image',
allowedPaths: [],
networkAccess: false,
},
},
},
@@ -371,8 +367,6 @@ describe('loadSandboxConfig', () => {
tools: {
sandbox: {
enabled: false,
allowedPaths: [],
networkAccess: false,
},
},
},
@@ -388,7 +382,6 @@ describe('loadSandboxConfig', () => {
sandbox: {
enabled: true,
allowedPaths: ['/settings-path'],
networkAccess: false,
},
},
},
+3 -16
View File
@@ -29,7 +29,6 @@ const VALID_SANDBOX_COMMANDS = [
'sandbox-exec',
'runsc',
'lxc',
'windows-native',
];
function isSandboxCommand(
@@ -76,15 +75,8 @@ function getSandboxCommand(
'gVisor (runsc) sandboxing is only supported on Linux',
);
}
// windows-native is only supported on Windows
if (sandbox === 'windows-native' && os.platform() !== 'win32') {
throw new FatalSandboxError(
'Windows native sandboxing is only supported on Windows',
);
}
// confirm that specified command exists (unless it's built-in)
if (sandbox !== 'windows-native' && !commandExists.sync(sandbox)) {
// confirm that specified command exists
if (!commandExists.sync(sandbox)) {
throw new FatalSandboxError(
`Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
);
@@ -157,12 +149,7 @@ export async function loadSandboxConfig(
customImage ??
packageJson?.config?.sandboxImageUri;
const isNative =
command === 'windows-native' ||
command === 'sandbox-exec' ||
command === 'lxc';
return command && (image || isNative)
return command && image
? { enabled: true, allowedPaths, networkAccess, command, image }
: undefined;
}
-22
View File
@@ -2751,28 +2751,6 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.admin?.mcp?.config).toEqual(mcpServers);
});
it('should map requiredMcpConfig from remote settings', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const requiredMcpConfig = {
'corp-tool': {
url: 'https://mcp.corp/tool',
type: 'http' as const,
trust: true,
},
};
loadedSettings.setRemoteAdminSettings({
mcpSetting: {
mcpEnabled: true,
requiredMcpConfig,
},
});
expect(loadedSettings.merged.admin?.mcp?.requiredConfig).toEqual(
requiredMcpConfig,
);
});
it('should set skills based on unmanagedCapabilitiesEnabled', () => {
const loadedSettings = loadSettings();
loadedSettings.setRemoteAdminSettings({
-5
View File
@@ -480,7 +480,6 @@ export class LoadedSettings {
admin.mcp = {
enabled: mcpSetting?.mcpEnabled,
config: mcpSetting?.mcpConfig?.mcpServers,
requiredConfig: mcpSetting?.requiredMcpConfig,
};
admin.extensions = {
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled,
@@ -632,10 +631,6 @@ export function resetSettingsCacheForTesting() {
settingsCache.clear();
}
export function isWorktreeEnabled(settings: LoadedSettings): boolean {
return settings.merged.experimental.worktrees;
}
/**
* Loads settings from user and workspace directories.
* Project settings override user settings.
@@ -538,32 +538,8 @@ describe('SettingsSchema', () => {
}
};
const visitJsonSchema = (jsonSchema: Record<string, unknown>) => {
const ref = jsonSchema['ref'];
if (typeof ref === 'string') {
referenced.add(ref);
}
const properties = jsonSchema['properties'];
if (
properties &&
typeof properties === 'object' &&
!Array.isArray(properties)
) {
Object.values(properties as Record<string, unknown>).forEach((prop) =>
visitJsonSchema(prop as Record<string, unknown>),
);
}
const items = jsonSchema['items'];
if (items && typeof items === 'object' && !Array.isArray(items)) {
visitJsonSchema(items as Record<string, unknown>);
}
};
Object.values(schema).forEach(visitDefinition);
// Also visit all definitions to find nested references
Object.values(SETTINGS_SCHEMA_DEFINITIONS).forEach(visitJsonSchema);
// Ensure definitions map doesn't accumulate stale entries.
Object.keys(SETTINGS_SCHEMA_DEFINITIONS).forEach((key) => {
if (!referenced.has(key)) {
+7 -194
View File
@@ -12,9 +12,7 @@
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_MODEL_CONFIGS,
AuthProviderType,
type MCPServerConfig,
type RequiredMcpServerConfig,
type BugCommandSettings,
type TelemetrySettings,
type AuthType,
@@ -1083,20 +1081,6 @@ const SETTINGS_SCHEMA = {
ref: 'ModelResolution',
},
},
modelChains: {
type: 'object',
label: 'Model Chains',
category: 'Model',
requiresRestart: true,
default: DEFAULT_MODEL_CONFIGS.modelChains,
description:
'Availability policy chains defining fallback behavior for models.',
showInDialog: false,
additionalProperties: {
type: 'array',
ref: 'ModelPolicyChain',
},
},
},
},
@@ -1198,26 +1182,6 @@ const SETTINGS_SCHEMA = {
'Disable user input on browser window during automation.',
showInDialog: false,
},
confirmSensitiveActions: {
type: 'boolean',
label: 'Confirm Sensitive Actions',
category: 'Advanced',
requiresRestart: true,
default: false,
description:
'Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script).',
showInDialog: true,
},
blockFileUploads: {
type: 'boolean',
label: 'Block File Uploads',
category: 'Advanced',
requiresRestart: true,
default: false,
description:
'Hard-block file upload requests from the browser agent.',
showInDialog: true,
},
},
},
},
@@ -1380,30 +1344,10 @@ const SETTINGS_SCHEMA = {
description: oneLine`
Legacy full-process sandbox execution environment.
Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile,
or specify an explicit sandbox command (e.g., "docker", "podman", "lxc", "windows-native").
or specify an explicit sandbox command (e.g., "docker", "podman", "lxc").
`,
showInDialog: false,
},
sandboxAllowedPaths: {
type: 'array',
label: 'Sandbox Allowed Paths',
category: 'Tools',
requiresRestart: true,
default: [] as string[],
description:
'List of additional paths that the sandbox is allowed to access.',
showInDialog: true,
items: { type: 'string' },
},
sandboxNetworkAccess: {
type: 'boolean',
label: 'Sandbox Network Access',
category: 'Tools',
requiresRestart: true,
default: false,
description: 'Whether the sandbox is allowed to access the network.',
showInDialog: true,
},
shell: {
type: 'object',
label: 'Shell',
@@ -1926,16 +1870,6 @@ const SETTINGS_SCHEMA = {
description: 'Enable local and remote subagents.',
showInDialog: false,
},
worktrees: {
type: 'boolean',
label: 'Enable Git Worktrees',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable automated Git worktree management for parallel work.',
showInDialog: true,
},
extensionManagement: {
type: 'boolean',
label: 'Extension Management',
@@ -2111,16 +2045,6 @@ const SETTINGS_SCHEMA = {
},
},
},
memoryManager: {
type: 'boolean',
label: 'Memory Manager Agent',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
showInDialog: true,
},
topicUpdateNarration: {
type: 'boolean',
label: 'Topic & Update Narration',
@@ -2467,7 +2391,7 @@ const SETTINGS_SCHEMA = {
category: 'Admin',
requiresRestart: false,
default: {} as Record<string, MCPServerConfig>,
description: 'Admin-configured MCP servers (allowlist).',
description: 'Admin-configured MCP servers.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
additionalProperties: {
@@ -2475,20 +2399,6 @@ const SETTINGS_SCHEMA = {
ref: 'MCPServerConfig',
},
},
requiredConfig: {
type: 'object',
label: 'Required MCP Config',
category: 'Admin',
requiresRestart: false,
default: {} as Record<string, RequiredMcpServerConfig>,
description: 'Admin-required MCP servers that are always injected.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
additionalProperties: {
type: 'object',
ref: 'RequiredMcpServerConfig',
},
},
},
},
skills: {
@@ -2613,72 +2523,11 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
type: 'string',
description:
'Authentication provider used for acquiring credentials (for example `dynamic_discovery`).',
enum: Object.values(AuthProviderType),
},
targetAudience: {
type: 'string',
description:
'OAuth target audience (CLIENT_ID.apps.googleusercontent.com).',
},
targetServiceAccount: {
type: 'string',
description:
'Service account email to impersonate (name@project.iam.gserviceaccount.com).',
},
},
},
RequiredMcpServerConfig: {
type: 'object',
description:
'Admin-required MCP server configuration (remote transports only).',
additionalProperties: false,
properties: {
url: {
type: 'string',
description: 'URL for the required MCP server.',
},
type: {
type: 'string',
description: 'Transport type for the required server.',
enum: ['sse', 'http'],
},
headers: {
type: 'object',
description: 'Additional HTTP headers sent to the server.',
additionalProperties: { type: 'string' },
},
timeout: {
type: 'number',
description: 'Timeout in milliseconds for MCP requests.',
},
trust: {
type: 'boolean',
description:
'Marks the server as trusted. Defaults to true for admin-required servers.',
},
description: {
type: 'string',
description: 'Human-readable description of the server.',
},
includeTools: {
type: 'array',
description: 'Subset of tools enabled for this server.',
items: { type: 'string' },
},
excludeTools: {
type: 'array',
description: 'Tools disabled for this server.',
items: { type: 'string' },
},
oauth: {
type: 'object',
description: 'OAuth configuration for authenticating with the server.',
additionalProperties: true,
},
authProviderType: {
type: 'string',
description: 'Authentication provider used for acquiring credentials.',
enum: Object.values(AuthProviderType),
enum: [
'dynamic_discovery',
'google_credentials',
'service_account_impersonation',
],
},
targetAudience: {
type: 'string',
@@ -3018,42 +2867,6 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
},
},
},
ModelPolicyChain: {
type: 'array',
description: 'A chain of model policies for fallback behavior.',
items: {
type: 'object',
ref: 'ModelPolicy',
},
},
ModelPolicy: {
type: 'object',
description:
'Defines the policy for a single model in the availability chain.',
properties: {
model: { type: 'string' },
isLastResort: { type: 'boolean' },
actions: {
type: 'object',
properties: {
terminal: { type: 'string', enum: ['silent', 'prompt'] },
transient: { type: 'string', enum: ['silent', 'prompt'] },
not_found: { type: 'string', enum: ['silent', 'prompt'] },
unknown: { type: 'string', enum: ['silent', 'prompt'] },
},
},
stateTransitions: {
type: 'object',
properties: {
terminal: { type: 'string', enum: ['terminal', 'sticky_retry'] },
transient: { type: 'string', enum: ['terminal', 'sticky_retry'] },
not_found: { type: 'string', enum: ['terminal', 'sticky_retry'] },
unknown: { type: 'string', enum: ['terminal', 'sticky_retry'] },
},
},
},
required: ['model'],
},
};
export function getSettingsSchema(): SettingsSchemaType {
-4
View File
@@ -126,7 +126,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
clearInstance: vi.fn(),
},
coreEvents: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...actual.coreEvents,
emitFeedback: vi.fn(),
emitConsoleLog: vi.fn(),
@@ -200,8 +199,6 @@ vi.mock('./config/config.js', () => ({
networkAccess: false,
}),
isDebugMode: vi.fn(() => false),
getRequestedWorktreeName: vi.fn(() => undefined),
getWorktreeArg: vi.fn(() => undefined),
}));
vi.mock('read-package-up', () => ({
@@ -1509,7 +1506,6 @@ describe('startInteractiveUI', () => {
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
const mockConfigWithScreenReader = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getScreenReader: () => screenReader,
} as Config;
+15 -43
View File
@@ -9,7 +9,6 @@ import {
WarningPriority,
type Config,
type ResumedSessionData,
type WorktreeInfo,
type OutputPayload,
type ConsoleLogPayload,
type UserFeedbackPayload,
@@ -32,7 +31,6 @@ import {
ValidationRequiredError,
type AdminControlsSettings,
debugLogger,
isHeadlessMode,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
@@ -65,7 +63,6 @@ import {
registerTelemetryConfig,
setupSignalHandlers,
} from './utils/cleanup.js';
import { setupWorktree } from './utils/worktreeSetup.js';
import {
cleanupToolOutputFiles,
cleanupExpiredSessions,
@@ -213,37 +210,6 @@ export async function main() {
const settings = loadSettings();
loadSettingsHandle?.end();
// If a worktree is requested and enabled, set it up early.
// This must be awaited before any other async tasks that depend on CWD (like loadCliConfig)
// because setupWorktree calls process.chdir().
const requestedWorktree = cliConfig.getRequestedWorktreeName(settings);
let worktreeInfo: WorktreeInfo | undefined;
if (requestedWorktree !== undefined) {
const worktreeHandle = startupProfiler.start('setup_worktree');
worktreeInfo = await setupWorktree(requestedWorktree || undefined);
worktreeHandle?.end();
}
const cleanupOpsHandle = startupProfiler.start('cleanup_ops');
Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
])
.catch((e) => {
debugLogger.error('Early cleanup failed:', e);
})
.finally(() => {
cleanupOpsHandle?.end();
});
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argvPromise = parseArguments(settings.merged).finally(() => {
parseArgsHandle?.end();
});
const rawStartupWarningsPromise = getStartupWarnings();
// Report settings errors once during startup
settings.errors.forEach((error) => {
coreEvents.emitFeedback('warning', error.message);
@@ -257,7 +223,15 @@ export async function main() {
);
});
const argv = await argvPromise;
await Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
]);
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argv = await parseArguments(settings.merged);
parseArgsHandle?.end();
if (
(argv.allowedTools && argv.allowedTools.length > 0) ||
@@ -297,7 +271,6 @@ export async function main() {
const isDebugMode = cliConfig.isDebugMode(argv);
const consolePatcher = new ConsolePatcher({
stderr: true,
interactive: isHeadlessMode() ? false : true,
debugMode: isDebugMode,
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
@@ -453,7 +426,6 @@ export async function main() {
const loadConfigHandle = startupProfiler.start('load_cli_config');
const config = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
worktreeSettings: worktreeInfo,
});
loadConfigHandle?.end();
@@ -485,10 +457,12 @@ export async function main() {
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
});
// Launch cleanup expired sessions as a background task
cleanupExpiredSessions(config, settings.merged).catch((e) => {
// Cleanup sessions after config initialization
try {
await cleanupExpiredSessions(config, settings.merged);
} catch (e) {
debugLogger.error('Failed to cleanup expired sessions:', e);
});
}
if (config.getListExtensions()) {
debugLogger.log('Installed extensions:');
@@ -540,9 +514,7 @@ export async function main() {
});
}
const terminalHandle = startupProfiler.start('setup_terminal');
await setupTerminalAndTheme(config, settings);
terminalHandle?.end();
const initAppHandle = startupProfiler.start('initialize_app');
const initializationResult = await initializeApp(config, settings);
@@ -566,7 +538,7 @@ export async function main() {
isAlternateBufferEnabled(config),
config.getScreenReader(),
);
const rawStartupWarnings = await rawStartupWarningsPromise;
const rawStartupWarnings = await getStartupWarnings();
const startupWarnings: StartupWarning[] = [
...rawStartupWarnings.map((message) => ({
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
-2
View File
@@ -72,8 +72,6 @@ vi.mock('./config/config.js', () => ({
} as unknown as Config),
parseArguments: vi.fn().mockResolvedValue({}),
isDebugMode: vi.fn(() => false),
getRequestedWorktreeName: vi.fn(() => undefined),
getWorktreeArg: vi.fn(() => undefined),
}));
vi.mock('read-package-up', () => ({
@@ -29,7 +29,7 @@ describe('Model Steering Integration', () => {
configOverrides: { modelSteering: true },
});
await rig.initialize();
await rig.render();
rig.render();
await rig.waitForIdle();
rig.setToolPolicy('list_directory', PolicyDecision.ASK_USER);
@@ -1137,7 +1137,6 @@ describe('runNonInteractive', () => {
expect(
processStderrSpy.mock.calls.some(
// eslint-disable-next-line no-restricted-syntax
(call) => typeof call[0] === 'string' && call[0].includes('Cancelling'),
),
).toBe(true);
-1
View File
@@ -65,7 +65,6 @@ export async function runNonInteractive({
return promptIdContext.run(prompt_id, async () => {
const consolePatcher = new ConsolePatcher({
stderr: true,
interactive: false,
debugMode: config.getDebugMode(),
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
@@ -65,9 +65,9 @@ export const handleSlashCommand = async (
const logger = new Logger(config?.getSessionId() || '', config?.storage);
const commandContext: CommandContext = {
const context: CommandContext = {
services: {
agentContext: config,
config,
settings,
git: undefined,
logger,
@@ -84,7 +84,7 @@ export const handleSlashCommand = async (
},
};
const result = await commandToExecute.action(commandContext, args);
const result = await commandToExecute.action(context, args);
if (result) {
switch (result.type) {
@@ -266,7 +266,6 @@ describe('BuiltinCommandLoader', () => {
it('should include policies command when message bus integration is enabled', async () => {
const mockConfigWithMessageBus = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getEnableHooks: () => false,
getMcpEnabled: () => true,
@@ -31,14 +31,11 @@ describe('AtFileProcessor', () => {
mockConfig = {
// The processor only passes the config through, so we don't need a full mock.
get config() {
return this;
},
} as unknown as Config;
context = createMockCommandContext({
services: {
agentContext: mockConfig,
config: mockConfig,
},
});
@@ -63,7 +60,7 @@ describe('AtFileProcessor', () => {
const prompt: PartUnion[] = [{ text: 'Analyze @{file.txt}' }];
const contextWithoutConfig = createMockCommandContext({
services: {
agentContext: null,
config: null,
},
});
const result = await processor.process(prompt, contextWithoutConfig);
@@ -25,7 +25,7 @@ export class AtFileProcessor implements IPromptProcessor {
input: PromptPipelineContent,
context: CommandContext,
): Promise<PromptPipelineContent> {
const config = context.services.agentContext?.config;
const config = context.services.config;
if (!config) {
return input;
}
@@ -89,9 +89,6 @@ describe('ShellProcessor', () => {
getPolicyEngine: vi.fn().mockReturnValue({
check: mockPolicyEngineCheck,
}),
get config() {
return this as unknown as Config;
},
};
context = createMockCommandContext({
@@ -101,7 +98,7 @@ describe('ShellProcessor', () => {
args: 'default args',
},
services: {
agentContext: mockConfig as Config,
config: mockConfig as Config,
},
session: {
sessionShellAllowlist: new Set(),
@@ -123,7 +120,7 @@ describe('ShellProcessor', () => {
const prompt: PromptPipelineContent = createPromptPipelineContent('!{ls}');
const contextWithoutConfig = createMockCommandContext({
services: {
agentContext: null,
config: null,
},
});
@@ -74,7 +74,7 @@ export class ShellProcessor implements IPromptProcessor {
];
}
const config = context.services.agentContext?.config;
const config = context.services.config;
if (!config) {
throw new Error(
`Security configuration not loaded. Cannot verify shell command permissions for '${this.commandName}'. Aborting.`,
+8 -2
View File
@@ -5,6 +5,7 @@
*/
import { describe, it, afterEach, expect } from 'vitest';
import { act } from 'react';
import { AppRig } from './AppRig.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -30,7 +31,7 @@ describe('AppRig', () => {
configOverrides: { modelSteering: true },
});
await rig.initialize();
await rig.render();
rig.render();
await rig.waitForIdle();
// Set breakpoints on the canonical tool names
@@ -68,7 +69,12 @@ describe('AppRig', () => {
);
rig = new AppRig({ fakeResponsesPath });
await rig.initialize();
await rig.render();
await act(async () => {
rig!.render();
// Allow async initializations (like banners) to settle within the act boundary
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Wait for initial render
await rig.waitForIdle();
+6 -16
View File
@@ -11,11 +11,7 @@ import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { AppContainer } from '../ui/AppContainer.js';
import {
renderWithProviders,
type RenderInstance,
persistentStateMock,
} from './render.js';
import { renderWithProviders } from './render.js';
import {
makeFakeConfig,
type Config,
@@ -159,7 +155,7 @@ export interface PendingConfirmation {
}
export class AppRig {
private renderResult: RenderInstance | undefined;
private renderResult: ReturnType<typeof renderWithProviders> | undefined;
private config: Config | undefined;
private settings: LoadedSettings | undefined;
private testDir: string;
@@ -184,11 +180,6 @@ export class AppRig {
}
async initialize() {
persistentStateMock.setData({
terminalSetupPromptShown: true,
tipsShown: 10,
});
this.setupEnvironment();
resetSettingsCacheForTesting();
this.settings = this.createRigSettings();
@@ -235,8 +226,6 @@ export class AppRig {
private setupEnvironment() {
// Stub environment variables to avoid interference from developer's machine
vi.stubEnv('GEMINI_CLI_HOME', this.testDir);
vi.stubEnv('TERM_PROGRAM', 'other');
vi.stubEnv('VSCODE_GIT_IPC_HANDLE', '');
if (this.options.fakeResponsesPath) {
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
MockShellExecutionService.setPassthrough(false);
@@ -302,6 +291,7 @@ export class AppRig {
const newContentGeneratorConfig = {
authType: authMethod,
proxy: gcConfig.getProxy(),
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
};
@@ -403,12 +393,12 @@ export class AppRig {
return isAnyToolActive || isAwaitingConfirmation;
}
async render() {
render() {
if (!this.config || !this.settings)
throw new Error('AppRig not initialized');
await act(async () => {
this.renderResult = await renderWithProviders(
act(() => {
this.renderResult = renderWithProviders(
<AppContainer
config={this.config!}
version="test-version"
@@ -79,7 +79,7 @@ export async function toMatchSvgSnapshot(
}
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
const { isNot } = this as any;
let pass = true;
const invalidLines: Array<{ line: number; content: string }> = [];
@@ -108,6 +108,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
expect.extend({
toHaveOnlyValidCharacters,
toMatchSvgSnapshot,
@@ -46,19 +46,15 @@ describe('createMockCommandContext', () => {
const overrides = {
services: {
agentContext: { config: mockConfig },
config: mockConfig,
},
};
const context = createMockCommandContext(overrides);
expect(context.services.agentContext).toBeDefined();
expect(context.services.agentContext?.config?.getModel()).toBe(
'gemini-pro',
);
expect(context.services.agentContext?.config?.getProjectRoot()).toBe(
'/test/project',
);
expect(context.services.config).toBeDefined();
expect(context.services.config?.getModel()).toBe('gemini-pro');
expect(context.services.config?.getProjectRoot()).toBe('/test/project');
// Verify a default property on the same nested object is still there
expect(context.services.logger).toBeDefined();
@@ -36,13 +36,15 @@ export const createMockCommandContext = (
args: '',
},
services: {
agentContext: null,
config: null,
settings: {
merged: defaultMergedSettings,
setValue: vi.fn(),
forScope: vi.fn().mockReturnValue({ settings: {} }),
} as unknown as LoadedSettings,
git: undefined as GitService | undefined,
logger: {
log: vi.fn(),
logMessage: vi.fn(),
@@ -51,6 +53,7 @@ export const createMockCommandContext = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any, // Cast because Logger is a class.
},
ui: {
addItem: vi.fn(),
clear: vi.fn(),
@@ -69,6 +72,7 @@ export const createMockCommandContext = (
} as any,
session: {
sessionShellAllowlist: new Set<string>(),
stats: {
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
@@ -94,6 +98,7 @@ export const createMockCommandContext = (
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key];
const targetValue = output[key];
if (
@@ -104,6 +109,7 @@ export const createMockCommandContext = (
output[key] = merge(targetValue, sourceValue);
} else {
// If not, we do a direct assignment. This preserves Date objects and others.
output[key] = sourceValue;
}
}
@@ -44,7 +44,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getDeleteSession: vi.fn(() => undefined),
setSessionId: vi.fn(),
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
getWorktreeSettings: vi.fn(() => undefined),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
getAcpMode: vi.fn(() => false),
isBrowserLaunchSuppressed: vi.fn(() => false),
+39 -14
View File
@@ -12,18 +12,24 @@ import { waitFor } from './async.js';
describe('render', () => {
it('should render a component', async () => {
const { lastFrame, unmount } = await render(<Text>Hello World</Text>);
const { lastFrame, waitUntilReady, unmount } = render(
<Text>Hello World</Text>,
);
await waitUntilReady();
expect(lastFrame()).toBe('Hello World\n');
unmount();
});
it('should support rerender', async () => {
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
const { lastFrame, rerender, waitUntilReady, unmount } = render(
<Text>Hello</Text>,
);
await waitUntilReady();
expect(lastFrame()).toBe('Hello\n');
await act(async () => rerender(<Text>World</Text>));
await act(async () => {
rerender(<Text>World</Text>);
});
await waitUntilReady();
expect(lastFrame()).toBe('World\n');
unmount();
@@ -36,8 +42,10 @@ describe('render', () => {
return <Text>Hello</Text>;
}
const { unmount } = await render(<TestComponent />);
const { unmount, waitUntilReady } = render(<TestComponent />);
await waitUntilReady();
unmount();
expect(cleanupMock).toHaveBeenCalled();
});
});
@@ -46,27 +54,36 @@ describe('renderHook', () => {
it('should rerender with previous props when called without arguments', async () => {
const useTestHook = ({ value }: { value: number }) => {
const [count, setCount] = useState(0);
useEffect(() => setCount((c) => c + 1), [value]);
useEffect(() => {
setCount((c) => c + 1);
}, [value]);
return { count, value };
};
const { result, rerender, waitUntilReady, unmount } = await renderHook(
const { result, rerender, waitUntilReady, unmount } = renderHook(
useTestHook,
{ initialProps: { value: 1 } },
{
initialProps: { value: 1 },
},
);
await waitUntilReady();
expect(result.current.value).toBe(1);
await waitFor(() => expect(result.current.count).toBe(1));
// Rerender with new props
await act(async () => rerender({ value: 2 }));
await act(async () => {
rerender({ value: 2 });
});
await waitUntilReady();
expect(result.current.value).toBe(2);
await waitFor(() => expect(result.current.count).toBe(2));
// Rerender without arguments should use previous props (value: 2)
// This would previously crash or pass undefined if not fixed
await act(async () => rerender());
await act(async () => {
rerender();
});
await waitUntilReady();
expect(result.current.value).toBe(2);
// Count should not increase because value didn't change
@@ -81,11 +98,14 @@ describe('renderHook', () => {
};
const { result, rerender, waitUntilReady, unmount } =
await renderHook(useTestHook);
renderHook(useTestHook);
await waitUntilReady();
expect(result.current.count).toBe(0);
await act(async () => rerender());
await act(async () => {
rerender();
});
await waitUntilReady();
expect(result.current.count).toBe(0);
unmount();
@@ -93,14 +113,19 @@ describe('renderHook', () => {
it('should update props if undefined is passed explicitly', async () => {
const useTestHook = (val: string | undefined) => val;
const { result, rerender, waitUntilReady, unmount } = await renderHook(
const { result, rerender, waitUntilReady, unmount } = renderHook(
useTestHook,
{ initialProps: 'initial' },
{
initialProps: 'initial' as string | undefined,
},
);
await waitUntilReady();
expect(result.current).toBe('initial');
await act(async () => rerender(undefined));
await act(async () => {
rerender(undefined);
});
await waitUntilReady();
expect(result.current).toBeUndefined();
unmount();
+99 -60
View File
@@ -16,6 +16,8 @@ import { vi } from 'vitest';
import stripAnsi from 'strip-ansi';
import type React from 'react';
import { act, useState } from 'react';
import os from 'node:os';
import path from 'node:path';
import type { LoadedSettings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
@@ -42,7 +44,7 @@ import {
type OverflowState,
} from '../ui/contexts/OverflowContext.js';
import { type Config } from '@google/gemini-cli-core';
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
import { createMockSettings } from './settings.js';
@@ -51,7 +53,6 @@ import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
import { pickDefaultThemeName } from '../ui/themes/theme.js';
import { generateSvgForTerminal } from './svg.js';
import { loadCliConfig, type CliArgs } from '../config/config.js';
export const persistentStateMock = new FakePersistentState();
@@ -65,9 +66,7 @@ if (process.env['NODE_ENV'] === 'test') {
}
vi.mock('../utils/persistentState.js', () => ({
get persistentState() {
return persistentStateMock;
},
persistentState: persistentStateMock,
}));
vi.mock('../ui/utils/terminalUtils.js', () => ({
@@ -257,9 +256,13 @@ class XtermStdout extends EventEmitter {
return currentFrame !== '';
}
// If Ink expects nothing (no new static content and no dynamic output),
// we consider it a match because the terminal buffer will just hold the historical static content.
if (expectedFrame === '') {
// If both are empty, it's a match.
// We consider undefined lastRenderOutput as effectively empty for this check
// to support hook testing where Ink may skip rendering completely.
if (
(this.lastRenderOutput === undefined || expectedFrame === '') &&
currentFrame === ''
) {
return true;
}
@@ -267,8 +270,8 @@ class XtermStdout extends EventEmitter {
return false;
}
// If the terminal is empty but Ink expects something, it's not a match.
if (currentFrame === '') {
// If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match.
if (expectedFrame === '' || currentFrame === '') {
return false;
}
@@ -376,21 +379,15 @@ export type RenderInstance = {
capturedOverflowActions: OverflowActions | undefined;
};
export type RenderWithProvidersInstance = RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
};
const instances: InkInstance[] = [];
export const render = async (
// Wrapper around ink's render that ensures act() is called and uses Xterm for output
export const render = (
tree: React.ReactElement,
terminalWidth?: number,
): Promise<
Omit<RenderInstance, 'capturedOverflowState' | 'capturedOverflowActions'>
): Omit<
RenderInstance,
'capturedOverflowState' | 'capturedOverflowActions'
> => {
const cols = terminalWidth ?? 100;
// We use 1000 rows to avoid windows with incorrect snapshots if a correct
@@ -439,8 +436,6 @@ export const render = async (
instances.push(instance);
await stdout.waitUntilReady();
return {
rerender: (newTree: React.ReactElement) => {
act(() => {
@@ -491,6 +486,50 @@ export const simulateClick = async (
});
};
let mockConfigInternal: Config | undefined;
const getMockConfigInternal = (): Config => {
if (!mockConfigInternal) {
mockConfigInternal = makeFakeConfig({
targetDir: os.tmpdir(),
enableEventDrivenScheduler: true,
});
}
return mockConfigInternal;
};
const configProxy = new Proxy({} as Config, {
get(_target, prop) {
if (prop === 'getTargetDir') {
return () =>
path.join(
path.parse(process.cwd()).root,
'Users',
'test',
'project',
'foo',
'bar',
'and',
'some',
'more',
'directories',
'to',
'make',
'it',
'long',
);
}
if (prop === 'getUseBackgroundColor') {
return () => true;
}
const internal = getMockConfigInternal();
if (prop in internal) {
return internal[prop as keyof typeof internal];
}
throw new Error(`mockConfig does not have property ${String(prop)}`);
},
});
export const mockSettings = createMockSettings();
// A minimal mock UIState to satisfy the context provider.
@@ -600,7 +639,7 @@ const ContextCapture: React.FC<{ children: React.ReactNode }> = ({
return <>{children}</>;
};
export const renderWithProviders = async (
export const renderWithProviders = (
component: React.ReactElement,
{
shellFocus = true,
@@ -608,7 +647,8 @@ export const renderWithProviders = async (
uiState: providedUiState,
width,
mouseEventsEnabled = false,
config,
config = configProxy as unknown as Config,
uiActions,
persistentState,
appState = mockAppState,
@@ -626,7 +666,13 @@ export const renderWithProviders = async (
};
appState?: AppState;
} = {},
): Promise<RenderWithProvidersInstance> => {
): RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
} => {
const baseState: UIState = new Proxy(
{ ...baseMockUiState, ...providedUiState },
{
@@ -655,15 +701,8 @@ export const renderWithProviders = async (
persistentStateMock.mockClear();
const terminalWidth = width ?? baseState.terminalWidth;
if (!config) {
config = await loadCliConfig(
settings.merged,
'random-session-id',
{} as unknown as CliArgs,
{ cwd: '/' },
);
}
const finalSettings = settings;
const finalConfig = config;
const mainAreaWidth = terminalWidth;
@@ -693,8 +732,8 @@ export const renderWithProviders = async (
const wrapWithProviders = (comp: React.ReactElement) => (
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={config}>
<SettingsContext.Provider value={settings}>
<ConfigContext.Provider value={finalConfig}>
<SettingsContext.Provider value={finalSettings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider>
<ShellFocusContext.Provider value={shellFocus}>
@@ -705,7 +744,7 @@ export const renderWithProviders = async (
<UIActionsContext.Provider value={finalUIActions}>
<OverflowProvider>
<ToolActionsProvider
config={config}
config={finalConfig}
toolCalls={allToolCalls}
>
<AskUserActionsProvider
@@ -747,10 +786,7 @@ export const renderWithProviders = async (
</AppContext.Provider>
);
const renderResult = await render(
wrapWithProviders(component),
terminalWidth,
);
const renderResult = render(wrapWithProviders(component), terminalWidth);
return {
...renderResult,
@@ -764,20 +800,21 @@ export const renderWithProviders = async (
};
};
export async function renderHook<Result, Props>(
export function renderHook<Result, Props>(
renderCallback: (props: Props) => Result,
options?: {
initialProps?: Props;
wrapper?: React.ComponentType<{ children: React.ReactNode }>;
},
): Promise<{
): {
result: { current: Result };
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise<void>;
generateSvg: () => string;
}> {
} {
const result = { current: undefined as unknown as Result };
let currentProps = options?.initialProps as Props;
function TestComponent({
@@ -798,15 +835,17 @@ export async function renderHook<Result, Props>(
let waitUntilReady: () => Promise<void> = async () => {};
let generateSvg: () => string = () => '';
const renderResult = await render(
<Wrapper>
<TestComponent renderCallback={renderCallback} props={currentProps} />
</Wrapper>,
);
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
generateSvg = renderResult.generateSvg;
act(() => {
const renderResult = render(
<Wrapper>
<TestComponent renderCallback={renderCallback} props={currentProps} />
</Wrapper>,
);
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
generateSvg = renderResult.generateSvg;
});
function rerender(props?: Props) {
if (arguments.length > 0) {
@@ -824,7 +863,7 @@ export async function renderHook<Result, Props>(
return { result, rerender, unmount, waitUntilReady, generateSvg };
}
export async function renderHookWithProviders<Result, Props>(
export function renderHookWithProviders<Result, Props>(
renderCallback: (props: Props) => Result,
options: {
initialProps?: Props;
@@ -837,13 +876,13 @@ export async function renderHookWithProviders<Result, Props>(
mouseEventsEnabled?: boolean;
config?: Config;
} = {},
): Promise<{
): {
result: { current: Result };
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise<void>;
generateSvg: () => string;
}> {
} {
const result = { current: undefined as unknown as Result };
let setPropsFn: ((props: Props) => void) | undefined;
@@ -860,10 +899,10 @@ export async function renderHookWithProviders<Result, Props>(
const Wrapper = options.wrapper || (({ children }) => <>{children}</>);
let renderResult: RenderWithProvidersInstance;
let renderResult: ReturnType<typeof render>;
await act(async () => {
renderResult = await renderWithProviders(
act(() => {
renderResult = renderWithProviders(
<Wrapper>
{}
<TestComponent initialProps={options.initialProps as Props} />
+2
View File
@@ -46,6 +46,7 @@ export const createMockSettings = (
workspace,
isTrusted,
errors,
merged: mergedOverride,
...settingsOverrides
} = overrides;
@@ -60,6 +61,7 @@ export const createMockSettings = (
settings: settingsOverrides,
originalSettings: settingsOverrides,
},
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
isTrusted ?? true,
errors || [],
+99 -45
View File
@@ -94,10 +94,15 @@ describe('App', () => {
};
it('should render main content and composer when not quitting', async () => {
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
@@ -111,10 +116,15 @@ describe('App', () => {
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: quittingUIState,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Quitting...');
unmount();
@@ -128,10 +138,15 @@ describe('App', () => {
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: quittingUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('HistoryItemDisplay');
expect(lastFrame()).toContain('Quitting...');
@@ -144,10 +159,15 @@ describe('App', () => {
dialogsVisible: true,
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: dialogUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
@@ -167,10 +187,15 @@ describe('App', () => {
[stateKey]: true,
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
unmount();
@@ -180,10 +205,15 @@ describe('App', () => {
it('should render ScreenReaderAppLayout when screen reader is enabled', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Footer');
@@ -195,10 +225,15 @@ describe('App', () => {
it('should render DefaultAppLayout when screen reader is not enabled', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
@@ -246,11 +281,15 @@ describe('App', () => {
vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: stateWithConfirmingTool,
config: configWithExperiment,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: stateWithConfirmingTool,
config: configWithExperiment,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
@@ -263,20 +302,30 @@ describe('App', () => {
describe('Snapshots', () => {
it('renders default layout correctly', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders screen reader layout correctly', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -286,10 +335,15 @@ describe('App', () => {
...mockUIState,
dialogsVisible: true,
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />,
{
uiState: dialogUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
File diff suppressed because it is too large Load Diff
+26 -15
View File
@@ -103,7 +103,7 @@ import {
useOverflowActions,
useOverflowState,
} from './contexts/OverflowContext.js';
import { useErrorCount } from './hooks/useConsoleMessages.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js';
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
@@ -552,7 +552,8 @@ export const AppContainer = (props: AppContainerProps) => {
};
}, [settings]);
const { errorCount, clearErrorCount } = useErrorCount();
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config);
// Derive widths for InputPrompt using shared helper
@@ -1007,18 +1008,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
Date.now(),
);
try {
let flattenedMemory: string;
let fileCount: number;
const { memoryContent, fileCount } =
await refreshServerHierarchicalMemory(config);
if (config.isJitContextEnabled()) {
await config.getContextManager()?.refresh();
flattenedMemory = flattenMemory(config.getUserMemory());
fileCount = config.getGeminiMdFileCount();
} else {
const result = await refreshServerHierarchicalMemory(config);
flattenedMemory = flattenMemory(result.memoryContent);
fileCount = result.fileCount;
}
const flattenedMemory = flattenMemory(memoryContent);
historyManager.addItem(
{
@@ -1379,11 +1372,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Explicitly hide the expansion hint and clear its x-second timer when clearing the screen.
triggerExpandHint(null);
historyManager.clearItems();
clearErrorCount();
clearConsoleMessagesState();
refreshStatic();
}, [
historyManager,
clearErrorCount,
clearConsoleMessagesState,
refreshStatic,
reset,
triggerExpandHint,
@@ -1990,6 +1983,22 @@ Logging in with Google... Restarting Gemini CLI to continue.
};
}, [historyManager]);
const filteredConsoleMessages = useMemo(() => {
if (config.getDebugMode()) {
return consoleMessages;
}
return consoleMessages.filter((msg) => msg.type !== 'debug');
}, [consoleMessages, config]);
// Computed values
const errorCount = useMemo(
() =>
filteredConsoleMessages
.filter((msg) => msg.type === 'error')
.reduce((total, msg) => total + msg.count, 0),
[filteredConsoleMessages],
);
const nightly = props.version.includes('nightly');
const dialogsVisible =
@@ -2224,6 +2233,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
constrainHeight,
showErrorDetails,
showFullTodos,
filteredConsoleMessages,
ideContextState,
renderMarkdown,
ctrlCPressedOnce: ctrlCPressCount >= 1,
@@ -2351,6 +2361,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
constrainHeight,
showErrorDetails,
showFullTodos,
filteredConsoleMessages,
ideContextState,
renderMarkdown,
ctrlCPressCount,
@@ -42,7 +42,6 @@ describe('IdeIntegrationNudge', () => {
beforeEach(() => {
vi.mocked(debugLogger.warn).mockImplementation((...args) => {
if (
// eslint-disable-next-line no-restricted-syntax
typeof args[0] === 'string' &&
/was not wrapped in act/.test(args[0])
) {
@@ -54,9 +53,10 @@ describe('IdeIntegrationNudge', () => {
});
it('renders correctly with default options', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?');
@@ -68,10 +68,12 @@ describe('IdeIntegrationNudge', () => {
it('handles "Yes" selection', async () => {
const onComplete = vi.fn();
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// "Yes" is the first option and selected by default usually.
await act(async () => {
stdin.write('\r');
@@ -87,10 +89,12 @@ describe('IdeIntegrationNudge', () => {
it('handles "No" selection', async () => {
const onComplete = vi.fn();
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// Navigate down to "No (esc)"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
@@ -111,10 +115,12 @@ describe('IdeIntegrationNudge', () => {
it('handles "Dismiss" selection', async () => {
const onComplete = vi.fn();
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// Navigate down to "No, don't ask again"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
@@ -140,10 +146,12 @@ describe('IdeIntegrationNudge', () => {
it('handles Escape key press', async () => {
const onComplete = vi.fn();
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// Press Escape
await act(async () => {
stdin.write('\u001B');
@@ -165,10 +173,11 @@ describe('IdeIntegrationNudge', () => {
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp');
const onComplete = vi.fn();
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
const frame = lastFrame();
@@ -2,13 +2,10 @@
exports[`App > Snapshots > renders default layout correctly 1`] = `
"
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Tips for getting started:
@@ -34,6 +31,9 @@ Tips for getting started:
@@ -47,13 +47,10 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
"Notifications
Footer
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Tips for getting started:
@@ -67,12 +64,12 @@ Composer
exports[`App > Snapshots > renders with dialogs visible 1`] = `
"
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Gemini CLI v1.2.3
@@ -110,13 +107,10 @@ DialogManager
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
"
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Tips for getting started:
@@ -146,6 +140,9 @@ HistoryItemDisplay
Notifications
Composer
"
@@ -73,21 +73,23 @@ describe('ApiAuthDialog', () => {
});
it('renders correctly', async () => {
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with a defaultValue', async () => {
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
defaultValue="test-key"
/>,
);
await waitUntilReady();
expect(mockedUseTextBuffer).toHaveBeenCalledWith(
expect.objectContaining({
initialText: 'test-key',
@@ -111,9 +113,10 @@ describe('ApiAuthDialog', () => {
'calls $expectedCall.name when $keyName is pressed',
async ({ keyName, sequence, expectedCall, args }) => {
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// 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];
@@ -133,22 +136,24 @@ describe('ApiAuthDialog', () => {
);
it('displays an error message', async () => {
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
error="Invalid API Key"
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Invalid API Key');
unmount();
});
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// Call 0 is ApiAuthDialog (isActive: true)
// Call 1 is TextInput (isActive: true, priority: true)
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
+58 -17
View File
@@ -143,9 +143,10 @@ describe('AuthDialog', () => {
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value as string);
}
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
for (const item of shouldContain) {
expect(items).toContainEqual(item);
@@ -160,7 +161,10 @@ describe('AuthDialog', () => {
it('filters auth types when enforcedType is set', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(1);
expect(items[0].value).toBe(AuthType.USE_GEMINI);
@@ -169,7 +173,10 @@ describe('AuthDialog', () => {
it('sets initial index to 0 when enforcedType is set', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(initialIndex).toBe(0);
unmount();
@@ -206,7 +213,10 @@ describe('AuthDialog', () => {
},
])('selects initial auth type $desc', async ({ setup, expected }) => {
setup();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(items[initialIndex].value).toBe(expected);
unmount();
@@ -216,7 +226,10 @@ describe('AuthDialog', () => {
describe('handleAuthSelect', () => {
it('calls onAuthError if validation fails', async () => {
mockedValidateAuthMethod.mockReturnValue('Invalid method');
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
handleAuthSelect(AuthType.USE_GEMINI);
@@ -232,7 +245,10 @@ describe('AuthDialog', () => {
it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
@@ -245,7 +261,10 @@ describe('AuthDialog', () => {
it('sets auth context with empty object for other auth types', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -259,7 +278,10 @@ describe('AuthDialog', () => {
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -275,7 +297,10 @@ describe('AuthDialog', () => {
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
// props.settings.merged.security.auth.selectedType is undefined here
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -291,7 +316,10 @@ describe('AuthDialog', () => {
// process.env['GEMINI_API_KEY'] is not set
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -309,7 +337,10 @@ describe('AuthDialog', () => {
props.settings.merged.security.auth.selectedType =
AuthType.LOGIN_WITH_GOOGLE;
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -329,7 +360,10 @@ describe('AuthDialog', () => {
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
mockedValidateAuthMethod.mockReturnValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
@@ -349,9 +383,10 @@ describe('AuthDialog', () => {
it('displays authError when provided', async () => {
props.authError = 'Something went wrong';
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Something went wrong');
unmount();
});
@@ -394,7 +429,10 @@ describe('AuthDialog', () => {
},
])('$desc', async ({ setup, expectations }) => {
setup();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expectations(props);
@@ -404,27 +442,30 @@ describe('AuthDialog', () => {
describe('Snapshots', () => {
it('renders correctly with default props', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with auth error', async () => {
props.authError = 'Something went wrong';
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with enforced auth type', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -42,7 +42,6 @@ describe('AuthInProgress', () => {
vi.useFakeTimers();
vi.mocked(debugLogger.error).mockImplementation((...args) => {
if (
// eslint-disable-next-line no-restricted-syntax
typeof args[0] === 'string' &&
args[0].includes('was not wrapped in act')
) {
@@ -56,18 +55,20 @@ describe('AuthInProgress', () => {
});
it('renders initial state with spinner', async () => {
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('[Spinner] Waiting for authentication...');
expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel');
unmount();
});
it('calls onTimeout when ESC is pressed', async () => {
const { waitUntilReady, unmount } = await render(
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
await act(async () => {
@@ -83,9 +84,10 @@ describe('AuthInProgress', () => {
});
it('calls onTimeout when Ctrl+C is pressed', async () => {
const { waitUntilReady, unmount } = await render(
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
await act(async () => {
@@ -98,9 +100,10 @@ describe('AuthInProgress', () => {
});
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
const { lastFrame, waitUntilReady, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
vi.advanceTimersByTime(180000);
@@ -113,7 +116,10 @@ describe('AuthInProgress', () => {
});
it('clears timer on unmount', async () => {
const { unmount } = await render(<AuthInProgress onTimeout={onTimeout} />);
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
unmount();

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