mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-26 17:51:04 -07:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0500ec891 | |||
| 7cfb85f951 | |||
| c52ea61af1 | |||
| 8adc855c48 | |||
| ea5eef7b90 | |||
| d0157c4869 | |||
| c0f1c9cc5b | |||
| 4df6d27ac2 | |||
| e3a6b6ab60 | |||
| a2ad637398 | |||
| a5b8ea4418 | |||
| ac6b1b888d | |||
| d6eeb2f99e | |||
| c08903ba83 | |||
| bfa762bcbb | |||
| 0e5978ee47 | |||
| 3502249e0c |
+1
-12
@@ -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 +0,0 @@
|
||||
packages/core/src/services/scripts/*.exe
|
||||
@@ -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'
|
||||
|
||||
@@ -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.*
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 you’ve 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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}}]}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+5
-5
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.' };
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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}"`,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ export async function getMcpServersFromConfig(
|
||||
return;
|
||||
}
|
||||
mcpServers[key] = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...server,
|
||||
extension,
|
||||
};
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user