mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -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 |
@@ -2,8 +2,7 @@
|
||||
"experimental": {
|
||||
"plan": true,
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"memoryManager": true
|
||||
"modelSteering": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -117,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` |
|
||||
@@ -151,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
@@ -1276,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.
|
||||
@@ -1527,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`
|
||||
@@ -1618,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.
|
||||
@@ -1733,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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-8
@@ -41,7 +41,7 @@ export default tseslint.config(
|
||||
{
|
||||
// Global ignores
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'node_modules/*',
|
||||
'eslint.config.js',
|
||||
'packages/**/dist/**',
|
||||
'bundle/**',
|
||||
@@ -50,7 +50,7 @@ export default tseslint.config(
|
||||
'dist/**',
|
||||
'evals/**',
|
||||
'packages/test-utils/**',
|
||||
'.gemini/**',
|
||||
'.gemini/skills/**',
|
||||
'**/*.d.ts',
|
||||
],
|
||||
},
|
||||
@@ -319,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,
|
||||
|
||||
@@ -13,12 +13,7 @@ import {
|
||||
} from './test-helper.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
type ScriptItem,
|
||||
extractFakeResponses,
|
||||
extractUserPrompts,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { DEFAULT_GEMINI_MODEL } from '@google/gemini-cli-core';
|
||||
|
||||
export interface AppEvalCase {
|
||||
name: string;
|
||||
@@ -28,12 +23,6 @@ export interface AppEvalCase {
|
||||
files?: Record<string, string>;
|
||||
setup?: (rig: AppRig) => Promise<void>;
|
||||
assert: (rig: AppRig, output: string) => Promise<void>;
|
||||
/**
|
||||
* Optional script to "prime the pump" before the main prompt.
|
||||
* A sequential array interleaving MockUserTurn (e.g., userText('hello'))
|
||||
* and FakeResponse (e.g., mockGenerateContentStreamText('hi')).
|
||||
*/
|
||||
script?: ScriptItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,25 +31,11 @@ export interface AppEvalCase {
|
||||
*/
|
||||
export function appEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
|
||||
const fn = async () => {
|
||||
const configOverrides = {
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
...evalCase.configOverrides,
|
||||
};
|
||||
|
||||
let userPrompts: string[] = [];
|
||||
|
||||
if (evalCase.script) {
|
||||
configOverrides.fakeModelConfig = {
|
||||
responses: extractFakeResponses(evalCase.script),
|
||||
hybridHandoff: true,
|
||||
};
|
||||
|
||||
// Extract the sequence of user prompts for the Mock User driver
|
||||
userPrompts = extractUserPrompts(evalCase.script);
|
||||
}
|
||||
|
||||
const rig = new AppRig({
|
||||
configOverrides,
|
||||
configOverrides: {
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
...evalCase.configOverrides,
|
||||
},
|
||||
});
|
||||
|
||||
const { logDir, sanitizedName } = await prepareLogDir(evalCase.name);
|
||||
@@ -87,16 +62,11 @@ export function appEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
|
||||
}
|
||||
|
||||
// Render the app!
|
||||
await rig.render();
|
||||
rig.render();
|
||||
|
||||
// Wait for initial ready state
|
||||
await rig.waitForIdle();
|
||||
|
||||
// Execute priming script if requested
|
||||
if (userPrompts.length > 0) {
|
||||
await rig.driveMockUser(userPrompts, evalCase.timeout);
|
||||
}
|
||||
|
||||
// Send the initial prompt
|
||||
await rig.sendMessage(evalCase.prompt);
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { appEvalTest } from './app-test-helper.js';
|
||||
import {
|
||||
userText,
|
||||
mockGenerateContentStreamText,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
describe('Hybrid Handoff (Mock User to Live Model)', () => {
|
||||
appEvalTest('ALWAYS_PASSES', {
|
||||
name: 'Mock User successfully primes AppRig using a scripted history and hands off to live model',
|
||||
timeout: 120000,
|
||||
script: [
|
||||
userText('Start priming'),
|
||||
mockGenerateContentStreamText(
|
||||
"Hello! I am a fake response. Let's prime the pump.",
|
||||
),
|
||||
userText('Continue priming'),
|
||||
mockGenerateContentStreamText(
|
||||
'Pump primed successfully. Ready for handoff.',
|
||||
),
|
||||
],
|
||||
prompt: 'What is 5 * 5? Please answer with just the final number.',
|
||||
assert: async (rig) => {
|
||||
await rig.drainBreakpointsUntilIdle(undefined, 30000);
|
||||
|
||||
const liveOutput = rig.getStaticOutput();
|
||||
|
||||
// Ensure the handoff was successful
|
||||
expect(liveOutput).toContain('25');
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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,178 +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');
|
||||
});
|
||||
});
|
||||
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",
|
||||
|
||||
+1
-1
@@ -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,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -264,7 +264,6 @@ describe('mcp list command', () => {
|
||||
config: {
|
||||
'allowed-server': { url: 'http://allowed' },
|
||||
},
|
||||
requiredConfig: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -2270,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
|
||||
|
||||
@@ -1248,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[] {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: 'ModelPolicy',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1360,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',
|
||||
@@ -1906,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',
|
||||
@@ -2091,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',
|
||||
@@ -2447,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: {
|
||||
@@ -2455,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: {
|
||||
@@ -2593,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',
|
||||
@@ -2998,34 +2867,6 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
},
|
||||
},
|
||||
},
|
||||
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 {
|
||||
|
||||
@@ -199,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', () => ({
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
WarningPriority,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type WorktreeInfo,
|
||||
type OutputPayload,
|
||||
type ConsoleLogPayload,
|
||||
type UserFeedbackPayload,
|
||||
@@ -64,7 +63,6 @@ import {
|
||||
registerTelemetryConfig,
|
||||
setupSignalHandlers,
|
||||
} from './utils/cleanup.js';
|
||||
import { setupWorktree } from './utils/worktreeSetup.js';
|
||||
import {
|
||||
cleanupToolOutputFiles,
|
||||
cleanupExpiredSessions,
|
||||
@@ -212,13 +210,6 @@ export async function main() {
|
||||
const settings = loadSettings();
|
||||
loadSettingsHandle?.end();
|
||||
|
||||
// If a worktree is requested and enabled, set it up early.
|
||||
const requestedWorktree = cliConfig.getRequestedWorktreeName(settings);
|
||||
let worktreeInfo: WorktreeInfo | undefined;
|
||||
if (requestedWorktree !== undefined) {
|
||||
worktreeInfo = await setupWorktree(requestedWorktree || undefined);
|
||||
}
|
||||
|
||||
// Report settings errors once during startup
|
||||
settings.errors.forEach((error) => {
|
||||
coreEvents.emitFeedback('warning', error.message);
|
||||
@@ -435,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();
|
||||
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AppRig } from '../test-utils/AppRig.js';
|
||||
import {
|
||||
FakeContentGenerator,
|
||||
FallbackContentGenerator,
|
||||
userText,
|
||||
mockGenerateContentStreamText,
|
||||
extractUserPrompts,
|
||||
extractFakeResponses,
|
||||
type ScriptItem,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
describe('Hybrid Handoff (Mock User to Synthetic Live Model)', () => {
|
||||
it('successfully transitions from mock responses to live responses', async () => {
|
||||
// 1. Define the conversational script for the priming phase
|
||||
const primingScript: ScriptItem[] = [
|
||||
userText('Start priming'),
|
||||
mockGenerateContentStreamText('Hello! I am a fake response.'),
|
||||
userText('Continue priming'),
|
||||
mockGenerateContentStreamText(
|
||||
'Pump primed successfully. Ready for handoff.',
|
||||
),
|
||||
];
|
||||
|
||||
// 2. Setup the primary fake generator that runs through the priming script
|
||||
const fakeGenerator = new FakeContentGenerator(
|
||||
extractFakeResponses(primingScript),
|
||||
);
|
||||
|
||||
// 3. Setup a "live" fallback generator (it's synthetic so we don't need API keys)
|
||||
const mockLiveFallback = new FakeContentGenerator([
|
||||
mockGenerateContentStreamText('The answer is 4.'),
|
||||
]);
|
||||
|
||||
// We need countTokens so AppRig doesn't hang checking size during truncation
|
||||
mockLiveFallback.countTokens = async () => ({ totalTokens: 10 });
|
||||
|
||||
// 4. Compose them using FallbackContentGenerator
|
||||
const composedGenerator = new FallbackContentGenerator(
|
||||
fakeGenerator,
|
||||
mockLiveFallback,
|
||||
);
|
||||
|
||||
// 5. Mount the AppRig natively supporting custom content generators
|
||||
const rig = new AppRig({
|
||||
contentGenerator: composedGenerator,
|
||||
configOverrides: {
|
||||
fakeResponses: [], // ensure it avoids disk IO attempts internally
|
||||
},
|
||||
});
|
||||
await rig.initialize();
|
||||
|
||||
await rig.render();
|
||||
await rig.waitForIdle();
|
||||
|
||||
// 6. Drive the Mock User sequence using the extracted prompts from the script
|
||||
await rig.driveMockUser(extractUserPrompts(primingScript), 10000);
|
||||
|
||||
// 7. Send the final prompt that should exhaust the primary generator and trigger the fallback
|
||||
await rig.sendMessage('What is 2 + 2?');
|
||||
|
||||
// 8. Wait for the fallback response to render
|
||||
await rig.waitForOutput('The answer is 4.', 10000);
|
||||
|
||||
const output = rig.getStaticOutput();
|
||||
expect(output).toContain('The answer is 4.');
|
||||
|
||||
// Wait for everything to settle so React act() warnings don't fire during unmount
|
||||
await rig.drainBreakpointsUntilIdle(undefined, 10000);
|
||||
await rig.unmount();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { describe, it, afterEach } from 'vitest';
|
||||
import { AppRig } from '../test-utils/AppRig.js';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FakeContentGenerator, PolicyDecision } from '@google/gemini-cli-core';
|
||||
import { PolicyDecision } from '@google/gemini-cli-core';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -24,14 +24,12 @@ describe('Model Steering Integration', () => {
|
||||
__dirname,
|
||||
'../test-utils/fixtures/steering.responses',
|
||||
);
|
||||
const contentGenerator =
|
||||
await FakeContentGenerator.fromFile(fakeResponsesPath);
|
||||
rig = new AppRig({
|
||||
contentGenerator,
|
||||
fakeResponsesPath,
|
||||
configOverrides: { modelSteering: true },
|
||||
});
|
||||
await rig.initialize();
|
||||
await rig.render();
|
||||
rig.render();
|
||||
await rig.waitForIdle();
|
||||
|
||||
rig.setToolPolicy('list_directory', PolicyDecision.ASK_USER);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,10 +5,11 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
import { FakeContentGenerator, debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -25,14 +26,12 @@ describe('AppRig', () => {
|
||||
'fixtures',
|
||||
'steering.responses',
|
||||
);
|
||||
const contentGenerator =
|
||||
await FakeContentGenerator.fromFile(fakeResponsesPath);
|
||||
rig = new AppRig({
|
||||
contentGenerator,
|
||||
fakeResponsesPath,
|
||||
configOverrides: { modelSteering: true },
|
||||
});
|
||||
await rig.initialize();
|
||||
await rig.render();
|
||||
rig.render();
|
||||
await rig.waitForIdle();
|
||||
|
||||
// Set breakpoints on the canonical tool names
|
||||
@@ -68,11 +67,14 @@ describe('AppRig', () => {
|
||||
'fixtures',
|
||||
'simple.responses',
|
||||
);
|
||||
const contentGenerator =
|
||||
await FakeContentGenerator.fromFile(fakeResponsesPath);
|
||||
rig = new AppRig({ contentGenerator });
|
||||
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,7 +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 } from './render.js';
|
||||
import { renderWithProviders } from './render.js';
|
||||
import {
|
||||
makeFakeConfig,
|
||||
type Config,
|
||||
@@ -31,8 +31,6 @@ import {
|
||||
debugLogger,
|
||||
CoreToolCallStatus,
|
||||
IntegrityDataStatus,
|
||||
ConsecaSafetyChecker,
|
||||
type ContentGenerator,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type MockShellCommand,
|
||||
@@ -50,42 +48,37 @@ import type {
|
||||
TrackedCompletedToolCall,
|
||||
TrackedToolCall,
|
||||
} from '../ui/hooks/useToolScheduler.js';
|
||||
import type { Content, GenerateContentParameters } from '@google/genai';
|
||||
|
||||
// Global state observer for React-based signals
|
||||
const sessionStateMap = new Map<string, StreamingState>();
|
||||
const activeRigs = new Map<string, AppRig>();
|
||||
|
||||
// Mock useGeminiStream to report state changes back to the observer
|
||||
vi.mock('../ui/hooks/useGeminiStream.js', async (importOriginal) => {
|
||||
// Mock StreamingContext to report state changes back to the observer
|
||||
vi.mock('../ui/contexts/StreamingContext.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('../ui/hooks/useGeminiStream.js')>();
|
||||
const React = await import('react');
|
||||
await importOriginal<typeof import('../ui/contexts/StreamingContext.js')>();
|
||||
const { useConfig } = await import('../ui/contexts/ConfigContext.js');
|
||||
const React = await import('react');
|
||||
|
||||
return {
|
||||
...original,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
useGeminiStream: (...args: any[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = (original.useGeminiStream as any)(...args);
|
||||
useStreamingContext: () => {
|
||||
const state = original.useStreamingContext();
|
||||
const config = useConfig();
|
||||
const sessionId = config.getSessionId();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (sessionId) {
|
||||
sessionStateMap.set(sessionId, result.streamingState);
|
||||
// If we see activity, we are no longer "awaiting" the start of a response
|
||||
if (result.streamingState !== StreamingState.Idle) {
|
||||
const rig = activeRigs.get(sessionId);
|
||||
if (rig) {
|
||||
rig.awaitingResponse = false;
|
||||
}
|
||||
sessionStateMap.set(sessionId, state);
|
||||
// If we see activity, we are no longer "awaiting" the start of a response
|
||||
if (state !== StreamingState.Idle) {
|
||||
const rig = activeRigs.get(sessionId);
|
||||
if (rig) {
|
||||
rig.awaitingResponse = false;
|
||||
}
|
||||
}
|
||||
}, [sessionId, result.streamingState]);
|
||||
}, [sessionId, state]);
|
||||
|
||||
return result;
|
||||
return state;
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -149,10 +142,10 @@ vi.mock('../ui/components/GeminiRespondingSpinner.js', async () => {
|
||||
});
|
||||
|
||||
export interface AppRigOptions {
|
||||
fakeResponsesPath?: string;
|
||||
terminalWidth?: number;
|
||||
terminalHeight?: number;
|
||||
configOverrides?: Partial<ConfigParameters>;
|
||||
contentGenerator?: ContentGenerator;
|
||||
}
|
||||
|
||||
export interface PendingConfirmation {
|
||||
@@ -162,12 +155,11 @@ 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;
|
||||
private sessionId: string;
|
||||
private appRigId: string;
|
||||
|
||||
private pendingConfirmations = new Map<string, PendingConfirmation>();
|
||||
private breakpointTools = new Set<string | undefined>();
|
||||
@@ -177,14 +169,12 @@ export class AppRig {
|
||||
* True if a message was just sent but React hasn't yet reported a non-idle state.
|
||||
*/
|
||||
awaitingResponse = false;
|
||||
activeStreamCount = 0;
|
||||
|
||||
constructor(private options: AppRigOptions = {}) {
|
||||
const uniqueId = randomUUID();
|
||||
this.testDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `gemini-app-rig-${uniqueId.slice(0, 8)}-`),
|
||||
);
|
||||
this.appRigId = path.basename(this.testDir).toLowerCase();
|
||||
this.sessionId = `test-session-${uniqueId}`;
|
||||
activeRigs.set(this.sessionId, this);
|
||||
}
|
||||
@@ -207,7 +197,7 @@ export class AppRig {
|
||||
cwd: this.testDir,
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
contentGenerator: this.options.contentGenerator,
|
||||
fakeResponses: this.options.fakeResponsesPath,
|
||||
interactive: true,
|
||||
approvalMode,
|
||||
policyEngineConfig,
|
||||
@@ -219,44 +209,8 @@ export class AppRig {
|
||||
};
|
||||
this.config = makeFakeConfig(configParams);
|
||||
|
||||
// Track active streams directly from the client to prevent false idleness during synchronous mock yields
|
||||
const client = this.config.getGeminiClient();
|
||||
const originalStream = client.sendMessageStream.bind(client);
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const self = this;
|
||||
|
||||
client.sendMessageStream = async function* (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...args: any[]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): AsyncGenerator<any, any, any> {
|
||||
self.awaitingResponse = false;
|
||||
self.activeStreamCount++;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
yield* (originalStream as any)(...args);
|
||||
} finally {
|
||||
self.activeStreamCount = Math.max(0, self.activeStreamCount - 1);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
|
||||
if (this.config.fakeResponses || this.options.contentGenerator) {
|
||||
if (!this.options.contentGenerator && !this.config.fakeResponses) {
|
||||
this.stubRefreshAuth();
|
||||
}
|
||||
if (!process.env['GEMINI_API_KEY']) {
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
}
|
||||
MockShellExecutionService.setPassthrough(false);
|
||||
} else {
|
||||
if (!process.env['GEMINI_API_KEY']) {
|
||||
throw new Error(
|
||||
'GEMINI_API_KEY must be set in the environment for live model tests.',
|
||||
);
|
||||
}
|
||||
// For live tests, we allow falling through to the real shell service if no mock matches
|
||||
MockShellExecutionService.setPassthrough(true);
|
||||
if (this.options.fakeResponsesPath) {
|
||||
this.stubRefreshAuth();
|
||||
}
|
||||
|
||||
this.setupMessageBusListeners();
|
||||
@@ -272,6 +226,18 @@ export class AppRig {
|
||||
private setupEnvironment() {
|
||||
// Stub environment variables to avoid interference from developer's machine
|
||||
vi.stubEnv('GEMINI_CLI_HOME', this.testDir);
|
||||
if (this.options.fakeResponsesPath) {
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
MockShellExecutionService.setPassthrough(false);
|
||||
} else {
|
||||
if (!process.env['GEMINI_API_KEY']) {
|
||||
throw new Error(
|
||||
'GEMINI_API_KEY must be set in the environment for live model tests.',
|
||||
);
|
||||
}
|
||||
// For live tests, we allow falling through to the real shell service if no mock matches
|
||||
MockShellExecutionService.setPassthrough(true);
|
||||
}
|
||||
vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', AuthType.USE_GEMINI);
|
||||
}
|
||||
|
||||
@@ -389,23 +355,18 @@ export class AppRig {
|
||||
* Returns true if the agent is currently busy (responding or executing tools).
|
||||
*/
|
||||
isBusy(): boolean {
|
||||
const reactState = sessionStateMap.get(this.sessionId);
|
||||
|
||||
if (reactState && reactState !== StreamingState.Idle) {
|
||||
this.awaitingResponse = false;
|
||||
}
|
||||
|
||||
if (this.awaitingResponse || this.activeStreamCount > 0) {
|
||||
if (this.awaitingResponse) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const reactState = sessionStateMap.get(this.sessionId);
|
||||
// If we have a React-based state, use it as the definitive signal.
|
||||
// 'responding' and 'waiting-for-confirmation' both count as busy for the overall task.
|
||||
if (reactState !== undefined) {
|
||||
return reactState !== StreamingState.Idle;
|
||||
}
|
||||
|
||||
// Fallback to tool tracking
|
||||
// Fallback to tool tracking if React hasn't reported yet
|
||||
const isAnyToolActive = this.toolCalls.some((tc) => {
|
||||
if (
|
||||
tc.status === CoreToolCallStatus.Executing ||
|
||||
@@ -432,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"
|
||||
@@ -696,21 +657,6 @@ export class AppRig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acts as an automated user ('Mock User') to prime the system with a specific
|
||||
* history state before handing off control to a live trial or eval.
|
||||
*
|
||||
* @param prompts An array of user messages to send sequentially.
|
||||
* @param timeout Optional timeout per interaction.
|
||||
*/
|
||||
async driveMockUser(prompts: string[], timeout = 60000) {
|
||||
for (let i = 0; i < prompts.length; i++) {
|
||||
const prompt = prompts[i];
|
||||
await this.sendMessage(prompt);
|
||||
await this.drainBreakpointsUntilIdle(undefined, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
getConfig(): Config {
|
||||
if (!this.config) throw new Error('AppRig not initialized');
|
||||
return this.config;
|
||||
@@ -808,10 +754,6 @@ export class AppRig {
|
||||
// Forcefully clear IdeClient singleton promise
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(IdeClient as any).instancePromise = null;
|
||||
|
||||
// Reset Conseca singleton to avoid leaking config/state across tests
|
||||
ConsecaSafetyChecker.resetInstance();
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
this.config = undefined;
|
||||
@@ -828,79 +770,4 @@ export class AppRig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSentRequests() {
|
||||
if (!this.config) throw new Error('AppRig not initialized');
|
||||
return this.config.getContentGenerator().getSentRequests?.() || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the curated history (contents) sent in the most recent model request.
|
||||
* This method scrubs unstable data like temp paths and IDs for deterministic goldens.
|
||||
*/
|
||||
getLastSentRequestContents() {
|
||||
const requests = this.getSentRequests();
|
||||
if (requests.length === 0) return [];
|
||||
const contents = requests[requests.length - 1].contents || [];
|
||||
return this.scrubUnstableData(contents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the final curated history of the active chat session.
|
||||
*/
|
||||
getCuratedHistory() {
|
||||
if (!this.config) throw new Error('AppRig not initialized');
|
||||
const history = this.config.getGeminiClient().getChat().getHistory(true);
|
||||
return this.scrubUnstableData(history);
|
||||
}
|
||||
|
||||
private scrubUnstableData<
|
||||
T extends
|
||||
| Content[]
|
||||
| GenerateContentParameters['contents']
|
||||
| readonly Content[],
|
||||
>(contents: T): T {
|
||||
// Deeply scrub unstable data
|
||||
const scrubbedString = JSON.stringify(contents)
|
||||
.replace(new RegExp(this.testDir, 'g'), '<TEST_DIR>')
|
||||
.replace(new RegExp(this.appRigId, 'g'), '<APP_RIG_ID>')
|
||||
.replace(new RegExp(this.sessionId, 'g'), '<SESSION_ID>')
|
||||
.replace(
|
||||
/([a-zA-Z0-9_]+)_([0-9]{13})_([0-9]+)\.txt/g,
|
||||
'$1_<TIMESTAMP>_<INDEX>.txt',
|
||||
)
|
||||
.replace(/Process Group PGID: \d+/g, 'Process Group PGID: <PGID>');
|
||||
|
||||
const scrubbed = JSON.parse(scrubbedString) as T;
|
||||
|
||||
if (Array.isArray(scrubbed) && scrubbed.length > 0) {
|
||||
const firstItem = scrubbed[0] as Content;
|
||||
if (firstItem.parts?.[0]?.text?.includes('<session_context>')) {
|
||||
firstItem.parts[0].text = '<SESSION_CONTEXT>';
|
||||
}
|
||||
|
||||
for (const content of scrubbed as Content[]) {
|
||||
if (content.parts) {
|
||||
for (const part of content.parts) {
|
||||
if (part.functionCall) {
|
||||
part.functionCall.id = '<CALL_ID>';
|
||||
}
|
||||
if (part.functionResponse) {
|
||||
part.functionResponse.id = '<CALL_ID>';
|
||||
if (
|
||||
part.functionResponse.response !== null &&
|
||||
typeof part.functionResponse.response === 'object' &&
|
||||
'original_output_file' in part.functionResponse.response
|
||||
) {
|
||||
part.functionResponse.response['original_output_file'] =
|
||||
'<TMP_FILE>';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scrubbed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +36,7 @@ export const createMockCommandContext = (
|
||||
args: '',
|
||||
},
|
||||
services: {
|
||||
agentContext: null,
|
||||
config: null,
|
||||
|
||||
settings: {
|
||||
merged: defaultMergedSettings,
|
||||
|
||||
@@ -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', () => ({
|
||||
@@ -487,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.
|
||||
@@ -596,7 +639,7 @@ const ContextCapture: React.FC<{ children: React.ReactNode }> = ({
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const renderWithProviders = async (
|
||||
export const renderWithProviders = (
|
||||
component: React.ReactElement,
|
||||
{
|
||||
shellFocus = true,
|
||||
@@ -604,7 +647,8 @@ export const renderWithProviders = async (
|
||||
uiState: providedUiState,
|
||||
width,
|
||||
mouseEventsEnabled = false,
|
||||
config,
|
||||
|
||||
config = configProxy as unknown as Config,
|
||||
uiActions,
|
||||
persistentState,
|
||||
appState = mockAppState,
|
||||
@@ -622,15 +666,13 @@ export const renderWithProviders = async (
|
||||
};
|
||||
appState?: AppState;
|
||||
} = {},
|
||||
): Promise<
|
||||
RenderInstance & {
|
||||
simulateClick: (
|
||||
col: number,
|
||||
row: number,
|
||||
button?: 0 | 1 | 2,
|
||||
) => Promise<void>;
|
||||
}
|
||||
> => {
|
||||
): RenderInstance & {
|
||||
simulateClick: (
|
||||
col: number,
|
||||
row: number,
|
||||
button?: 0 | 1 | 2,
|
||||
) => Promise<void>;
|
||||
} => {
|
||||
const baseState: UIState = new Proxy(
|
||||
{ ...baseMockUiState, ...providedUiState },
|
||||
{
|
||||
@@ -659,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;
|
||||
|
||||
@@ -697,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}>
|
||||
@@ -709,7 +744,7 @@ export const renderWithProviders = async (
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<OverflowProvider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
config={finalConfig}
|
||||
toolCalls={allToolCalls}
|
||||
>
|
||||
<AskUserActionsProvider
|
||||
@@ -828,7 +863,7 @@ export 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;
|
||||
@@ -841,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;
|
||||
@@ -866,8 +901,8 @@ export async function renderHookWithProviders<Result, Props>(
|
||||
|
||||
let renderResult: ReturnType<typeof render>;
|
||||
|
||||
await act(async () => {
|
||||
renderResult = await renderWithProviders(
|
||||
act(() => {
|
||||
renderResult = renderWithProviders(
|
||||
<Wrapper>
|
||||
{}
|
||||
<TestComponent initialProps={options.initialProps as Props} />
|
||||
|
||||
@@ -94,10 +94,11 @@ describe('App', () => {
|
||||
};
|
||||
|
||||
it('should render main content and composer when not quitting', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<App />,
|
||||
{
|
||||
uiState: mockUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: false }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
|
||||
},
|
||||
);
|
||||
@@ -115,10 +116,11 @@ describe('App', () => {
|
||||
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<App />,
|
||||
{
|
||||
uiState: quittingUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: false }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
|
||||
},
|
||||
);
|
||||
@@ -136,10 +138,11 @@ describe('App', () => {
|
||||
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<App />,
|
||||
{
|
||||
uiState: quittingUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
@@ -156,10 +159,11 @@ describe('App', () => {
|
||||
dialogsVisible: true,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<App />,
|
||||
{
|
||||
uiState: dialogUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
@@ -183,10 +187,11 @@ describe('App', () => {
|
||||
[stateKey]: true,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<App />,
|
||||
{
|
||||
uiState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
@@ -200,10 +205,11 @@ describe('App', () => {
|
||||
it('should render ScreenReaderAppLayout when screen reader is enabled', async () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<App />,
|
||||
{
|
||||
uiState: mockUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
@@ -219,10 +225,11 @@ describe('App', () => {
|
||||
it('should render DefaultAppLayout when screen reader is not enabled', async () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<App />,
|
||||
{
|
||||
uiState: mockUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
@@ -274,7 +281,7 @@ describe('App', () => {
|
||||
vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
|
||||
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<App />,
|
||||
{
|
||||
uiState: stateWithConfirmingTool,
|
||||
@@ -295,10 +302,11 @@ describe('App', () => {
|
||||
describe('Snapshots', () => {
|
||||
it('renders default layout correctly', async () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<App />,
|
||||
{
|
||||
uiState: mockUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
@@ -309,10 +317,11 @@ describe('App', () => {
|
||||
|
||||
it('renders screen reader layout correctly', async () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<App />,
|
||||
{
|
||||
uiState: mockUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
@@ -326,10 +335,11 @@ describe('App', () => {
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
} as UIState;
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<App />,
|
||||
{
|
||||
uiState: dialogUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -212,7 +212,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useErrorCount } from './hooks/useConsoleMessages.js';
|
||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||
@@ -294,7 +294,7 @@ describe('AppContainer State Management', () => {
|
||||
const mockedUseSettingsCommand = useSettingsCommand as Mock;
|
||||
const mockedUseModelCommand = useModelCommand as Mock;
|
||||
const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock;
|
||||
const mockedUseConsoleMessages = useErrorCount as Mock;
|
||||
const mockedUseConsoleMessages = useConsoleMessages as Mock;
|
||||
const mockedUseGeminiStream = useGeminiStream as Mock;
|
||||
const mockedUseVim = useVim as Mock;
|
||||
const mockedUseFolderTrust = useFolderTrust as Mock;
|
||||
@@ -396,9 +396,9 @@ describe('AppContainer State Management', () => {
|
||||
confirmationRequest: null,
|
||||
});
|
||||
mockedUseConsoleMessages.mockReturnValue({
|
||||
errorCount: 0,
|
||||
consoleMessages: [],
|
||||
handleNewMessage: vi.fn(),
|
||||
clearErrorCount: vi.fn(),
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
|
||||
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('IdeIntegrationNudge', () => {
|
||||
});
|
||||
|
||||
it('renders correctly with default options', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<IdeIntegrationNudge {...defaultProps} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -68,7 +68,7 @@ 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} />,
|
||||
);
|
||||
|
||||
@@ -89,7 +89,7 @@ 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} />,
|
||||
);
|
||||
|
||||
@@ -115,7 +115,7 @@ 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} />,
|
||||
);
|
||||
|
||||
@@ -146,7 +146,7 @@ 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} />,
|
||||
);
|
||||
|
||||
@@ -173,10 +173,9 @@ 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();
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ describe('AuthDialog', () => {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
vi.stubEnv(key, value as string);
|
||||
}
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -161,7 +161,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('filters auth types when enforcedType is set', async () => {
|
||||
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -173,7 +173,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('sets initial index to 0 when enforcedType is set', async () => {
|
||||
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -213,7 +213,7 @@ describe('AuthDialog', () => {
|
||||
},
|
||||
])('selects initial auth type $desc', async ({ setup, expected }) => {
|
||||
setup();
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -226,7 +226,7 @@ describe('AuthDialog', () => {
|
||||
describe('handleAuthSelect', () => {
|
||||
it('calls onAuthError if validation fails', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue('Invalid method');
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -245,7 +245,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -261,7 +261,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('sets auth context with empty object for other auth types', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -278,7 +278,7 @@ describe('AuthDialog', () => {
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
|
||||
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
|
||||
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -297,7 +297,7 @@ describe('AuthDialog', () => {
|
||||
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
|
||||
// props.settings.merged.security.auth.selectedType is undefined here
|
||||
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -316,7 +316,7 @@ describe('AuthDialog', () => {
|
||||
// process.env['GEMINI_API_KEY'] is not set
|
||||
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
|
||||
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -337,7 +337,7 @@ describe('AuthDialog', () => {
|
||||
props.settings.merged.security.auth.selectedType =
|
||||
AuthType.LOGIN_WITH_GOOGLE;
|
||||
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -360,7 +360,7 @@ describe('AuthDialog', () => {
|
||||
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -383,7 +383,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('displays authError when provided', async () => {
|
||||
props.authError = 'Something went wrong';
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -429,7 +429,7 @@ describe('AuthDialog', () => {
|
||||
},
|
||||
])('$desc', async ({ setup, expectations }) => {
|
||||
setup();
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -442,7 +442,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
describe('Snapshots', () => {
|
||||
it('renders correctly with default props', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -452,7 +452,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('renders correctly with auth error', async () => {
|
||||
props.authError = 'Something went wrong';
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -462,7 +462,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('renders correctly with enforced auth type', async () => {
|
||||
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<AuthDialog {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('BannedAccountDialog', () => {
|
||||
});
|
||||
|
||||
it('renders the suspension message from accountSuspensionInfo', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
@@ -89,7 +89,7 @@ describe('BannedAccountDialog', () => {
|
||||
});
|
||||
|
||||
it('renders menu options with appeal link text from response', async () => {
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
@@ -109,7 +109,7 @@ describe('BannedAccountDialog', () => {
|
||||
const infoWithoutUrl: AccountSuspensionInfo = {
|
||||
message: 'Account suspended.',
|
||||
};
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={infoWithoutUrl}
|
||||
onExit={onExit}
|
||||
@@ -129,7 +129,7 @@ describe('BannedAccountDialog', () => {
|
||||
message: 'Account suspended.',
|
||||
appealUrl: 'https://example.com/appeal',
|
||||
};
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={infoWithoutLinkText}
|
||||
onExit={onExit}
|
||||
@@ -143,7 +143,7 @@ describe('BannedAccountDialog', () => {
|
||||
});
|
||||
|
||||
it('opens browser when appeal option is selected', async () => {
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
@@ -162,7 +162,7 @@ describe('BannedAccountDialog', () => {
|
||||
|
||||
it('shows URL when browser cannot be launched', async () => {
|
||||
mockedShouldLaunchBrowser.mockReturnValue(false);
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
@@ -180,7 +180,7 @@ describe('BannedAccountDialog', () => {
|
||||
});
|
||||
|
||||
it('calls onExit when "Exit" is selected', async () => {
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
@@ -196,7 +196,7 @@ describe('BannedAccountDialog', () => {
|
||||
});
|
||||
|
||||
it('calls onChangeAuth when "Change authentication" is selected', async () => {
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
@@ -212,7 +212,7 @@ describe('BannedAccountDialog', () => {
|
||||
});
|
||||
|
||||
it('exits on escape key', async () => {
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
@@ -227,7 +227,7 @@ describe('BannedAccountDialog', () => {
|
||||
});
|
||||
|
||||
it('renders snapshot correctly', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
|
||||
@@ -36,12 +36,10 @@ describe('aboutCommand', () => {
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getModel: vi.fn(),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getUserTierName: vi.fn().mockReturnValue(undefined),
|
||||
},
|
||||
config: {
|
||||
getModel: vi.fn(),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getUserTierName: vi.fn().mockReturnValue(undefined),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
@@ -59,10 +57,9 @@ describe('aboutCommand', () => {
|
||||
} as unknown as CommandContext);
|
||||
|
||||
vi.mocked(getVersion).mockResolvedValue('test-version');
|
||||
vi.spyOn(
|
||||
mockContext.services.agentContext!.config,
|
||||
'getModel',
|
||||
).mockReturnValue('test-model');
|
||||
vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue(
|
||||
'test-model',
|
||||
);
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'test-os',
|
||||
@@ -163,9 +160,9 @@ describe('aboutCommand', () => {
|
||||
});
|
||||
|
||||
it('should display the tier when getUserTierName returns a value', async () => {
|
||||
vi.mocked(
|
||||
mockContext.services.agentContext!.config.getUserTierName,
|
||||
).mockReturnValue('Enterprise Tier');
|
||||
vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue(
|
||||
'Enterprise Tier',
|
||||
);
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
@@ -34,8 +34,7 @@ export const aboutCommand: SlashCommand = {
|
||||
process.env['SEATBELT_PROFILE'] || 'unknown'
|
||||
})`;
|
||||
}
|
||||
const modelVersion =
|
||||
context.services.agentContext?.config.getModel() || 'Unknown';
|
||||
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getVersion();
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security.auth.selectedType || '';
|
||||
@@ -49,7 +48,7 @@ export const aboutCommand: SlashCommand = {
|
||||
});
|
||||
const userEmail = cachedAccount ?? undefined;
|
||||
|
||||
const tier = context.services.agentContext?.config.getUserTierName();
|
||||
const tier = context.services.config?.getUserTierName();
|
||||
|
||||
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
|
||||
type: MessageType.ABOUT,
|
||||
@@ -69,7 +68,7 @@ export const aboutCommand: SlashCommand = {
|
||||
};
|
||||
|
||||
async function getIdeClientName(context: CommandContext) {
|
||||
if (!context.services.agentContext?.config.getIdeMode()) {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
|
||||
@@ -26,7 +26,6 @@ describe('agentsCommand', () => {
|
||||
let mockContext: ReturnType<typeof createMockCommandContext>;
|
||||
let mockConfig: {
|
||||
getAgentRegistry: ReturnType<typeof vi.fn>;
|
||||
config: Config;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -38,14 +37,11 @@ describe('agentsCommand', () => {
|
||||
getAllAgentNames: vi.fn().mockReturnValue([]),
|
||||
reload: vi.fn(),
|
||||
}),
|
||||
get config() {
|
||||
return this as unknown as Config;
|
||||
},
|
||||
};
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: mockConfig as unknown as Config,
|
||||
config: mockConfig as unknown as Config,
|
||||
settings: {
|
||||
workspace: { path: '/mock/path' },
|
||||
merged: { agents: { overrides: {} } },
|
||||
@@ -57,7 +53,7 @@ describe('agentsCommand', () => {
|
||||
it('should show an error if config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: null,
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -230,7 +226,7 @@ describe('agentsCommand', () => {
|
||||
|
||||
it('should show an error if config is not available for enable', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: { agentContext: null },
|
||||
services: { config: null },
|
||||
});
|
||||
const enableCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'enable',
|
||||
@@ -336,7 +332,7 @@ describe('agentsCommand', () => {
|
||||
|
||||
it('should show an error if config is not available for disable', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: { agentContext: null },
|
||||
services: { config: null },
|
||||
});
|
||||
const disableCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'disable',
|
||||
@@ -437,7 +433,7 @@ describe('agentsCommand', () => {
|
||||
|
||||
it('should show an error if config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: { agentContext: null },
|
||||
services: { config: null },
|
||||
});
|
||||
const configCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'config',
|
||||
|
||||
@@ -21,7 +21,7 @@ const agentsListCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context: CommandContext) => {
|
||||
const config = context.services.agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -61,8 +61,7 @@ async function enableAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn | void> {
|
||||
const config = context.services.agentContext?.config;
|
||||
const { settings } = context.services;
|
||||
const { config, settings } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -138,8 +137,7 @@ async function disableAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn | void> {
|
||||
const config = context.services.agentContext?.config;
|
||||
const { settings } = context.services;
|
||||
const { config, settings } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -218,7 +216,7 @@ async function configAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn | void> {
|
||||
const config = context.services.agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -268,8 +266,7 @@ async function configAction(
|
||||
}
|
||||
|
||||
function completeAgentsToEnable(context: CommandContext, partialArg: string) {
|
||||
const config = context.services.agentContext?.config;
|
||||
const { settings } = context.services;
|
||||
const { config, settings } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const overrides = settings.merged.agents.overrides;
|
||||
@@ -281,7 +278,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) {
|
||||
}
|
||||
|
||||
function completeAgentsToDisable(context: CommandContext, partialArg: string) {
|
||||
const config = context.services.agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const agentRegistry = config.getAgentRegistry();
|
||||
@@ -290,7 +287,7 @@ function completeAgentsToDisable(context: CommandContext, partialArg: string) {
|
||||
}
|
||||
|
||||
function completeAllAgents(context: CommandContext, partialArg: string) {
|
||||
const config = context.services.agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const agentRegistry = config.getAgentRegistry();
|
||||
@@ -331,7 +328,7 @@ const agentsReloadCommand: SlashCommand = {
|
||||
description: 'Reload the agent registry',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext) => {
|
||||
const config = context.services.agentContext?.config;
|
||||
const { config } = context.services;
|
||||
const agentRegistry = config?.getAgentRegistry();
|
||||
if (!agentRegistry) {
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { authCommand } from './authCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { GeminiClient } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
@@ -25,10 +24,8 @@ describe('authCommand', () => {
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
geminiClient: {
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
},
|
||||
config: {
|
||||
getGeminiClient: vi.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -104,19 +101,17 @@ describe('authCommand', () => {
|
||||
const mockStripThoughts = vi.fn();
|
||||
const mockClient = {
|
||||
stripThoughtsFromHistory: mockStripThoughts,
|
||||
} as unknown as GeminiClient;
|
||||
if (mockContext.services.agentContext?.config) {
|
||||
mockContext.services.agentContext.config.getGeminiClient = vi.fn(
|
||||
() => mockClient,
|
||||
);
|
||||
} as unknown as ReturnType<
|
||||
NonNullable<typeof mockContext.services.config>['getGeminiClient']
|
||||
>;
|
||||
|
||||
if (mockContext.services.config) {
|
||||
mockContext.services.config.getGeminiClient = vi.fn(() => mockClient);
|
||||
}
|
||||
|
||||
await logoutCommand!.action!(mockContext, '');
|
||||
|
||||
expect(
|
||||
mockContext.services.agentContext?.geminiClient
|
||||
.stripThoughtsFromHistory,
|
||||
).toHaveBeenCalled();
|
||||
expect(mockStripThoughts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return logout action to signal explicit state change', async () => {
|
||||
@@ -128,7 +123,7 @@ describe('authCommand', () => {
|
||||
|
||||
it('should handle missing config gracefully', async () => {
|
||||
const logoutCommand = authCommand.subCommands?.[1];
|
||||
mockContext.services.agentContext = null;
|
||||
mockContext.services.config = null;
|
||||
|
||||
const result = await logoutCommand!.action!(mockContext, '');
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ const authLogoutCommand: SlashCommand = {
|
||||
undefined,
|
||||
);
|
||||
// Strip thoughts from history instead of clearing completely
|
||||
context.services.agentContext?.geminiClient.stripThoughtsFromHistory();
|
||||
context.services.config?.getGeminiClient()?.stripThoughtsFromHistory();
|
||||
// Return logout action to signal explicit state change
|
||||
return {
|
||||
type: 'logout',
|
||||
|
||||
@@ -83,18 +83,16 @@ describe('bugCommand', () => {
|
||||
it('should generate the default GitHub issue URL', async () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getModel: () => 'gemini-pro',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => true,
|
||||
getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }),
|
||||
},
|
||||
geminiClient: {
|
||||
config: {
|
||||
getModel: () => 'gemini-pro',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => true,
|
||||
getGeminiClient: () => ({
|
||||
getChat: () => ({
|
||||
getHistory: () => [],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -128,20 +126,18 @@ describe('bugCommand', () => {
|
||||
];
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getModel: () => 'gemini-pro',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => true,
|
||||
getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }),
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp/gemini',
|
||||
},
|
||||
},
|
||||
geminiClient: {
|
||||
config: {
|
||||
getModel: () => 'gemini-pro',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => true,
|
||||
getGeminiClient: () => ({
|
||||
getChat: () => ({
|
||||
getHistory: () => history,
|
||||
}),
|
||||
}),
|
||||
getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }),
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp/gemini',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -176,18 +172,16 @@ describe('bugCommand', () => {
|
||||
'https://internal.bug-tracker.com/new?desc={title}&details={info}';
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getModel: () => 'gemini-pro',
|
||||
getBugCommand: () => ({ urlTemplate: customTemplate }),
|
||||
getIdeMode: () => true,
|
||||
getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }),
|
||||
},
|
||||
geminiClient: {
|
||||
config: {
|
||||
getModel: () => 'gemini-pro',
|
||||
getBugCommand: () => ({ urlTemplate: customTemplate }),
|
||||
getIdeMode: () => true,
|
||||
getGeminiClient: () => ({
|
||||
getChat: () => ({
|
||||
getHistory: () => [],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,8 +32,8 @@ export const bugCommand: SlashCommand = {
|
||||
autoExecute: false,
|
||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||
const bugDescription = (args || '').trim();
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
|
||||
const osVersion = `${process.platform} ${process.version}`;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
||||
@@ -73,7 +73,7 @@ export const bugCommand: SlashCommand = {
|
||||
info += `* **IDE Client:** ${ideClient}\n`;
|
||||
}
|
||||
|
||||
const chat = agentContext?.geminiClient?.getChat();
|
||||
const chat = config?.getGeminiClient()?.getChat();
|
||||
const history = chat?.getHistory() || [];
|
||||
let historyFileMessage = '';
|
||||
let problemValue = bugDescription;
|
||||
@@ -134,7 +134,7 @@ export const bugCommand: SlashCommand = {
|
||||
};
|
||||
|
||||
async function getIdeClientName(context: CommandContext) {
|
||||
if (!context.services.agentContext?.config.getIdeMode()) {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
|
||||
@@ -70,19 +70,18 @@ describe('chatCommand', () => {
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getProjectRoot: () => '/project/root',
|
||||
getContentGeneratorConfig: () => ({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash',
|
||||
},
|
||||
config: {
|
||||
getProjectRoot: () => '/project/root',
|
||||
getGeminiClient: () =>
|
||||
({
|
||||
getChat: mockGetChat,
|
||||
}) as unknown as GeminiClient,
|
||||
storage: {
|
||||
getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash',
|
||||
},
|
||||
geminiClient: {
|
||||
getChat: mockGetChat,
|
||||
} as unknown as GeminiClient,
|
||||
getContentGeneratorConfig: () => ({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
}),
|
||||
},
|
||||
logger: {
|
||||
saveCheckpoint: mockSaveCheckpoint,
|
||||
@@ -699,11 +698,7 @@ Hi there!`;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetLatestApiRequest = vi.fn();
|
||||
if (!mockContext.services.agentContext!.config) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mockContext.services.agentContext!.config as any) = {};
|
||||
}
|
||||
mockContext.services.agentContext!.config.getLatestApiRequest =
|
||||
mockContext.services.config!.getLatestApiRequest =
|
||||
mockGetLatestApiRequest;
|
||||
vi.spyOn(process, 'cwd').mockReturnValue('/project/root');
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
|
||||
|
||||
@@ -35,7 +35,7 @@ const getSavedChatTags = async (
|
||||
context: CommandContext,
|
||||
mtSortDesc: boolean,
|
||||
): Promise<ChatDetail[]> => {
|
||||
const cfg = context.services.agentContext?.config;
|
||||
const cfg = context.services.config;
|
||||
const geminiDir = cfg?.storage?.getProjectTempDir();
|
||||
if (!geminiDir) {
|
||||
return [];
|
||||
@@ -103,8 +103,7 @@ const saveCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
const { logger } = context.services;
|
||||
const config = context.services.agentContext?.config;
|
||||
const { logger, config } = context.services;
|
||||
await logger.initialize();
|
||||
|
||||
if (!context.overwriteConfirmed) {
|
||||
@@ -126,7 +125,7 @@ const saveCommand: SlashCommand = {
|
||||
}
|
||||
}
|
||||
|
||||
const chat = context.services.agentContext?.geminiClient?.getChat();
|
||||
const chat = config?.getGeminiClient()?.getChat();
|
||||
if (!chat) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -173,8 +172,7 @@ const resumeCheckpointCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
const { logger } = context.services;
|
||||
const config = context.services.agentContext?.config;
|
||||
const { logger, config } = context.services;
|
||||
await logger.initialize();
|
||||
const checkpoint = await logger.loadCheckpoint(tag);
|
||||
const conversation = checkpoint.history;
|
||||
@@ -300,7 +298,7 @@ const shareCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
const chat = context.services.agentContext?.geminiClient?.getChat();
|
||||
const chat = context.services.config?.getGeminiClient()?.getChat();
|
||||
if (!chat) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -346,7 +344,7 @@ export const debugCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context): Promise<MessageActionReturn> => {
|
||||
const req = context.services.agentContext?.config.getLatestApiRequest();
|
||||
const req = context.services.config?.getLatestApiRequest();
|
||||
if (!req) {
|
||||
return {
|
||||
type: 'message',
|
||||
|
||||
@@ -36,25 +36,24 @@ describe('clearCommand', () => {
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getEnableHooks: vi.fn().mockReturnValue(false),
|
||||
setSessionId: vi.fn(),
|
||||
getMessageBus: vi.fn().mockReturnValue(undefined),
|
||||
getHookSystem: vi.fn().mockReturnValue({
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
injectionService: {
|
||||
clear: mockHintClear,
|
||||
},
|
||||
config: {
|
||||
getGeminiClient: () =>
|
||||
({
|
||||
resetChat: mockResetChat,
|
||||
getChat: () => ({
|
||||
getChatRecordingService: mockGetChatRecordingService,
|
||||
}),
|
||||
}) as unknown as GeminiClient,
|
||||
setSessionId: vi.fn(),
|
||||
getEnableHooks: vi.fn().mockReturnValue(false),
|
||||
getMessageBus: vi.fn().mockReturnValue(undefined),
|
||||
getHookSystem: vi.fn().mockReturnValue({
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
injectionService: {
|
||||
clear: mockHintClear,
|
||||
},
|
||||
geminiClient: {
|
||||
resetChat: mockResetChat,
|
||||
getChat: () => ({
|
||||
getChatRecordingService: mockGetChatRecordingService,
|
||||
}),
|
||||
} as unknown as GeminiClient,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -99,7 +98,7 @@ describe('clearCommand', () => {
|
||||
|
||||
const nullConfigContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: null,
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ export const clearCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context, _args) => {
|
||||
const geminiClient = context.services.agentContext?.geminiClient;
|
||||
const config = context.services.agentContext?.config;
|
||||
const geminiClient = context.services.config?.getGeminiClient();
|
||||
const config = context.services.config;
|
||||
|
||||
// Fire SessionEnd hook before clearing
|
||||
const hookSystem = config?.getHookSystem();
|
||||
|
||||
@@ -22,10 +22,11 @@ describe('compressCommand', () => {
|
||||
mockTryCompressChat = vi.fn();
|
||||
context = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
geminiClient: {
|
||||
tryCompressChat: mockTryCompressChat,
|
||||
} as unknown as GeminiClient,
|
||||
config: {
|
||||
getGeminiClient: () =>
|
||||
({
|
||||
tryCompressChat: mockTryCompressChat,
|
||||
}) as unknown as GeminiClient,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,11 +39,9 @@ export const compressCommand: SlashCommand = {
|
||||
try {
|
||||
ui.setPendingItem(pendingMessage);
|
||||
const promptId = `compress-${Date.now()}`;
|
||||
const compressed =
|
||||
await context.services.agentContext?.geminiClient?.tryCompressChat(
|
||||
promptId,
|
||||
true,
|
||||
);
|
||||
const compressed = await context.services.config
|
||||
?.getGeminiClient()
|
||||
?.tryCompressChat(promptId, true);
|
||||
if (compressed) {
|
||||
ui.addItem(
|
||||
{
|
||||
|
||||
@@ -29,10 +29,10 @@ describe('copyCommand', () => {
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
geminiClient: {
|
||||
config: {
|
||||
getGeminiClient: () => ({
|
||||
getChat: mockGetChat,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -301,7 +301,7 @@ describe('copyCommand', () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const nullConfigContext = createMockCommandContext({
|
||||
services: { agentContext: null },
|
||||
services: { config: null },
|
||||
});
|
||||
|
||||
const result = await copyCommand.action(nullConfigContext, '');
|
||||
|
||||
@@ -18,7 +18,7 @@ export const copyCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
|
||||
const chat = context.services.agentContext?.geminiClient?.getChat();
|
||||
const chat = context.services.config?.getGeminiClient()?.getChat();
|
||||
const history = chat?.getHistory();
|
||||
|
||||
// Get the last message from the AI (model role)
|
||||
|
||||
@@ -85,14 +85,11 @@ describe('directoryCommand', () => {
|
||||
getFileFilteringOptions: () => ({ ignore: [], include: [] }),
|
||||
setUserMemory: vi.fn(),
|
||||
setGeminiMdFileCount: vi.fn(),
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
mockContext = {
|
||||
services: {
|
||||
agentContext: mockConfig,
|
||||
config: mockConfig,
|
||||
settings: {
|
||||
merged: {
|
||||
memoryDiscoveryMaxDirs: 1000,
|
||||
|
||||
@@ -60,7 +60,7 @@ async function finishAddingDirectories(
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
const gemini = config.geminiClient;
|
||||
const gemini = config.getGeminiClient();
|
||||
if (gemini) {
|
||||
await gemini.addDirectoryContext();
|
||||
|
||||
@@ -110,9 +110,9 @@ export const directoryCommand: SlashCommand = {
|
||||
|
||||
// Filter out existing directories
|
||||
let filteredSuggestions = suggestions;
|
||||
if (context.services.agentContext?.config) {
|
||||
if (context.services.config) {
|
||||
const workspaceContext =
|
||||
context.services.agentContext.config.getWorkspaceContext();
|
||||
context.services.config.getWorkspaceContext();
|
||||
const existingDirs = new Set(
|
||||
workspaceContext.getDirectories().map((dir) => path.resolve(dir)),
|
||||
);
|
||||
@@ -144,11 +144,11 @@ export const directoryCommand: SlashCommand = {
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
const {
|
||||
ui: { addItem },
|
||||
services: { agentContext, settings },
|
||||
services: { config, settings },
|
||||
} = context;
|
||||
const [...rest] = args.split(' ');
|
||||
|
||||
if (!agentContext) {
|
||||
if (!config) {
|
||||
addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Configuration is not available.',
|
||||
@@ -156,7 +156,7 @@ export const directoryCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentContext.config.isRestrictiveSandbox()) {
|
||||
if (config.isRestrictiveSandbox()) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'error' as const,
|
||||
@@ -181,7 +181,7 @@ export const directoryCommand: SlashCommand = {
|
||||
const errors: string[] = [];
|
||||
const alreadyAdded: string[] = [];
|
||||
|
||||
const workspaceContext = agentContext.config.getWorkspaceContext();
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
const currentWorkspaceDirs = workspaceContext.getDirectories();
|
||||
const pathsToProcess: string[] = [];
|
||||
|
||||
@@ -252,7 +252,7 @@ export const directoryCommand: SlashCommand = {
|
||||
trustedDirs={added}
|
||||
errors={errors}
|
||||
finishAddingDirectories={finishAddingDirectories}
|
||||
config={agentContext.config}
|
||||
config={config}
|
||||
addItem={addItem}
|
||||
/>
|
||||
),
|
||||
@@ -264,12 +264,7 @@ export const directoryCommand: SlashCommand = {
|
||||
errors.push(...result.errors);
|
||||
}
|
||||
|
||||
await finishAddingDirectories(
|
||||
agentContext.config,
|
||||
addItem,
|
||||
added,
|
||||
errors,
|
||||
);
|
||||
await finishAddingDirectories(config, addItem, added, errors);
|
||||
return;
|
||||
},
|
||||
},
|
||||
@@ -280,16 +275,16 @@ export const directoryCommand: SlashCommand = {
|
||||
action: async (context: CommandContext) => {
|
||||
const {
|
||||
ui: { addItem },
|
||||
services: { agentContext },
|
||||
services: { config },
|
||||
} = context;
|
||||
if (!agentContext) {
|
||||
if (!config) {
|
||||
addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Configuration is not available.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const workspaceContext = agentContext.config.getWorkspaceContext();
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
const directories = workspaceContext.getDirectories();
|
||||
const directoryList = directories.map((dir) => `- ${dir}`).join('\n');
|
||||
addItem({
|
||||
|
||||
@@ -161,16 +161,14 @@ describe('extensionsCommand', () => {
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader),
|
||||
getWorkingDir: () => '/test/dir',
|
||||
reloadSkills: mockReloadSkills,
|
||||
getAgentRegistry: vi.fn().mockReturnValue({
|
||||
reload: mockReloadAgents,
|
||||
}),
|
||||
},
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader),
|
||||
getWorkingDir: () => '/test/dir',
|
||||
reloadSkills: mockReloadSkills,
|
||||
getAgentRegistry: vi.fn().mockReturnValue({
|
||||
reload: mockReloadAgents,
|
||||
}),
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
@@ -710,14 +708,10 @@ describe('extensionsCommand', () => {
|
||||
size: 100,
|
||||
} as Stats);
|
||||
await linkAction!(mockContext, packageName);
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith(
|
||||
{
|
||||
source: packageName,
|
||||
type: 'link',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||
source: packageName,
|
||||
type: 'link',
|
||||
});
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||
type: MessageType.INFO,
|
||||
text: `Linking extension from "${packageName}"...`,
|
||||
@@ -737,14 +731,10 @@ describe('extensionsCommand', () => {
|
||||
} as Stats);
|
||||
|
||||
await linkAction!(mockContext, packageName);
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith(
|
||||
{
|
||||
source: packageName,
|
||||
type: 'link',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||
source: packageName,
|
||||
type: 'link',
|
||||
});
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to link extension from "${packageName}": ${errorMessage}`,
|
||||
@@ -927,7 +917,7 @@ describe('extensionsCommand', () => {
|
||||
expect(restartAction).not.toBeNull();
|
||||
|
||||
mockRestartExtension = vi.fn();
|
||||
mockContext.services.agentContext!.config.getExtensionLoader = vi
|
||||
mockContext.services.config!.getExtensionLoader = vi
|
||||
.fn()
|
||||
.mockImplementation(() => ({
|
||||
getExtensions: mockGetExtensions,
|
||||
@@ -937,7 +927,7 @@ describe('extensionsCommand', () => {
|
||||
});
|
||||
|
||||
it('should show a message if no extensions are installed', async () => {
|
||||
mockContext.services.agentContext!.config.getExtensionLoader = vi
|
||||
mockContext.services.config!.getExtensionLoader = vi
|
||||
.fn()
|
||||
.mockImplementation(() => ({
|
||||
getExtensions: () => [],
|
||||
@@ -1027,7 +1017,7 @@ describe('extensionsCommand', () => {
|
||||
});
|
||||
|
||||
it('shows an error if no extension loader is available', async () => {
|
||||
mockContext.services.agentContext!.config.getExtensionLoader = vi.fn();
|
||||
mockContext.services.config!.getExtensionLoader = vi.fn();
|
||||
|
||||
await restartAction!(mockContext, '--all');
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ function showMessageIfNoExtensions(
|
||||
}
|
||||
|
||||
async function listAction(context: CommandContext) {
|
||||
const extensions = context.services.agentContext?.config
|
||||
? listExtensions(context.services.agentContext.config)
|
||||
const extensions = context.services.config
|
||||
? listExtensions(context.services.config)
|
||||
: [];
|
||||
|
||||
if (showMessageIfNoExtensions(context, extensions)) {
|
||||
@@ -88,8 +88,8 @@ function updateAction(context: CommandContext, args: string): Promise<void> {
|
||||
(resolve) => (resolveUpdateComplete = resolve),
|
||||
);
|
||||
|
||||
const extensions = context.services.agentContext?.config
|
||||
? listExtensions(context.services.agentContext.config)
|
||||
const extensions = context.services.config
|
||||
? listExtensions(context.services.config)
|
||||
: [];
|
||||
|
||||
if (showMessageIfNoExtensions(context, extensions)) {
|
||||
@@ -128,7 +128,7 @@ function updateAction(context: CommandContext, args: string): Promise<void> {
|
||||
},
|
||||
});
|
||||
if (names?.length) {
|
||||
const extensions = listExtensions(context.services.agentContext!.config);
|
||||
const extensions = listExtensions(context.services.config!);
|
||||
for (const name of names) {
|
||||
const extension = extensions.find(
|
||||
(extension) => extension.name === name,
|
||||
@@ -156,8 +156,7 @@ async function restartAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void> {
|
||||
const extensionLoader =
|
||||
context.services.agentContext?.config.getExtensionLoader();
|
||||
const extensionLoader = context.services.config?.getExtensionLoader();
|
||||
if (!extensionLoader) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
@@ -236,8 +235,8 @@ async function restartAction(
|
||||
|
||||
if (failures.length < extensionsToRestart.length) {
|
||||
try {
|
||||
await context.services.agentContext?.config.reloadSkills();
|
||||
await context.services.agentContext?.config.getAgentRegistry()?.reload();
|
||||
await context.services.config?.reloadSkills();
|
||||
await context.services.config?.getAgentRegistry()?.reload();
|
||||
} catch (error) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
@@ -275,8 +274,7 @@ async function exploreAction(
|
||||
const useRegistryUI = settings.experimental?.extensionRegistry;
|
||||
|
||||
if (useRegistryUI) {
|
||||
const extensionManager =
|
||||
context.services.agentContext?.config.getExtensionLoader();
|
||||
const extensionManager = context.services.config?.getExtensionLoader();
|
||||
if (extensionManager instanceof ExtensionManager) {
|
||||
return {
|
||||
type: 'custom_dialog' as const,
|
||||
@@ -286,11 +284,6 @@ async function exploreAction(
|
||||
await installAction(context, extension.url, requestConsentOverride);
|
||||
context.ui.removeComponent();
|
||||
},
|
||||
onLink: async (extension, requestConsentOverride) => {
|
||||
debugLogger.log(`Linking extension: ${extension.extensionName}`);
|
||||
await linkAction(context, extension.url, requestConsentOverride);
|
||||
context.ui.removeComponent();
|
||||
},
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
extensionManager,
|
||||
}),
|
||||
@@ -338,8 +331,7 @@ function getEnableDisableContext(
|
||||
names: string[];
|
||||
scope: SettingScope;
|
||||
} | null {
|
||||
const extensionLoader =
|
||||
context.services.agentContext?.config.getExtensionLoader();
|
||||
const extensionLoader = context.services.config?.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
@@ -439,8 +431,7 @@ async function enableAction(context: CommandContext, args: string) {
|
||||
|
||||
if (extension?.mcpServers) {
|
||||
const mcpEnablementManager = McpServerEnablementManager.getInstance();
|
||||
const mcpClientManager =
|
||||
context.services.agentContext?.config.getMcpClientManager();
|
||||
const mcpClientManager = context.services.config?.getMcpClientManager();
|
||||
const enabledServers = await mcpEnablementManager.autoEnableServers(
|
||||
Object.keys(extension.mcpServers ?? {}),
|
||||
);
|
||||
@@ -472,8 +463,7 @@ async function installAction(
|
||||
args: string,
|
||||
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
||||
) {
|
||||
const extensionLoader =
|
||||
context.services.agentContext?.config.getExtensionLoader();
|
||||
const extensionLoader = context.services.config?.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
@@ -538,13 +528,8 @@ async function installAction(
|
||||
}
|
||||
}
|
||||
|
||||
async function linkAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
||||
) {
|
||||
const extensionLoader =
|
||||
context.services.agentContext?.config.getExtensionLoader();
|
||||
async function linkAction(context: CommandContext, args: string) {
|
||||
const extensionLoader = context.services.config?.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
@@ -591,11 +576,8 @@ async function linkAction(
|
||||
source: sourceFilepath,
|
||||
type: 'link',
|
||||
};
|
||||
const extension = await extensionLoader.installOrUpdateExtension(
|
||||
installMetadata,
|
||||
undefined,
|
||||
requestConsentOverride,
|
||||
);
|
||||
const extension =
|
||||
await extensionLoader.installOrUpdateExtension(installMetadata);
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Extension "${extension.name}" linked successfully.`,
|
||||
@@ -611,8 +593,7 @@ async function linkAction(
|
||||
}
|
||||
|
||||
async function uninstallAction(context: CommandContext, args: string) {
|
||||
const extensionLoader =
|
||||
context.services.agentContext?.config.getExtensionLoader();
|
||||
const extensionLoader = context.services.config?.getExtensionLoader();
|
||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
@@ -711,8 +692,7 @@ async function configAction(context: CommandContext, args: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const extensionManager =
|
||||
context.services.agentContext?.config.getExtensionLoader();
|
||||
const extensionManager = context.services.config?.getExtensionLoader();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
@@ -749,7 +729,7 @@ export function completeExtensions(
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
) {
|
||||
let extensions = context.services.agentContext?.config.getExtensions() ?? [];
|
||||
let extensions = context.services.config?.getExtensions() ?? [];
|
||||
|
||||
if (context.invocation?.name === 'enable') {
|
||||
extensions = extensions.filter((ext) => !ext.isActive);
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('hooksCommand', () => {
|
||||
// Create mock context with config and settings
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: { config: mockConfig },
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
@@ -141,7 +141,7 @@ describe('hooksCommand', () => {
|
||||
it('should return error when config is not loaded', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: null,
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('hooksCommand', () => {
|
||||
it('should return error when config is not loaded', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: null,
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -338,7 +338,7 @@ describe('hooksCommand', () => {
|
||||
it('should return error when config is not loaded', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: null,
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -470,7 +470,7 @@ describe('hooksCommand', () => {
|
||||
it('should return empty array when config is not available', () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: null,
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -567,7 +567,7 @@ describe('hooksCommand', () => {
|
||||
it('should return error when config is not loaded', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: null,
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -691,7 +691,7 @@ describe('hooksCommand', () => {
|
||||
it('should return error when config is not loaded', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: null,
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -27,8 +27,7 @@ import { HooksDialog } from '../components/HooksDialog.js';
|
||||
function panelAction(
|
||||
context: CommandContext,
|
||||
): MessageActionReturn | OpenCustomDialogActionReturn {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -56,8 +55,7 @@ async function enableAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | MessageActionReturn> {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -110,8 +108,7 @@ async function disableAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | MessageActionReturn> {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -166,8 +163,7 @@ function completeEnabledHookNames(
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
): string[] {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
@@ -187,8 +183,7 @@ function completeDisabledHookNames(
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
): string[] {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
@@ -214,8 +209,7 @@ function getHookDisplayName(hook: HookRegistryEntry): string {
|
||||
async function enableAllAction(
|
||||
context: CommandContext,
|
||||
): Promise<void | MessageActionReturn> {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -286,8 +280,7 @@ async function enableAllAction(
|
||||
async function disableAllAction(
|
||||
context: CommandContext,
|
||||
): Promise<void | MessageActionReturn> {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
|
||||
@@ -60,12 +60,10 @@ describe('ideCommand', () => {
|
||||
settings: {
|
||||
setValue: vi.fn(),
|
||||
},
|
||||
agentContext: {
|
||||
config: {
|
||||
getIdeMode: vi.fn(),
|
||||
setIdeMode: vi.fn(),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
},
|
||||
config: {
|
||||
getIdeMode: vi.fn(),
|
||||
setIdeMode: vi.fn(),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
@@ -217,13 +217,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
);
|
||||
// Poll for up to 5 seconds for the extension to activate.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await setIdeModeAndSyncConnection(
|
||||
context.services.agentContext!.config,
|
||||
true,
|
||||
{
|
||||
logToConsole: false,
|
||||
},
|
||||
);
|
||||
await setIdeModeAndSyncConnection(context.services.config!, true, {
|
||||
logToConsole: false,
|
||||
});
|
||||
if (
|
||||
ideClient.getConnectionStatus().status ===
|
||||
IDEConnectionStatus.Connected
|
||||
@@ -266,10 +262,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
'ide.enabled',
|
||||
true,
|
||||
);
|
||||
await setIdeModeAndSyncConnection(
|
||||
context.services.agentContext!.config,
|
||||
true,
|
||||
);
|
||||
await setIdeModeAndSyncConnection(context.services.config!, true);
|
||||
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||
context.ui.addItem(
|
||||
{
|
||||
@@ -292,10 +285,7 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
'ide.enabled',
|
||||
false,
|
||||
);
|
||||
await setIdeModeAndSyncConnection(
|
||||
context.services.agentContext!.config,
|
||||
false,
|
||||
);
|
||||
await setIdeModeAndSyncConnection(context.services.config!, false);
|
||||
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||
context.ui.addItem(
|
||||
{
|
||||
|
||||
@@ -31,10 +31,8 @@ describe('initCommand', () => {
|
||||
// Create a fresh mock context for each test
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getTargetDir: () => targetDir,
|
||||
},
|
||||
config: {
|
||||
getTargetDir: () => targetDir,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -96,7 +94,7 @@ describe('initCommand', () => {
|
||||
// Arrange: Create a context without config
|
||||
const noConfigContext = createMockCommandContext();
|
||||
if (noConfigContext.services) {
|
||||
noConfigContext.services.agentContext = null;
|
||||
noConfigContext.services.config = null;
|
||||
}
|
||||
|
||||
// Act: Run the command's action
|
||||
|
||||
@@ -23,14 +23,14 @@ export const initCommand: SlashCommand = {
|
||||
context: CommandContext,
|
||||
_args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
if (!context.services.agentContext?.config) {
|
||||
if (!context.services.config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Configuration not available.',
|
||||
};
|
||||
}
|
||||
const targetDir = context.services.agentContext.config.getTargetDir();
|
||||
const targetDir = context.services.config.getTargetDir();
|
||||
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
|
||||
|
||||
const result = performInit(fs.existsSync(geminiMdPath));
|
||||
|
||||
@@ -119,10 +119,7 @@ describe('mcpCommand', () => {
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: mockConfig,
|
||||
toolRegistry: mockConfig.getToolRegistry(),
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -135,7 +132,7 @@ describe('mcpCommand', () => {
|
||||
it('should show an error if config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: null,
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -149,8 +146,7 @@ describe('mcpCommand', () => {
|
||||
});
|
||||
|
||||
it('should show an error if tool registry is not available', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mockContext.services.agentContext as any).toolRegistry = undefined;
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
@@ -200,13 +196,9 @@ describe('mcpCommand', () => {
|
||||
...mockServer3Tools,
|
||||
];
|
||||
|
||||
const mockToolRegistry = {
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue(allTools),
|
||||
};
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue(mockToolRegistry);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mockContext.services.agentContext as any).toolRegistry =
|
||||
mockToolRegistry;
|
||||
});
|
||||
|
||||
const resourcesByServer: Record<
|
||||
string,
|
||||
|
||||
@@ -42,8 +42,8 @@ const authCommand: SlashCommand = {
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const serverName = args.trim();
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -138,7 +138,7 @@ const authCommand: SlashCommand = {
|
||||
await mcpClientManager.restartServer(serverName);
|
||||
}
|
||||
// Update the client with the new tools
|
||||
const geminiClient = context.services.agentContext?.geminiClient;
|
||||
const geminiClient = config.getGeminiClient();
|
||||
if (geminiClient?.isInitialized()) {
|
||||
await geminiClient.setTools();
|
||||
}
|
||||
@@ -162,8 +162,7 @@ const authCommand: SlashCommand = {
|
||||
}
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
|
||||
@@ -178,8 +177,7 @@ const listAction = async (
|
||||
showDescriptions = false,
|
||||
showSchema = false,
|
||||
): Promise<void | MessageActionReturn> => {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -190,7 +188,7 @@ const listAction = async (
|
||||
|
||||
config.setUserInteractedWithMcp();
|
||||
|
||||
const toolRegistry = agentContext.toolRegistry;
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (!toolRegistry) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -336,8 +334,7 @@ const reloadCommand: SlashCommand = {
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -363,7 +360,7 @@ const reloadCommand: SlashCommand = {
|
||||
await mcpClientManager.restart();
|
||||
|
||||
// Update the client with the new tools
|
||||
const geminiClient = agentContext.geminiClient;
|
||||
const geminiClient = config.getGeminiClient();
|
||||
if (geminiClient?.isInitialized()) {
|
||||
await geminiClient.setTools();
|
||||
}
|
||||
@@ -380,8 +377,7 @@ async function handleEnableDisable(
|
||||
args: string,
|
||||
enable: boolean,
|
||||
): Promise<MessageActionReturn> {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -469,8 +465,8 @@ async function handleEnableDisable(
|
||||
);
|
||||
await mcpClientManager.restart();
|
||||
}
|
||||
if (agentContext.geminiClient?.isInitialized())
|
||||
await agentContext.geminiClient.setTools();
|
||||
if (config.getGeminiClient()?.isInitialized())
|
||||
await config.getGeminiClient().setTools();
|
||||
context.ui.reloadCommands();
|
||||
|
||||
return { type: 'message', messageType: 'info', content: msg };
|
||||
@@ -481,8 +477,7 @@ async function getEnablementCompletion(
|
||||
partialArg: string,
|
||||
showEnabled: boolean,
|
||||
): Promise<string[]> {
|
||||
const agentContext = context.services.agentContext;
|
||||
const config = agentContext?.config;
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
const servers = Object.keys(
|
||||
config.getMcpClientManager()?.getMcpServers() || {},
|
||||
|
||||
@@ -102,12 +102,10 @@ describe('memoryCommand', () => {
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getUserMemory: mockGetUserMemory,
|
||||
getGeminiMdFileCount: mockGetGeminiMdFileCount,
|
||||
getExtensionLoader: () => new SimpleExtensionLoader([]),
|
||||
},
|
||||
config: {
|
||||
getUserMemory: mockGetUserMemory,
|
||||
getGeminiMdFileCount: mockGetGeminiMdFileCount,
|
||||
getExtensionLoader: () => new SimpleExtensionLoader([]),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -252,7 +250,7 @@ describe('memoryCommand', () => {
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: { config: mockConfig },
|
||||
config: mockConfig,
|
||||
settings: {
|
||||
merged: {
|
||||
memoryDiscoveryMaxDirs: 1000,
|
||||
@@ -270,7 +268,7 @@ describe('memoryCommand', () => {
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
|
||||
// Enable JIT in mock config
|
||||
const config = mockContext.services.agentContext?.config;
|
||||
const config = mockContext.services.config;
|
||||
if (!config) throw new Error('Config is undefined');
|
||||
|
||||
vi.mocked(config.isJitContextEnabled).mockReturnValue(true);
|
||||
@@ -372,7 +370,7 @@ describe('memoryCommand', () => {
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const nullConfigContext = createMockCommandContext({
|
||||
services: { agentContext: null },
|
||||
services: { config: null },
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -415,10 +413,8 @@ describe('memoryCommand', () => {
|
||||
});
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getGeminiMdFilePaths: mockGetGeminiMdfilePaths,
|
||||
},
|
||||
config: {
|
||||
getGeminiMdFilePaths: mockGetGeminiMdfilePaths,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ export const memoryCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
const config = context.services.agentContext?.config;
|
||||
const config = context.services.config;
|
||||
if (!config) return;
|
||||
const result = showMemory(config);
|
||||
|
||||
@@ -81,7 +81,7 @@ export const memoryCommand: SlashCommand = {
|
||||
);
|
||||
|
||||
try {
|
||||
const config = context.services.agentContext?.config;
|
||||
const config = context.services.config;
|
||||
if (config) {
|
||||
const result = await refreshMemory(config);
|
||||
|
||||
@@ -111,7 +111,7 @@ export const memoryCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
const config = context.services.agentContext?.config;
|
||||
const config = context.services.config;
|
||||
if (!config) return;
|
||||
const result = listMemoryFiles(config);
|
||||
|
||||
|
||||
@@ -37,11 +37,8 @@ describe('modelCommand', () => {
|
||||
}
|
||||
|
||||
const mockRefreshUserQuota = vi.fn();
|
||||
mockContext.services.agentContext = {
|
||||
mockContext.services.config = {
|
||||
refreshUserQuota: mockRefreshUserQuota,
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
await modelCommand.action(mockContext, '');
|
||||
@@ -69,11 +66,8 @@ describe('modelCommand', () => {
|
||||
(c) => c.name === 'manage',
|
||||
);
|
||||
const mockRefreshUserQuota = vi.fn();
|
||||
mockContext.services.agentContext = {
|
||||
mockContext.services.config = {
|
||||
refreshUserQuota: mockRefreshUserQuota,
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
await manageCommand!.action!(mockContext, '');
|
||||
@@ -90,7 +84,7 @@ describe('modelCommand', () => {
|
||||
expect(setCommand).toBeDefined();
|
||||
|
||||
const mockSetModel = vi.fn();
|
||||
mockContext.services.agentContext = {
|
||||
mockContext.services.config = {
|
||||
setModel: mockSetModel,
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
|
||||
getUserId: vi.fn().mockReturnValue('test-user'),
|
||||
@@ -104,9 +98,6 @@ describe('modelCommand', () => {
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
getApprovalMode: vi.fn().mockReturnValue('auto'),
|
||||
}),
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
await setCommand!.action!(mockContext, 'gemini-pro');
|
||||
@@ -125,7 +116,7 @@ describe('modelCommand', () => {
|
||||
(c) => c.name === 'set',
|
||||
);
|
||||
const mockSetModel = vi.fn();
|
||||
mockContext.services.agentContext = {
|
||||
mockContext.services.config = {
|
||||
setModel: mockSetModel,
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
|
||||
getUserId: vi.fn().mockReturnValue('test-user'),
|
||||
@@ -139,9 +130,6 @@ describe('modelCommand', () => {
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
getApprovalMode: vi.fn().mockReturnValue('auto'),
|
||||
}),
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
await setCommand!.action!(mockContext, 'gemini-pro --persist');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user