diff --git a/docs/admin/enterprise-controls.md b/docs/admin/enterprise-controls.md index 5792a6c5bc..575b597db6 100644 --- a/docs/admin/enterprise-controls.md +++ b/docs/admin/enterprise-controls.md @@ -72,7 +72,7 @@ organization. **Supported Fields:** - `url`: (Required) The full URL of the MCP server endpoint. -- `type`: (Required) The connection type (e.g., `sse` or `http`). +- `type`: (Required) The connection type (for example, `sse` or `http`). - `trust`: (Optional) If set to `true`, the server is trusted and tool execution will not require user approval. - `includeTools`: (Optional) An explicit list of tool names to allow. If diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index f57ea4b56d..3184abf79d 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.37.0 +# Latest stable release: v0.37.1 -Released: April 08, 2026 +Released: April 09, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -26,6 +26,12 @@ npm install -g @google/gemini-cli ## What's Changed +- fix(acp): handle all InvalidStreamError types gracefully in prompt + [#24540](https://github.com/google-gemini/gemini-cli/pull/24540) +- feat(acp): add support for /about command + [#24649](https://github.com/google-gemini/gemini-cli/pull/24649) +- feat(acp): add /help command + [#24839](https://github.com/google-gemini/gemini-cli/pull/24839) - feat(evals): centralize test agents into test-utils for reuse by @Samee24 in [#23616](https://github.com/google-gemini/gemini-cli/pull/23616) - revert: chore(config): disable agents by default by @abhipatel12 in @@ -416,4 +422,4 @@ npm install -g @google/gemini-cli [#24842](https://github.com/google-gemini/gemini-cli/pull/24842) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.0 +https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.1 diff --git a/docs/cli/acp-mode.md b/docs/cli/acp-mode.md index 16ff3b9a15..a5f9b6a63a 100644 --- a/docs/cli/acp-mode.md +++ b/docs/cli/acp-mode.md @@ -44,8 +44,8 @@ and Gemini CLI (the server). - **Communication:** The entire communication happens over standard input/output (stdio) using the JSON-RPC 2.0 protocol. -- **Client's role:** The client is responsible for sending requests (e.g., - prompts) and handling responses and notifications from Gemini CLI. +- **Client's role:** The client is responsible for sending requests (for + example, prompts) and handling responses and notifications from Gemini CLI. - **Gemini CLI's role:** In ACP mode, Gemini CLI listens for incoming JSON-RPC requests, processes them, and sends back responses. @@ -72,8 +72,8 @@ leverage the IDE's capabilities to perform tasks. The MCP client logic is in ## Capabilities and supported methods -The ACP protocol exposes a number of methods for ACP clients (e.g. IDEs) to -control Gemini CLI. +The ACP protocol exposes a number of methods for ACP clients (for example IDEs) +to control Gemini CLI. ### Core methods @@ -87,8 +87,8 @@ control Gemini CLI. ### Session control -- `setSessionMode`: Allows changing the approval level for tool calls (e.g., to - `auto-approve`). +- `setSessionMode`: Allows changing the approval level for tool calls (for + example, to `auto-approve`). - `unstable_setSessionModel`: Changes the model for the current session. ### File system proxy diff --git a/docs/cli/checkpointing.md b/docs/cli/checkpointing.md index 3a4a690cea..775c9b7fea 100644 --- a/docs/cli/checkpointing.md +++ b/docs/cli/checkpointing.md @@ -1,9 +1,9 @@ # Checkpointing -The Gemini CLI includes a Checkpointing feature that automatically saves a -snapshot of your project's state before any file modifications are made by -AI-powered tools. This lets you safely experiment with and apply code changes, -knowing you can instantly revert back to the state before the tool was run. +Gemini CLI includes a Checkpointing feature that automatically saves a snapshot +of your project's state before any file modifications are made by AI-powered +tools. This lets you safely experiment with and apply code changes, knowing you +can instantly revert back to the state before the tool was run. ## How it works @@ -72,7 +72,7 @@ To see a list of all saved checkpoints for the current project, simply run: The CLI will display a list of available checkpoint files. These file names are typically composed of a timestamp, the name of the file being modified, and the -name of the tool that was about to be run (e.g., +name of the tool that was about to be run (for example, `2025-06-22T10-00-00_000Z-my-file.txt-write_file`). ### Restore a specific checkpoint diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index 39d98f60e9..e8217e226e 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -29,16 +29,16 @@ and parameters. These commands are available within the interactive REPL. -| Command | Description | -| -------------------- | ---------------------------------------- | -| `/skills reload` | Reload discovered skills from disk | -| `/agents reload` | Reload the agent registry | -| `/commands reload` | Reload custom slash commands | -| `/memory reload` | Reload context files (e.g., `GEMINI.md`) | -| `/mcp reload` | Restart and reload MCP servers | -| `/extensions reload` | Reload all active extensions | -| `/help` | Show help for all commands | -| `/quit` | Exit the interactive session | +| Command | Description | +| -------------------- | ----------------------------------------------- | +| `/skills reload` | Reload discovered skills from disk | +| `/agents reload` | Reload the agent registry | +| `/commands reload` | Reload custom slash commands | +| `/memory reload` | Reload context files (for example, `GEMINI.md`) | +| `/mcp reload` | Restart and reload MCP servers | +| `/extensions reload` | Reload all active extensions | +| `/help` | Show help for all commands | +| `/quit` | Exit the interactive session | ## CLI Options @@ -60,7 +60,7 @@ These commands are available within the interactive REPL. | `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../reference/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) | | `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) | | `--list-extensions` | `-l` | boolean | - | List all available extensions and exit | -| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) | +| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (for example `--resume 5`) | | `--list-sessions` | - | boolean | - | List available sessions for the current project and exit | | `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) | | `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) | diff --git a/docs/cli/creating-skills.md b/docs/cli/creating-skills.md index 9826ddbfce..71f7e6df8a 100644 --- a/docs/cli/creating-skills.md +++ b/docs/cli/creating-skills.md @@ -14,7 +14,7 @@ skill. To use it, ask Gemini CLI to create a new skill for you. Gemini CLI will then use the `skill-creator` to generate the skill: -1. Generate a new directory for your skill (e.g., `my-new-skill/`). +1. Generate a new directory for your skill (for example, `my-new-skill/`). 2. Create a `SKILL.md` file with the necessary YAML frontmatter (`name` and `description`). 3. Create the standard resource directories: `scripts/`, `references/`, and @@ -24,7 +24,7 @@ Gemini CLI will then use the `skill-creator` to generate the skill: If you prefer to create skills manually: -1. **Create a directory** for your skill (e.g., `my-new-skill/`). +1. **Create a directory** for your skill (for example, `my-new-skill/`). 2. **Create a `SKILL.md` file** inside the new directory. To add additional resources that support the skill, refer to the skill diff --git a/docs/cli/custom-commands.md b/docs/cli/custom-commands.md index 6fcce4e825..3cb3cea36a 100644 --- a/docs/cli/custom-commands.md +++ b/docs/cli/custom-commands.md @@ -85,8 +85,8 @@ The model receives: **B. Using arguments in shell commands (inside `!{...}` blocks)** When you use `{{args}}` inside a shell injection block (`!{...}`), the arguments -are automatically **shell-escaped** before replacement. This allows you to -safely pass arguments to shell commands, ensuring the resulting command is +are automatically **shell-escaped** before replacement. This lets you safely +pass arguments to shell commands, ensuring the resulting command is syntactically correct and secure while preventing command injection vulnerabilities. @@ -105,8 +105,8 @@ When you run `/grep-code It's complicated`: 1. The CLI sees `{{args}}` used both outside and inside `!{...}`. 2. Outside: The first `{{args}}` is replaced raw with `It's complicated`. -3. Inside: The second `{{args}}` is replaced with the escaped version (e.g., on - Linux: `"It\'s complicated"`). +3. Inside: The second `{{args}}` is replaced with the escaped version (for + example, on Linux: `"It\'s complicated"`). 4. The command executed is `grep -r "It's complicated" .`. 5. The CLI prompts you to confirm this exact, secure command before execution. 6. The final prompt is sent. @@ -116,13 +116,13 @@ When you run `/grep-code It's complicated`: If your `prompt` does **not** contain the special placeholder `{{args}}`, the CLI uses a default behavior for handling arguments. -If you provide arguments to the command (e.g., `/mycommand arg1`), the CLI will -append the full command you typed to the end of the prompt, separated by two -newlines. This allows the model to see both the original instructions and the -specific arguments you just provided. +If you provide arguments to the command (for example, `/mycommand arg1`), the +CLI will append the full command you typed to the end of the prompt, separated +by two newlines. This allows the model to see both the original instructions and +the specific arguments you just provided. -If you do **not** provide any arguments (e.g., `/mycommand`), the prompt is sent -to the model exactly as it is, with nothing appended. +If you do **not** provide any arguments (for example, `/mycommand`), the prompt +is sent to the model exactly as it is, with nothing appended. **Example (`changelog.toml`):** @@ -188,7 +188,7 @@ ensure that only intended commands can be run. dialog will appear showing the exact command(s) to be executed. 5. **Execution and error reporting:** The command is executed. If the command fails, the output injected into the prompt will include the error messages - (stderr) followed by a status line, e.g., + (stderr) followed by a status line, for example, `[Shell command exited with code 1]`. This helps the model understand the context of the failure. @@ -229,9 +229,10 @@ operate on specific files. - **File injection**: `@{path/to/file.txt}` is replaced by the content of `file.txt`. -- **Multimodal support**: If the path points to a supported image (e.g., PNG, - JPEG), PDF, audio, or video file, it will be correctly encoded and injected as - multimodal input. Other binary files are handled gracefully and skipped. +- **Multimodal support**: If the path points to a supported image (for example, + PNG, JPEG), PDF, audio, or video file, it will be correctly encoded and + injected as multimodal input. Other binary files are handled gracefully and + skipped. - **Directory listing**: `@{path/to/dir}` is traversed and each file present within the directory and all subdirectories is inserted into the prompt. This respects `.gitignore` and `.geminiignore` if enabled. diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md index 5e9cede33a..a34a4be269 100644 --- a/docs/cli/enterprise.md +++ b/docs/cli/enterprise.md @@ -175,8 +175,8 @@ the enterprise settings are always loaded with the highest precedence. **Example wrapper script:** Administrators can create a script named `gemini` and place it in a directory -that appears earlier in the user's `PATH` than the actual Gemini CLI binary -(e.g., `/usr/local/bin/gemini`). +that appears earlier in the user's `PATH` than the actual Gemini CLI binary (for +example, `/usr/local/bin/gemini`). ```bash #!/bin/bash @@ -325,9 +325,9 @@ User. When it comes to the `mcpServers` object, these configurations are 1. **Merging:** The lists of servers from all three levels are combined into a single list. 2. **Precedence:** If a server with the **same name** is defined at multiple - levels (e.g., a server named `corp-api` exists in both system and user - settings), the definition from the highest-precedence level is used. The - order of precedence is: **System > Workspace > User**. + levels (for example, a server named `corp-api` exists in both system and + user settings), the definition from the highest-precedence level is used. + The order of precedence is: **System > Workspace > User**. This means a user **cannot** override the definition of a server that is already defined in the system-level settings. However, they **can** add new servers with @@ -343,8 +343,8 @@ canonical servers and adding their names to an allowlist. For even greater security, especially when dealing with third-party MCP servers, you can restrict which specific tools from a server are exposed to the model. This is done using the `includeTools` and `excludeTools` properties within a -server's definition. This allows you to use a subset of tools from a server -without allowing potentially dangerous ones. +server's definition. This lets you use a subset of tools from a server without +allowing potentially dangerous ones. Following the principle of least privilege, it is highly recommended to use `includeTools` to create an allowlist of only the necessary tools. @@ -481,9 +481,8 @@ an environment variable, but it can also be enforced for custom tools via the ## Telemetry and auditing For auditing and monitoring purposes, you can configure Gemini CLI to send -telemetry data to a central location. This allows you to track tool usage and -other events. For more information, see the -[telemetry documentation](./telemetry.md). +telemetry data to a central location. This lets you track tool usage and other +events. For more information, see the [telemetry documentation](./telemetry.md). **Example:** Enable telemetry and send it to a local OTLP collector. If `otlpEndpoint` is not specified, it defaults to `http://localhost:4317`. diff --git a/docs/cli/gemini-ignore.md b/docs/cli/gemini-ignore.md index f7ec68aae3..fcdf94482c 100644 --- a/docs/cli/gemini-ignore.md +++ b/docs/cli/gemini-ignore.md @@ -1,9 +1,9 @@ # Ignoring files This document provides an overview of the Gemini Ignore (`.geminiignore`) -feature of the Gemini CLI. +feature of Gemini CLI. -The Gemini CLI includes the ability to automatically ignore files, similar to +Gemini CLI includes the ability to automatically ignore files, similar to `.gitignore` (used by Git) and `.aiexclude` (used by Gemini Code Assist). Adding paths to your `.geminiignore` file will exclude them from tools that support this feature, although they will still be visible to other services (such as diff --git a/docs/cli/generation-settings.md b/docs/cli/generation-settings.md index 79aa47e107..c5ba2151b8 100644 --- a/docs/cli/generation-settings.md +++ b/docs/cli/generation-settings.md @@ -1,26 +1,28 @@ # Advanced Model Configuration -This guide details the Model Configuration system within the Gemini CLI. -Designed for researchers, AI quality engineers, and advanced users, this system -provides a rigorous framework for managing generative model hyperparameters and +This guide details the Model Configuration system within Gemini CLI. Designed +for researchers, AI quality engineers, and advanced users, this system provides +a rigorous framework for managing generative model hyperparameters and behaviors. -> **Warning**: This is a power-user feature. Configuration values are passed + +> [!WARNING] +> This is a power-user feature. Configuration values are passed > directly to the model provider with minimal validation. Incorrect settings -> (e.g., incompatible parameter combinations) may result in runtime errors from -> the API. +> (for example, incompatible parameter combinations) may result in runtime +> errors from the API. ## 1. System Overview The Model Configuration system (`ModelConfigService`) enables deterministic -control over model generation. It decouples the requested model identifier -(e.g., a CLI flag or agent request) from the underlying API configuration. This -allows for: +control over model generation. It decouples the requested model identifier (for +example, a CLI flag or agent request) from the underlying API configuration. +This allows for: - **Precise Hyperparameter Tuning**: Direct control over `temperature`, `topP`, `thinkingBudget`, and other SDK-level parameters. - **Environment-Specific Behavior**: Distinct configurations for different - operating contexts (e.g., testing vs. production). + operating contexts (for example, testing vs. production). - **Agent-Scoped Customization**: Applying specific settings only when a particular agent is active. @@ -71,7 +73,7 @@ context. They are evaluated dynamically for each model request. specified `match` properties. - `model`: Matches the requested model name or alias. - `overrideScope`: Matches the distinct scope of the request (typically the - agent name, e.g., `codebaseInvestigator`). + agent name, for example, `codebaseInvestigator`). **Example Override**: @@ -113,8 +115,8 @@ and `overrideScope`). 1. **Filtering**: All matching overrides are identified. 2. **Sorting**: Matches are prioritized by **specificity** (the number of matched keys in the `match` object). - - Specific matches (e.g., `model` + `overrideScope`) override broad matches - (e.g., `model` only). + - Specific matches (for example, `model` + `overrideScope`) override broad + matches (for example, `model` only). - Tie-breaking: If specificity is equal, the order of definition in the `overrides` array is preserved (last one wins). 3. **Merging**: The configurations from the sorted overrides are merged @@ -128,10 +130,10 @@ The configuration follows the `ModelConfigServiceConfig` interface. Defines the actual parameters for the model. -| Property | Type | Description | -| :---------------------- | :------- | :----------------------------------------------------------------- | -| `model` | `string` | The identifier of the model to be called (e.g., `gemini-2.5-pro`). | -| `generateContentConfig` | `object` | The configuration object passed to the `@google/genai` SDK. | +| Property | Type | Description | +| :---------------------- | :------- | :------------------------------------------------------------------------ | +| `model` | `string` | The identifier of the model to be called (for example, `gemini-2.5-pro`). | +| `generateContentConfig` | `object` | The configuration object passed to the `@google/genai` SDK. | ### `GenerateContentConfig` (Common Parameters) @@ -142,7 +144,7 @@ Directly maps to the SDK's `GenerateContentConfig`. Common parameters include: - **`topP`**: (`number`) Nucleus sampling probability. - **`maxOutputTokens`**: (`number`) Limit on generated response length. - **`thinkingConfig`**: (`object`) Configuration for models with reasoning - capabilities (e.g., `thinkingBudget`, `includeThoughts`). + capabilities (for example, `thinkingBudget`, `includeThoughts`). ## 5. Practical Examples @@ -170,7 +172,7 @@ configuration but enforcing zero temperature. ### Agent-Specific Parameter Injection Enforce extended thinking budgets for a specific agent without altering the -global default, e.g. for the `codebaseInvestigator`. +global default, for example for the `codebaseInvestigator`. ```json "modelConfigs": { diff --git a/docs/cli/model-routing.md b/docs/cli/model-routing.md index 3c7bd65bc5..c9ec073a64 100644 --- a/docs/cli/model-routing.md +++ b/docs/cli/model-routing.md @@ -10,8 +10,8 @@ Model routing is managed by the `ModelAvailabilityService`, which monitors model health and automatically routes requests to available models based on defined policies. -1. **Model failure:** If the currently selected model fails (e.g., due to quota - or server errors), the CLI will initiate the fallback process. +1. **Model failure:** If the currently selected model fails (for example, due + to quota or server errors), the CLI will initiate the fallback process. 2. **User consent:** Depending on the failure and the model's policy, the CLI may prompt you to switch to a fallback model (by default always prompts diff --git a/docs/cli/model-steering.md b/docs/cli/model-steering.md index 26ff4e1209..60f07253c4 100644 --- a/docs/cli/model-steering.md +++ b/docs/cli/model-steering.md @@ -19,7 +19,7 @@ Model steering is an experimental feature and is disabled by default. You can enable it using the `/settings` command or by updating your `settings.json` file. -1. Type `/settings` in the Gemini CLI. +1. Type `/settings` in Gemini CLI. 2. Search for **Model Steering**. 3. Set the value to **true**. diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 11f7a9e521..f5532a07ca 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -314,8 +314,8 @@ Hooks such as `BeforeTool` or `AfterTool` can be configured to intercept the > [!WARNING] When hooks are triggered by **tool executions**, they do **not** > run when you manually toggle Plan Mode using the `/plan` command or the > `Shift+Tab` keyboard shortcut. If you need hooks to execute on mode changes, -> ensure the transition is initiated by the agent (e.g., by asking "start a plan -> for..."). +> ensure the transition is initiated by the agent (for example, by asking "start +> a plan for..."). #### Example: Archive approved plans to GCS (`AfterTool`) diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index f81b561e0a..66f894d835 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -1,11 +1,11 @@ -# Sandboxing in the Gemini CLI +# Sandboxing in Gemini CLI -This document provides a guide to sandboxing in the Gemini CLI, including +This document provides a guide to sandboxing in Gemini CLI, including prerequisites, quickstart, and configuration. ## Prerequisites -Before using sandboxing, you need to install and set up the Gemini CLI: +Before using sandboxing, you need to install and set up Gemini CLI: ```bash npm install -g @google/gemini-cli @@ -229,7 +229,7 @@ gemini -p "run the test suite" 2. **Environment variable**: `GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc` 3. **Settings file**: `"sandbox": true` in the `tools` object of your - `settings.json` file (e.g., `{"tools": {"sandbox": true}}`). + `settings.json` file (for example, `{"tools": {"sandbox": true}}`). ### macOS Seatbelt profiles diff --git a/docs/cli/system-prompt.md b/docs/cli/system-prompt.md index c249d55cec..6d6388bc87 100644 --- a/docs/cli/system-prompt.md +++ b/docs/cli/system-prompt.md @@ -35,7 +35,7 @@ via a `.gemini/.env` file. See - `GEMINI_SYSTEM_MD=/absolute/path/to/my-system.md` - Relative paths are supported and resolved from the current working directory. - - Tilde expansion is supported (e.g., `~/my-system.md`). + - Tilde expansion is supported (for example, `~/my-system.md`). - Disable the override (use built‑in prompt): - `GEMINI_SYSTEM_MD=false` or `GEMINI_SYSTEM_MD=0` or unset the variable. @@ -70,7 +70,7 @@ dynamically include built-in content: - `${AvailableTools}`: Injects a bulleted list of all currently enabled tool names. - Tool Name Variables: Injects the actual name of a tool using the pattern: - `${toolName}_ToolName` (e.g., `${write_file_ToolName}`, + `${toolName}_ToolName` (for example, `${write_file_ToolName}`, `${run_shell_command_ToolName}`). This pattern is generated dynamically for all available tools. diff --git a/docs/cli/themes.md b/docs/cli/themes.md index 93912032c0..9a3d628c20 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -117,8 +117,8 @@ least `background.primary`, `text.primary`, `text.secondary`, and the various accent colors via `text.link`, `text.accent`, and `status` to ensure a cohesive UI. -You can use either hex codes (e.g., `#FF0000`) **or** standard CSS color names -(e.g., `coral`, `teal`, `blue`) for any color value. See +You can use either hex codes (for example, `#FF0000`) **or** standard CSS color +names (for example, `coral`, `teal`, `blue`) for any color value. See [CSS color names](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#color_keywords) for a full list of supported names. diff --git a/docs/cli/trusted-folders.md b/docs/cli/trusted-folders.md index c271a0dba2..cc4e880300 100644 --- a/docs/cli/trusted-folders.md +++ b/docs/cli/trusted-folders.md @@ -1,7 +1,7 @@ # Trusted Folders The Trusted Folders feature is a security setting that gives you control over -which projects can use the full capabilities of the Gemini CLI. It prevents +which projects can use the full capabilities of Gemini CLI. It prevents potentially malicious code from running by asking you to approve a folder before the CLI loads any project-specific configurations from it. @@ -24,12 +24,12 @@ Add the following to your user `settings.json` file: ## How it works: The trust dialog -Once the feature is enabled, the first time you run the Gemini CLI from a -folder, a dialog will automatically appear, prompting you to make a choice: +Once the feature is enabled, the first time you run Gemini CLI from a folder, a +dialog will automatically appear, prompting you to make a choice: -- **Trust folder**: Grants full trust to the current folder (e.g., +- **Trust folder**: Grants full trust to the current folder (for example, `my-project`). -- **Trust parent folder**: Grants trust to the parent directory (e.g., +- **Trust parent folder**: Grants trust to the parent directory (for example, `safe-projects`), which automatically trusts all of its subdirectories as well. This is useful if you keep all your safe projects in one place. - **Don't trust**: Marks the folder as untrusted. The CLI will operate in a @@ -40,9 +40,9 @@ will only be asked once per folder. ## Understanding folder contents: The discovery phase -Before you make a choice, the Gemini CLI performs a **discovery phase** to scan -the folder for potential configurations. This information is displayed in the -trust dialog to help you make an informed decision. +Before you make a choice, Gemini CLI performs a **discovery phase** to scan the +folder for potential configurations. This information is displayed in the trust +dialog to help you make an informed decision. The discovery UI lists the following categories of items found in the project: @@ -63,16 +63,16 @@ attention: settings, such as auto-approving certain tools or disabling the security sandbox. - **Discovery Errors**: If the CLI encounters issues while scanning the folder - (e.g., a malformed `settings.json` file), these errors will be displayed - prominently. + (for example, a malformed `settings.json` file), these errors will be + displayed prominently. By reviewing these details, you can ensure that you only grant trust to projects that you know are safe. ## Why trust matters: The impact of an untrusted workspace -When a folder is **untrusted**, the Gemini CLI runs in a restricted "safe mode" -to protect you. In this mode, the following features are disabled: +When a folder is **untrusted**, Gemini CLI runs in a restricted "safe mode" to +protect you. In this mode, the following features are disabled: 1. **Workspace settings are ignored**: The CLI will **not** load the `.gemini/settings.json` file from the project. This prevents the loading of @@ -97,8 +97,8 @@ to protect you. In this mode, the following features are disabled: commands from .toml files, including both project-specific and global user commands. -Granting trust to a folder unlocks the full functionality of the Gemini CLI for -that workspace. +Granting trust to a folder unlocks the full functionality of Gemini CLI for that +workspace. ## Managing your trust settings diff --git a/docs/cli/tutorials/mcp-setup.md b/docs/cli/tutorials/mcp-setup.md index 1eff7452ab..6d3646ade9 100644 --- a/docs/cli/tutorials/mcp-setup.md +++ b/docs/cli/tutorials/mcp-setup.md @@ -102,7 +102,7 @@ The agent will: ## Troubleshooting - **Server won't start?** Try running the docker command manually in your - terminal to see if it prints an error (e.g., "image not found"). + terminal to see if it prints an error (for example, "image not found"). - **Tools not found?** Run `/mcp reload` to force the CLI to re-query the server for its capabilities. diff --git a/docs/cli/tutorials/memory-management.md b/docs/cli/tutorials/memory-management.md index 2268ebd923..c2406e1d3c 100644 --- a/docs/cli/tutorials/memory-management.md +++ b/docs/cli/tutorials/memory-management.md @@ -50,7 +50,7 @@ loaded into every conversation. ### Scenario: Using the hierarchy -Context is loaded hierarchically. This allows you to have general rules for +Context is loaded hierarchically. This lets you have general rules for everything and specific rules for sub-projects. 1. **Global:** `~/.gemini/GEMINI.md` (Rules for _every_ project you work on). diff --git a/docs/cli/tutorials/plan-mode-steering.md b/docs/cli/tutorials/plan-mode-steering.md index 0384425848..b666877d5b 100644 --- a/docs/cli/tutorials/plan-mode-steering.md +++ b/docs/cli/tutorials/plan-mode-steering.md @@ -79,8 +79,8 @@ each step with higher confidence and fewer errors. - **Steer early:** Providing feedback during the research phase is more efficient than waiting for the final plan to be drafted. - **Use for context:** Steering is a great way to provide knowledge that might - not be obvious from reading the code (e.g., "We are planning to deprecate this - module next month"). + not be obvious from reading the code (for example, "We are planning to + deprecate this module next month"). ## Next steps diff --git a/docs/cli/tutorials/session-management.md b/docs/cli/tutorials/session-management.md index 6b50358b2c..3a0a6fae86 100644 --- a/docs/cli/tutorials/session-management.md +++ b/docs/cli/tutorials/session-management.md @@ -35,7 +35,7 @@ browser. This opens a searchable list of all your past sessions. You'll see: -- A timestamp (e.g., "2 hours ago"). +- A timestamp (for example, "2 hours ago"). - The first user message (helping you identify the topic). - The number of turns in the conversation. diff --git a/docs/cli/tutorials/shell-commands.md b/docs/cli/tutorials/shell-commands.md index 390c8acab9..9ff7cef4ef 100644 --- a/docs/cli/tutorials/shell-commands.md +++ b/docs/cli/tutorials/shell-commands.md @@ -58,7 +58,7 @@ watchers. **Prompt:** `Start the React dev server in the background.` -Gemini will run the command (e.g., `npm run dev`) and detach it. +Gemini will run the command (for example, `npm run dev`) and detach it. ### Scenario: Viewing active shells diff --git a/docs/cli/tutorials/task-planning.md b/docs/cli/tutorials/task-planning.md index e8f4f4d31d..86f7bab9a4 100644 --- a/docs/cli/tutorials/task-planning.md +++ b/docs/cli/tutorials/task-planning.md @@ -7,7 +7,7 @@ progress with the todo list. ## Prerequisites - Gemini CLI installed and authenticated. -- A complex task in mind (e.g., a multi-file refactor or new feature). +- A complex task in mind (for example, a multi-file refactor or new feature). ## Why use task planning? @@ -58,7 +58,7 @@ Tell the agent to proceed. As the agent works, you'll see the todo list update in real-time above the input box. -- **Current focus:** The active task is highlighted (e.g., +- **Current focus:** The active task is highlighted (for example, `[IN_PROGRESS] Create tsconfig.json`). - **Progress:** Completed tasks are marked as done. @@ -90,4 +90,4 @@ living document, not a static text block. - See the [Todo tool reference](../../tools/todos.md) for technical schema details. - Learn about [Memory management](memory-management.md) to persist planning - preferences (e.g., "Always create a test plan first"). + preferences (for example, "Always create a test plan first"). diff --git a/docs/core/index.md b/docs/core/index.md index ae5a6794fe..2724e8e922 100644 --- a/docs/core/index.md +++ b/docs/core/index.md @@ -29,7 +29,7 @@ While the `packages/cli` portion of Gemini CLI provides the user interface, potentially incorporating conversation history, tool definitions, and instructional context from `GEMINI.md` files. - **Tool management & orchestration:** - - Registering available tools (e.g., file system tools, shell command + - Registering available tools (for example, file system tools, shell command execution). - Interpreting tool use requests from the Gemini model. - Executing the requested tools with the provided arguments. @@ -45,7 +45,7 @@ The core plays a vital role in security: - **API key management:** It handles the `GEMINI_API_KEY` and ensures it's used securely when communicating with the Gemini API. -- **Tool execution:** When tools interact with the local system (e.g., +- **Tool execution:** When tools interact with the local system (for example, `run_shell_command`), the core (and its underlying tool implementations) must do so with appropriate caution, often involving sandboxing mechanisms to prevent unintended modifications. @@ -70,7 +70,7 @@ to use the CLI even if the default "pro" model is rate-limited. If you are using the default "pro" model and the CLI detects that you are being rate-limited, it automatically switches to the "flash" model for the current -session. This allows you to continue working without interruption. +session. This lets you continue working without interruption. Internal utility calls that use `gemini-2.5-flash-lite` (for example, prompt completion and classification) silently fall back to `gemini-2.5-flash` and @@ -90,9 +90,8 @@ in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories. -This allows you to have global, project-level, and component-level context -files, which are all combined to provide the model with the most relevant -information. +This lets you have global, project-level, and component-level context files, +which are all combined to provide the model with the most relevant information. You can use the [`/memory` command](../reference/commands.md) to `show`, `add`, and `refresh` the content of loaded `GEMINI.md` files. diff --git a/docs/core/local-model-routing.md b/docs/core/local-model-routing.md index 99f52511b0..220ee13c46 100644 --- a/docs/core/local-model-routing.md +++ b/docs/core/local-model-routing.md @@ -108,7 +108,7 @@ Download complete. $ ./lit.lit.macos_arm64 pull gemma3-1b-gpu-custom [Legal] The model you are about to download is governed by -the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing. +the Gemma Terms of Use and Prohibited Use Policy. Review these terms and ensure you agree before continuing. Full Terms: https://ai.google.dev/gemma/terms Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy diff --git a/docs/core/remote-agents.md b/docs/core/remote-agents.md index 584ad87847..7a3e7ffe2a 100644 --- a/docs/core/remote-agents.md +++ b/docs/core/remote-agents.md @@ -430,7 +430,7 @@ both behind auth. ## Managing Subagents -Users can manage subagents using the following commands within the Gemini CLI: +Users can manage subagents using the following commands within Gemini CLI: - `/agents list`: Displays all available local and remote subagents. - `/agents reload`: Reloads the agent registry. Use this after adding or diff --git a/docs/core/subagents.md b/docs/core/subagents.md index fd3fb1db71..a31cdfd324 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -358,7 +358,7 @@ it yourself; just report it. | `kind` | string | No | `local` (default) or `remote`. | | `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** | | `mcpServers` | object | No | Configuration for inline Model Context Protocol (MCP) servers isolated to this specific agent. | -| `model` | string | No | Specific model to use (e.g., `gemini-3-preview`). Defaults to `inherit` (uses the main session model). | +| `model` | string | No | Specific model to use (for example, `gemini-3-preview`). Defaults to `inherit` (uses the main session model). | | `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. | | `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. | | `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `10`. | @@ -410,8 +410,8 @@ With this feature, you can: ### Configuring isolated tools and servers You can configure tool isolation for a subagent by updating its markdown -frontmatter. This allows you to explicitly state which tools the subagent can -use, rather than relying on the global registry. +frontmatter. This lets you explicitly state which tools the subagent can use, +rather than relying on the global registry. Add an `mcpServers` object to define inline MCP servers that are unique to the agent. diff --git a/docs/extensions/best-practices.md b/docs/extensions/best-practices.md index 8ed3e7fc23..ccd1652c88 100644 --- a/docs/extensions/best-practices.md +++ b/docs/extensions/best-practices.md @@ -117,8 +117,9 @@ for your users. Follow [Semantic Versioning (SemVer)](https://semver.org/) to communicate changes clearly. -- **Major:** Breaking changes (e.g., renaming tools or changing arguments). -- **Minor:** New features (e.g., adding new tools or commands). +- **Major:** Breaking changes (for example, renaming tools or changing + arguments). +- **Minor:** New features (for example, adding new tools or commands). - **Patch:** Bug fixes and performance improvements. ### Release channels @@ -182,7 +183,7 @@ If your tools aren't working as expected: If a custom command isn't responding: - **Check precedence:** Remember that user and project commands take precedence - over extension commands. Use the prefixed name (e.g., `/extension.command`) to - verify the extension's version. + over extension commands. Use the prefixed name (for example, + `/extension.command`) to verify the extension's version. - **Help command:** Run `/help` to see a list of all available commands and their sources. diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index 56c51d30df..274cb61a78 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -88,12 +88,12 @@ gemini extensions new [template] ``` - ``: The directory to create. -- `[template]`: The template to use (e.g., `mcp-server`, `context`, +- `[template]`: The template to use (for example, `mcp-server`, `context`, `custom-commands`). ### Link a local extension -Create a symbolic link between your development directory and the Gemini CLI +Create a symbolic link between your development directory and Gemini CLI extensions directory. This lets you test changes immediately without reinstalling. @@ -244,7 +244,7 @@ agent definition files (`.md`) to an `agents/` directory in your extension root. ### Policy Engine -Extensions can contribute policy rules and safety checkers to the Gemini CLI +Extensions can contribute policy rules and safety checkers to Gemini CLI [Policy Engine](../reference/policy-engine.md). These rules are defined in `.toml` files and take effect when the extension is activated. @@ -324,13 +324,14 @@ defined in the `themes` array in `gemini-extension.json`. Custom themes provided by extensions can be selected using the `/theme` command or by setting the `ui.theme` property in your `settings.json` file. Note that when referring to a theme from an extension, the extension name is appended to -the theme name in parentheses, e.g., `shades-of-green (my-green-extension)`. +the theme name in parentheses, for example, +`shades-of-green (my-green-extension)`. ### Conflict resolution Extension commands have the lowest precedence. If an extension command name conflicts with a user or project command, the extension command is prefixed with -the extension name (e.g., `/gcp.deploy`) using a dot separator. +the extension name (for example, `/gcp.deploy`) using a dot separator. ## Variables diff --git a/docs/extensions/releasing.md b/docs/extensions/releasing.md index cb19c351a8..10ab3584ed 100644 --- a/docs/extensions/releasing.md +++ b/docs/extensions/releasing.md @@ -98,7 +98,7 @@ Use these values for the placeholders: **Examples:** - `darwin.arm64.my-tool.tar.gz` (specific to Apple Silicon Macs) -- `darwin.my-tool.tar.gz` (fallback for all Macs, e.g. Intel) +- `darwin.my-tool.tar.gz` (fallback for all Macs, for example Intel) - `linux.x64.my-tool.tar.gz` - `win32.my-tool.zip` @@ -155,9 +155,10 @@ jobs: ## Migrating an Extension Repository -If you need to move your extension to a new repository (e.g., from a personal -account to an organization) or rename it, you can use the `migratedTo` property -in your `gemini-extension.json` file to seamlessly transition your users. +If you need to move your extension to a new repository (for example, from a +personal account to an organization) or rename it, you can use the `migratedTo` +property in your `gemini-extension.json` file to seamlessly transition your +users. 1. **Create the new repository**: Setup your extension in its new location. 2. **Update the old repository**: In your original repository, update the @@ -173,7 +174,7 @@ in your `gemini-extension.json` file to seamlessly transition your users. ``` 3. **Release the update**: Publish this new version in your old repository. -When users check for updates, the Gemini CLI will detect the `migratedTo` field, +When users check for updates, Gemini CLI will detect the `migratedTo` field, verify that the new repository contains a valid extension update, and automatically update their local installation to track the new source and name moving forward. All extension settings will automatically migrate to the new diff --git a/docs/extensions/writing-extensions.md b/docs/extensions/writing-extensions.md index b22f69e672..f2dc730c29 100644 --- a/docs/extensions/writing-extensions.md +++ b/docs/extensions/writing-extensions.md @@ -7,22 +7,22 @@ linking it for local development. ## Prerequisites -Before you start, ensure you have the Gemini CLI installed and a basic -understanding of Node.js. +Before you start, ensure you have Gemini CLI installed and a basic understanding +of Node.js. ## Extension features Extensions offer several ways to customize Gemini CLI. Use this table to decide which features your extension needs. -| Feature | What it is | When to use it | Invoked by | -| :------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------- | -| **[MCP server](reference.md#mcp-servers)** | A standard way to expose new tools and data sources to the model. | Use this when you want the model to be able to _do_ new things, like fetching data from an internal API, querying a database, or controlling a local application. We also support MCP resources (which can replace custom commands) and system instructions (which can replace custom context) | Model | -| **[Custom commands](../cli/custom-commands.md)** | A shortcut (like `/my-cmd`) that executes a pre-defined prompt or shell command. | Use this for repetitive tasks or to save long, complex prompts that you use frequently. Great for automation. | User | -| **[Context file (`GEMINI.md`)](reference.md#contextfilename)** | A markdown file containing instructions that are loaded into the model's context at the start of every session. | Use this to define the "personality" of your extension, set coding standards, or provide essential knowledge that the model should always have. | CLI provides to model | -| **[Agent skills](../cli/skills.md)** | A specialized set of instructions and workflows that the model activates only when needed. | Use this for complex, occasional tasks (like "create a PR" or "audit security") to avoid cluttering the main context window when the skill isn't being used. | Model | -| **[Hooks](../hooks/index.md)** | A way to intercept and customize the CLI's behavior at specific lifecycle events (e.g., before/after a tool call). | Use this when you want to automate actions based on what the model is doing, like validating tool arguments, logging activity, or modifying the model's input/output. | CLI | -| **[Custom themes](reference.md#themes)** | A set of color definitions to personalize the CLI UI. | Use this to provide a unique visual identity for your extension or to offer specialized high-contrast or thematic color schemes. | User (via /theme) | +| Feature | What it is | When to use it | Invoked by | +| :------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------- | +| **[MCP server](reference.md#mcp-servers)** | A standard way to expose new tools and data sources to the model. | Use this when you want the model to be able to _do_ new things, like fetching data from an internal API, querying a database, or controlling a local application. We also support MCP resources (which can replace custom commands) and system instructions (which can replace custom context) | Model | +| **[Custom commands](../cli/custom-commands.md)** | A shortcut (like `/my-cmd`) that executes a pre-defined prompt or shell command. | Use this for repetitive tasks or to save long, complex prompts that you use frequently. Great for automation. | User | +| **[Context file (`GEMINI.md`)](reference.md#contextfilename)** | A markdown file containing instructions that are loaded into the model's context at the start of every session. | Use this to define the "personality" of your extension, set coding standards, or provide essential knowledge that the model should always have. | CLI provides to model | +| **[Agent skills](../cli/skills.md)** | A specialized set of instructions and workflows that the model activates only when needed. | Use this for complex, occasional tasks (like "create a PR" or "audit security") to avoid cluttering the main context window when the skill isn't being used. | Model | +| **[Hooks](../hooks/index.md)** | A way to intercept and customize the CLI's behavior at specific lifecycle events (for example, before/after a tool call). | Use this when you want to automate actions based on what the model is doing, like validating tool arguments, logging activity, or modifying the model's input/output. | CLI | +| **[Custom themes](reference.md#themes)** | A set of color definitions to personalize the CLI UI. | Use this to provide a unique visual identity for your extension or to offer specialized high-contrast or thematic color schemes. | User (via /theme) | ## Step 1: Create a new extension @@ -172,7 +172,7 @@ Link your extension to your Gemini CLI installation for local development. 2. **Link the extension:** - The `link` command creates a symbolic link from the Gemini CLI extensions + The `link` command creates a symbolic link from Gemini CLI extensions directory to your development directory. Changes you make are reflected immediately. diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index 11ef1edbbb..259070d3ec 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -60,7 +60,7 @@ or fallback to Gemini 2.5 Pro. > [!NOTE] > The **Keep trying** option uses exponential backoff, in which Gemini > CLI waits longer between each retry, when the system is busy. If the retry -> doesn't happen immediately, please wait a few minutes for the request to +> doesn't happen immediately, wait a few minutes for the request to > process. ### Model selection and routing types diff --git a/docs/get-started/index.md b/docs/get-started/index.md index 906998ab48..c6ea5ea4ae 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -1,7 +1,7 @@ # Get started with Gemini CLI Welcome to Gemini CLI! This guide will help you install, configure, and start -using the Gemini CLI to enhance your workflow right from your terminal. +using Gemini CLI to enhance your workflow right from your terminal. ## Quickstart: Install, authenticate, configure, and use Gemini CLI @@ -132,7 +132,7 @@ colors. After analyzing the source code, here's how it works: getters. The `red` getter adds the red color code, and the `bold` getter adds the bold code. -- **Output generation:** When the chain is treated as a string (e.g., in +- **Output generation:** When the chain is treated as a string (for example, in `console.log`), a final `toString()` method is called. This method joins all the stored ANSI codes, wraps them around the input string ('Hello'), and adds a reset code at the end. This produces the final, styled string that the diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md index 5158cfc5eb..1a4dd46de1 100644 --- a/docs/hooks/best-practices.md +++ b/docs/hooks/best-practices.md @@ -367,7 +367,7 @@ chmod +x .gemini/hooks/*.js ``` **Windows Note**: On Windows, PowerShell scripts (`.ps1`) don't use `chmod`, but -you may need to ensure your execution policy allows them to run (e.g., +you may need to ensure your execution policy allows them to run (for example, `Set-ExecutionPolicy RemoteSigned -Scope CurrentUser`). ### Version control @@ -401,12 +401,12 @@ git add .gemini/settings.json Understanding where hooks come from and what they can do is critical for secure usage. -| Hook Source | Description | -| :---------------------------- | :------------------------------------------------------------------------------------------------------------------------- | -| **System** | Configured by system administrators (e.g., `/etc/gemini-cli/settings.json`, `/Library/...`). Assumed to be the **safest**. | -| **User** (`~/.gemini/...`) | Configured by you. You are responsible for ensuring they are safe. | -| **Extensions** | You explicitly approve and install these. Security depends on the extension source (integrity). | -| **Project** (`./.gemini/...`) | **Untrusted by default.** Safest in trusted internal repos; higher risk in third-party/public repos. | +| Hook Source | Description | +| :---------------------------- | :-------------------------------------------------------------------------------------------------------------------------------- | +| **System** | Configured by system administrators (for example, `/etc/gemini-cli/settings.json`, `/Library/...`). Assumed to be the **safest**. | +| **User** (`~/.gemini/...`) | Configured by you. You are responsible for ensuring they are safe. | +| **Extensions** | You explicitly approve and install these. Security depends on the extension source (integrity). | +| **Project** (`./.gemini/...`) | **Untrusted by default.** Safest in trusted internal repos; higher risk in third-party/public repos. | #### Project Hook Security @@ -422,9 +422,10 @@ When you open a project with hooks defined in `.gemini/settings.json`: 5. **Trust**: The hook is marked as "trusted" for this project. > **Modification detection**: If the `command` string of a project hook is -> changed (e.g., by a `git pull`), its identity changes. Gemini CLI will treat -> it as a **new, untrusted hook** and warn you again. This prevents malicious -> actors from silently swapping a verified command for a malicious one. +> changed (for example, by a `git pull`), its identity changes. Gemini CLI will +> treat it as a **new, untrusted hook** and warn you again. This prevents +> malicious actors from silently swapping a verified command for a malicious +> one. ### Risks @@ -441,17 +442,17 @@ When you open a project with hooks defined in `.gemini/settings.json`: **Verify the source** of any project hooks or extensions before enabling them. - For open-source projects, a quick review of the hook scripts is recommended. -- For extensions, ensure you trust the author or publisher (e.g., verified - publishers, well-known community members). +- For extensions, ensure you trust the author or publisher (for example, + verified publishers, well-known community members). - Be cautious with obfuscated scripts or compiled binaries from unknown sources. #### Sanitize environment -Hooks inherit the environment of the Gemini CLI process, which may include -sensitive API keys. Gemini CLI provides a +Hooks inherit the environment of Gemini CLI process, which may include sensitive +API keys. Gemini CLI provides a [redaction system](../reference/configuration.md#environment-variable-redaction) -that automatically filters variables matching sensitive patterns (e.g., `KEY`, -`TOKEN`). +that automatically filters variables matching sensitive patterns (for example, +`KEY`, `TOKEN`). > **Disabled by Default**: Environment redaction is currently **OFF by > default**. We strongly recommend enabling it if you are running third-party @@ -511,7 +512,7 @@ chmod +x .gemini/hooks/my-hook.sh ``` **Windows Note**: On Windows, ensure your execution policy allows running -scripts (e.g., `Get-ExecutionPolicy`). +scripts (for example, `Get-ExecutionPolicy`). **Verify script path:** Ensure the path in `settings.json` resolves correctly. diff --git a/docs/hooks/index.md b/docs/hooks/index.md index f2c786361c..0d6ae6d447 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -63,9 +63,9 @@ Hooks communicate via `stdin` (Input) and `stdout` (Output). 2. **Pollution = Failure**: If `stdout` contains non-JSON text, parsing will fail. The CLI will default to "Allow" and treat the entire output as a `systemMessage`. -3. **Debug via Stderr**: Use `stderr` for **all** logging and debugging (e.g., - `echo "debug" >&2`). Gemini CLI captures `stderr` but never attempts to parse - it as JSON. +3. **Debug via Stderr**: Use `stderr` for **all** logging and debugging (for + example, `echo "debug" >&2`). Gemini CLI captures `stderr` but never attempts + to parse it as JSON. #### Exit codes @@ -74,7 +74,7 @@ execution: | Exit Code | Label | Behavioral Impact | | --------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **0** | **Success** | The `stdout` is parsed as JSON. **Preferred code** for all logic, including intentional blocks (e.g., `{"decision": "deny"}`). | +| **0** | **Success** | The `stdout` is parsed as JSON. **Preferred code** for all logic, including intentional blocks (for example, `{"decision": "deny"}`). | | **2** | **System Block** | **Critical Block**. The target action (tool, turn, or stop) is aborted. `stderr` is used as the rejection reason. High severity; used for security stops or script failures. | | **Other** | **Warning** | Non-fatal failure. A warning is shown, but the interaction proceeds using original parameters. | @@ -84,8 +84,9 @@ You can filter which specific tools or triggers fire your hook using the `matcher` field. - **Tool events** (`BeforeTool`, `AfterTool`): Matchers are **Regular - Expressions**. (e.g., `"write_.*"`). -- **Lifecycle events**: Matchers are **Exact Strings**. (e.g., `"startup"`). + Expressions**. (for example, `"write_.*"`). +- **Lifecycle events**: Matchers are **Exact Strings**. (for example, + `"startup"`). - **Wildcards**: `"*"` or `""` (empty string) matches all occurrences. ## Configuration @@ -151,8 +152,8 @@ Hooks are executed with a sanitized environment. **Project-level hooks** are particularly risky when opening untrusted projects. Gemini CLI **fingerprints** project hooks. If a hook's name or command changes -(e.g., via `git pull`), it is treated as a **new, untrusted hook** and you will -be warned before it executes. +(for example, via `git pull`), it is treated as a **new, untrusted hook** and +you will be warned before it executes. See [Security Considerations](../hooks/best-practices.md#using-hooks-securely) for a detailed threat model. diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md index 5242c3a13d..14846fe227 100644 --- a/docs/hooks/reference.md +++ b/docs/hooks/reference.md @@ -20,8 +20,8 @@ including JSON schemas and API details. ## Configuration schema -Hooks are defined in `settings.json` within the `hooks` object. Each event -(e.g., `BeforeTool`) contains an array of **hook definitions**. +Hooks are defined in `settings.json` within the `hooks` object. Each event (for +example, `BeforeTool`) contains an array of **hook definitions**. ### Hook definition @@ -52,7 +52,7 @@ All hooks receive these common fields via `stdin`: "session_id": string, // Unique ID for the current session "transcript_path": string, // Absolute path to session transcript JSON "cwd": string, // Current working directory - "hook_event_name": string, // The firing event (e.g. "BeforeTool") + "hook_event_name": string, // The firing event (for example "BeforeTool") "timestamp": string // ISO 8601 execution time } ``` @@ -81,12 +81,12 @@ Most hooks support these fields in their `stdout` JSON: For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is compared against the name of the tool being executed. -- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`, +- **Built-in Tools**: You can match any built-in tool (for example, `read_file`, `run_shell_command`). See the [Tools Reference](../reference/tools) for a full list of available tool names. - **MCP Tools**: Tools from MCP servers follow the naming pattern `mcp__`. -- **Regex Support**: Matchers support regular expressions (e.g., +- **Regex Support**: Matchers support regular expressions (for example, `matcher: "read_.*"` matches all file reading tools). ### `BeforeTool` @@ -194,7 +194,7 @@ request format. (generation params). - **Relevant Output Fields**: - `hookSpecificOutput.llm_request`: An object that **overrides** parts of the - outgoing request (e.g., changing models or temperature). + outgoing request (for example, changing models or temperature). - `hookSpecificOutput.llm_response`: A **Synthetic Response** object. If provided, the CLI skips the LLM call entirely and uses this as the response. - `decision`: Set to `"deny"` to block the request and abort the turn. @@ -271,14 +271,14 @@ telemetry. ### `Notification` -Fires when the CLI emits a system alert (e.g., Tool Permissions). Used for -external logging or cross-platform alerts. +Fires when the CLI emits a system alert (for example, Tool Permissions). Used +for external logging or cross-platform alerts. - **Input Fields**: - `notification_type`: (`"ToolPermission"`) - `message`: Summary of the alert. - - `details`: JSON object with alert-specific metadata (e.g., tool name, file - path). + - `details`: JSON object with alert-specific metadata (for example, tool name, + file path). - **Relevant Output Fields**: - `systemMessage`: Displayed alongside the system alert. - **Observability Only**: This hook **cannot** block alerts or grant permissions diff --git a/docs/ide-integration/ide-companion-spec.md b/docs/ide-integration/ide-companion-spec.md index 7ae22b7eb5..eb4e24bd82 100644 --- a/docs/ide-integration/ide-companion-spec.md +++ b/docs/ide-integration/ide-companion-spec.md @@ -20,9 +20,9 @@ Protocol (MCP)**. - **Protocol:** The server must be a valid MCP server. We recommend using an existing MCP SDK for your language of choice if available. -- **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for - all MCP communication. -- **Port:** The server **MUST** listen on a dynamically assigned port (i.e., +- **Endpoint:** The server should expose a single endpoint (for example, `/mcp`) + for all MCP communication. +- **Port:** The server **MUST** listen on a dynamically assigned port (that is, listen on port `0`). ### 2. Discovery mechanism: The port file @@ -68,15 +68,15 @@ creating a "discovery file." The CLI will include this token in an `Authorization: Bearer ` header on all requests. - `ideInfo` (object, required): Information about the IDE. - - `name` (string, required): A short, lowercase identifier for the IDE - (e.g., `vscode`, `jetbrains`). - - `displayName` (string, required): A user-friendly name for the IDE (e.g., - `VS Code`, `JetBrains IDE`). + - `name` (string, required): A short, lowercase identifier for the IDE (for + example, `vscode`, `jetbrains`). + - `displayName` (string, required): A user-friendly name for the IDE (for + example, `VS Code`, `JetBrains IDE`). - **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP - server (e.g., `Authorization: Bearer a-very-secret-token`). Your server + server (for example, `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized. - **Tie-breaking with environment variables (recommended):** For the most @@ -135,7 +135,7 @@ to the CLI whenever the user's context changes. > [!NOTE] > The `openFiles` list should only include files that exist on disk. -> Virtual files (e.g., unsaved files without a path, editor settings pages) +> Virtual files (for example, unsaved files without a path, editor settings pages) > **MUST** be excluded. ### How the CLI uses this context @@ -188,7 +188,7 @@ The plugin **MUST** register an `openDiff` tool on its MCP server. `CallToolResult` to acknowledge the request and report whether the diff view was successfully opened. - On Success: If the diff view was opened successfully, the response **MUST** - contain empty content (i.e., `content: []`). + contain empty content (that is, `content: []`). - On Failure: If an error prevented the diff view from opening, the response **MUST** have `isError: true` and include a `TextContent` block in the `content` array describing the error. @@ -223,9 +223,9 @@ The plugin **MUST** register a `closeDiff` tool on its MCP server. ### `ide/diffAccepted` notification -When the user accepts the changes in a diff view (e.g., by clicking an "Apply" -or "Save" button), the plugin **MUST** send an `ide/diffAccepted` notification -to the CLI. +When the user accepts the changes in a diff view (for example, by clicking an +"Apply" or "Save" button), the plugin **MUST** send an `ide/diffAccepted` +notification to the CLI. - **Payload:** The notification parameters **MUST** include the file path and the final content of the file. The content may differ from the original @@ -242,7 +242,7 @@ to the CLI. ### `ide/diffRejected` notification -When the user rejects the changes (e.g., by closing the diff view without +When the user rejects the changes (for example, by closing the diff view without accepting), the plugin **MUST** send an `ide/diffRejected` notification to the CLI. diff --git a/docs/ide-integration/index.md b/docs/ide-integration/index.md index 00b5ad846d..cc3b150c1a 100644 --- a/docs/ide-integration/index.md +++ b/docs/ide-integration/index.md @@ -132,7 +132,7 @@ editor. **To accept a diff**, you can perform any of the following actions: - Click the **checkmark icon** in the diff editor's title bar. -- Save the file (e.g., with `Cmd+S` or `Ctrl+S`). +- Save the file (for example, with `Cmd+S` or `Ctrl+S`). - Open the Command Palette and run **Gemini CLI: Accept Diff**. - Respond with `yes` in the CLI when prompted. @@ -208,7 +208,7 @@ directly through their in-built registry features. ## Using with sandboxing -If you are using Gemini CLI within a sandbox, please be aware of the following: +If you are using Gemini CLI within a sandbox, be aware of the following: - **On macOS:** The IDE integration requires network access to communicate with the IDE companion extension. You must use a Seatbelt profile that allows @@ -299,5 +299,5 @@ to connect using the provided PID. ### ACP integration errors -For issues related to ACP integration, please refer to the debugging and -telemetry section in the [ACP Mode](../cli/acp-mode.md) documentation. +For issues related to ACP integration, refer to the debugging and telemetry +section in the [ACP Mode](../cli/acp-mode.md) documentation. diff --git a/docs/integration-tests.md b/docs/integration-tests.md index ddd4eb9c73..06ac3a347f 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -6,8 +6,8 @@ in this project. ## Overview The integration tests are designed to validate the end-to-end functionality of -the Gemini CLI. They execute the built binary in a controlled environment and -verify that it behaves as expected when interacting with the file system. +Gemini CLI. They execute the built binary in a controlled environment and verify +that it behaves as expected when interacting with the file system. These tests are located in the `integration-tests` directory and are run using a custom test runner. diff --git a/docs/issue-and-pr-automation.md b/docs/issue-and-pr-automation.md index 6f27592833..3107bfcb4e 100644 --- a/docs/issue-and-pr-automation.md +++ b/docs/issue-and-pr-automation.md @@ -37,8 +37,8 @@ is to perform an initial analysis and apply the correct labels. - It uses a Gemini model to analyze the issue's title and body against a detailed set of guidelines. - **Applies one `area/*` label**: Categorizes the issue into a functional area - of the project (e.g., `area/ux`, `area/models`, `area/platform`). - - **Applies one `kind/*` label**: Identifies the type of issue (e.g., + of the project (for example, `area/ux`, `area/models`, `area/platform`). + - **Applies one `kind/*` label**: Identifies the type of issue (for example, `kind/bug`, `kind/enhancement`, `kind/question`). - **Applies one `priority/*` label**: Assigns a priority from P0 (critical) to P3 (low) based on the described impact. @@ -50,8 +50,8 @@ is to perform an initial analysis and apply the correct labels. - **What you should do**: - Fill out the issue template as completely as possible. The more detail you provide, the more accurate the triage will be. - - If the `status/need-information` label is added, please provide the - requested details in a comment. + - If the `status/need-information` label is added, provide the requested + details in a comment. ### 2. When you open a pull request: `Continuous Integration (CI)` @@ -84,7 +84,8 @@ issues and have consistent labels. - **When it runs**: Every 15 minutes on all open pull requests. - **What it does**: - **Checks for a linked issue**: The bot scans your PR description for a - keyword that links it to an issue (e.g., `Fixes #123`, `Closes #456`). + keyword that links it to an issue (for example, `Fixes #123`, + `Closes #456`). - **Adds `status/need-issue`**: If no linked issue is found, the bot will add the `status/need-issue` label to your PR. This is a clear signal that an issue needs to be created and linked. @@ -156,7 +157,7 @@ and will never be auto-unassigned. ### 6. Release automation This workflow handles the process of packaging and publishing new versions of -the Gemini CLI. +Gemini CLI. - **Workflow File**: `.github/workflows/release-manual.yml` - **When it runs**: On a daily schedule for "nightly" releases, and manually for @@ -171,4 +172,4 @@ the Gemini CLI. will be included in the very next nightly release. We hope this detailed overview is helpful. If you have any questions about our -automation or processes, please don't hesitate to ask! +automation or processes, don't hesitate to ask! diff --git a/docs/npm.md b/docs/npm.md index 33d8f7ec06..3ceab3c5e7 100644 --- a/docs/npm.md +++ b/docs/npm.md @@ -5,7 +5,7 @@ This monorepo contains two main packages: `@google/gemini-cli` and ## `@google/gemini-cli` -This is the main package for the Gemini CLI. It is responsible for the user +This is the main package for Gemini CLI. It is responsible for the user interface, command parsing, and all other user-facing functionality. When this package is published, it is bundled into a single executable file. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 67690f6ba2..7651539cb2 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -156,7 +156,7 @@ Slash commands provide meta-level control over the CLI itself. ### `/docs` -- **Description:** Open the Gemini CLI documentation in your browser. +- **Description:** Open Gemini CLI documentation in your browser. ### `/editor` @@ -400,8 +400,8 @@ Slash commands provide meta-level control over the CLI itself. ### `/shells` (or `/bashes`) -- **Description:** Toggle the background shells view. This allows you to view - and manage long-running processes that you've sent to the background. +- **Description:** Toggle the background shells view. This lets you view and + manage long-running processes that you've sent to the background. ### `/setup-github` @@ -474,7 +474,8 @@ Slash commands provide meta-level control over the CLI itself. input area supports vim-style navigation and editing commands in both NORMAL and INSERT modes. - **Features:** - - **Count support:** Prefix commands with numbers (e.g., `3h`, `5w`, `10G`) + - **Count support:** Prefix commands with numbers (for example, `3h`, `5w`, + `10G`) - **Editing commands:** Delete with `x`, change with `c`, insert with `i`, `a`, `o`, `O`; complex operations like `dd`, `cc`, `dw`, `cw` - **INSERT mode:** Standard text input with escape to return to NORMAL mode @@ -490,9 +491,8 @@ Slash commands provide meta-level control over the CLI itself. ### Custom commands Custom commands allow you to create personalized shortcuts for your most-used -prompts. For detailed instructions on how to create, manage, and use them, -please see the dedicated -[Custom Commands documentation](../cli/custom-commands.md). +prompts. For detailed instructions on how to create, manage, and use them, see +the dedicated [Custom Commands documentation](../cli/custom-commands.md). ## Input prompt shortcuts @@ -523,7 +523,7 @@ your prompt to Gemini. These commands include git-aware filtering. - If a path to a single file is provided, the content of that file is read. - If a path to a directory is provided, the command attempts to read the content of files within that directory and any subdirectories. - - Spaces in paths should be escaped with a backslash (e.g., + - Spaces in paths should be escaped with a backslash (for example, `@My\ Documents/file.txt`). - The command uses the `read_many_files` tool internally. The content is fetched and then inserted into your query before being sent to the Gemini @@ -549,8 +549,8 @@ your prompt to Gemini. These commands include git-aware filtering. - If the path specified after `@` is not found or is invalid, an error message will be displayed, and the query might not be sent to the Gemini model, or it will be sent without the file content. -- If the `read_many_files` tool encounters an error (e.g., permission issues), - this will also be reported. +- If the `read_many_files` tool encounters an error (for example, permission + issues), this will also be reported. ## Shell mode and passthrough commands (`!`) @@ -583,4 +583,4 @@ Gemini CLI. - **Environment variable:** When a command is executed via `!` or in shell mode, the `GEMINI_CLI=1` environment variable is set in the subprocess's environment. This allows scripts or tools to detect if they are being run from - within the Gemini CLI. + within Gemini CLI. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index f10336a0d9..f0acd3f5a4 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -71,7 +71,7 @@ Additionally, each extension can have its own `.env` file in its directory, which will be loaded automatically. **Note for Enterprise Users:** For guidance on deploying and managing Gemini CLI -in a corporate environment, please see the +in a corporate environment, see the [Enterprise Configuration](../cli/enterprise.md) documentation. ### The `.gemini` directory in your project @@ -79,7 +79,7 @@ in a corporate environment, please see the In addition to a project settings file, a project's `.gemini` directory can contain other project-specific files related to Gemini CLI's operation, such as: -- [Custom sandbox profiles](#sandboxing) (e.g., +- [Custom sandbox profiles](#sandboxing) (for example, `.gemini/sandbox-macos-custom.sb`, `.gemini/sandbox.Dockerfile`). ### Available settings in `settings.json` @@ -202,6 +202,12 @@ their corresponding top-level category object in your `settings.json` file. #### `ui` +- **`ui.debugRainbow`** (boolean): + - **Description:** Enable debug rainbow rendering. Only useful for debugging + rendering bugs and performance issues. + - **Default:** `false` + - **Requires restart:** Yes + - **`ui.theme`** (string): - **Description:** The color theme for the UI. See the CLI themes guide for available options. @@ -1912,15 +1918,15 @@ Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Gemini CLI attempts to connect to each configured MCP server to discover available tools. Every discovered tool is prepended with the `mcp_` prefix and its server alias to form a fully qualified -name (FQN) (e.g., `mcp_serverAlias_actualToolName`) to avoid conflicts. Note -that the system might strip certain schema properties from MCP tool definitions -for compatibility. At least one of `command`, `url`, or `httpUrl` must be -provided. If multiple are specified, the order of precedence is `httpUrl`, then -`url`, then `command`. +name (FQN) (for example, `mcp_serverAlias_actualToolName`) to avoid conflicts. +Note that the system might strip certain schema properties from MCP tool +definitions for compatibility. At least one of `command`, `url`, or `httpUrl` +must be provided. If multiple are specified, the order of precedence is +`httpUrl`, then `url`, then `command`. > [!WARNING] -> Avoid using underscores (`_`) in your server aliases (e.g., use +> Avoid using underscores (`_`) in your server aliases (for example, use > `my-server` instead of `my_server`). The underlying policy engine parses Fully > Qualified Names (`mcp_server_tool`) using the first underscore after the > `mcp_` prefix. An underscore in your server alias will cause the parser to @@ -2086,8 +2092,8 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - Your API key for the Gemini API. - One of several available [authentication methods](../get-started/authentication.md). - - Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` - file. + - Set this in your shell profile (for example, `~/.bashrc`, `~/.zshrc`) or an + `.env` file. - **`GEMINI_MODEL`**: - Specifies the default Gemini model to use. - Overrides the hardcoded default @@ -2171,7 +2177,7 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. Any other value is treated as disabling it. - Overrides the `telemetry.useCollector` setting. - **`GOOGLE_CLOUD_LOCATION`**: - - Your Google Cloud Project Location (e.g., us-central1). + - Your Google Cloud Project Location (for example, us-central1). - Required for using Vertex AI in non-express mode. - Example: `export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"` (Windows PowerShell: `$env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"`). @@ -2202,7 +2208,7 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - `strict-proxied`: Same as `strict-open` but routes network through proxy. - ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.gemini/` - directory (e.g., `my-project/.gemini/sandbox-macos-custom.sb`). + directory (for example, `my-project/.gemini/sandbox-macos-custom.sb`). - **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI itself): - Set to `true` or `1` to enable verbose debug logging, which can be helpful @@ -2241,7 +2247,7 @@ from the system or loaded from `.env` files. **Allowlist (Never Redacted):** -- Common system variables (e.g., `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, +- Common system variables (for example, `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `LANG`). - Variables starting with `GEMINI_CLI_`. - GitHub Action specific variables. @@ -2367,7 +2373,7 @@ for that specific session. While not strictly configuration for the CLI's _behavior_, context files (defaulting to `GEMINI.md` but configurable via the `context.fileName` setting) are crucial for configuring the _instructional context_ (also referred to as -"memory") provided to the Gemini model. This powerful feature allows you to give +"memory") provided to the Gemini model. This powerful feature lets you give project-specific instructions, coding style guides, or any relevant background information to the AI, making its responses more tailored and accurate to your needs. The CLI includes UI elements, such as an indicator in the footer showing @@ -2378,7 +2384,7 @@ context. that you want the Gemini model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically. -### Example context file content (e.g., `GEMINI.md`) +### Example context file content (for example, `GEMINI.md`) Here's a conceptual example of what a context file at the root of a TypeScript project might contain: @@ -2388,7 +2394,7 @@ project might contain: ## General Instructions: -- When generating new TypeScript code, please follow the existing coding style. +- When generating new TypeScript code, follow the existing coding style. - Ensure all new functions and classes have JSDoc comments. - Prefer functional programming paradigms where appropriate. - All code should be compatible with TypeScript 5.0 and Node.js 20+. @@ -2396,7 +2402,7 @@ project might contain: ## Coding Style: - Use 2 spaces for indentation. -- Interface names should be prefixed with `I` (e.g., `IUserService`). +- Interface names should be prefixed with `I` (for example, `IUserService`). - Private class members should be prefixed with an underscore (`_`). - Always use strict equality (`===` and `!==`). @@ -2410,7 +2416,7 @@ project might contain: ## Regarding Dependencies: - Avoid introducing new external dependencies unless absolutely necessary. -- If a new dependency is required, please state the reason. +- If a new dependency is required, state the reason. ``` This example demonstrates how you can provide general project context, specific @@ -2420,13 +2426,13 @@ you. Project-specific context files are highly encouraged to establish conventions and context. - **Hierarchical loading and precedence:** The CLI implements a sophisticated - hierarchical memory system by loading context files (e.g., `GEMINI.md`) from - several locations. Content from files lower in this list (more specific) + hierarchical memory system by loading context files (for example, `GEMINI.md`) + from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is: 1. **Global context file:** - - Location: `~/.gemini/` (e.g., + - Location: `~/.gemini/` (for example, `~/.gemini/GEMINI.md` in your user home directory). - Scope: Provides default instructions for all your projects. 2. **Project root and ancestors context files:** @@ -2463,12 +2469,12 @@ conventions and context. By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor -the Gemini CLI's responses to your specific needs and projects. +Gemini CLI's responses to your specific needs and projects. ## Sandboxing -The Gemini CLI can execute potentially unsafe operations (like shell commands -and file modifications) within a sandboxed environment to protect your system. +Gemini CLI can execute potentially unsafe operations (like shell commands and +file modifications) within a sandboxed environment to protect your system. Sandboxing is disabled by default, but you can enable it in a few ways: @@ -2505,9 +2511,9 @@ BUILD_SANDBOX=1 gemini -s ## Usage statistics -To help us improve the Gemini CLI, we collect anonymized usage statistics. This -data helps us understand how the CLI is used, identify common issues, and -prioritize new features. +To help us improve Gemini CLI, we collect anonymized usage statistics. This data +helps us understand how the CLI is used, identify common issues, and prioritize +new features. **What we collect:** diff --git a/docs/reference/memport.md b/docs/reference/memport.md index 1460404792..a8c2da5a2d 100644 --- a/docs/reference/memport.md +++ b/docs/reference/memport.md @@ -1,8 +1,7 @@ # Memory Import Processor -The Memory Import Processor is a feature that allows you to modularize your -GEMINI.md files by importing content from other files using the `@file.md` -syntax. +The Memory Import Processor is a feature that lets you modularize your GEMINI.md +files by importing content from other files using the `@file.md` syntax. ## Overview diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index 30458c23f9..a86c201b85 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -1,8 +1,8 @@ # Policy engine -The Gemini CLI includes a powerful policy engine that provides fine-grained -control over tool execution. It allows users and administrators to define rules -that determine whether a tool call should be allowed, denied, or require user +Gemini CLI includes a powerful policy engine that provides fine-grained control +over tool execution. It allows users and administrators to define rules that +determine whether a tool call should be allowed, denied, or require user confirmation. ## Quick start @@ -23,9 +23,9 @@ To create your first policy: New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini\policies" ``` -2. **Create a new policy file** (e.g., `~/.gemini/policies/my-rules.toml`). You - can use any filename ending in `.toml`; all such files in this directory - will be loaded and combined: +2. **Create a new policy file** (for example, + `~/.gemini/policies/my-rules.toml`). You can use any filename ending in + `.toml`; all such files in this directory will be loaded and combined: ```toml [[rule]] toolName = "run_shell_command" @@ -33,7 +33,7 @@ To create your first policy: decision = "deny" priority = 100 ``` -3. **Run a command** that triggers the policy (e.g., ask Gemini CLI to +3. **Run a command** that triggers the policy (for example, ask Gemini CLI to `rm -rf /`). The tool will now be blocked automatically. ## Core concepts @@ -127,13 +127,13 @@ rule with the highest priority wins**. To provide a clear hierarchy, policies are organized into three tiers. Each tier has a designated number that forms the base of the final priority calculation. -| Tier | Base | Description | -| :-------- | :--- | :------------------------------------------------------------------------- | -| Default | 1 | Built-in policies that ship with the Gemini CLI. | -| Extension | 2 | Policies defined in extensions. | -| Workspace | 3 | Policies defined in the current workspace's configuration directory. | -| User | 4 | Custom policies defined by the user. | -| Admin | 5 | Policies managed by an administrator (e.g., in an enterprise environment). | +| Tier | Base | Description | +| :-------- | :--- | :-------------------------------------------------------------------------------- | +| Default | 1 | Built-in policies that ship with Gemini CLI. | +| Extension | 2 | Policies defined in extensions. | +| Workspace | 3 | Policies defined in the current workspace's configuration directory. | +| User | 4 | Custom policies defined by the user. | +| Admin | 5 | Policies managed by an administrator (for example, in an enterprise environment). | Within a TOML policy file, you assign a priority value from **0 to 999**. The engine transforms this into a final priority using the following formula: @@ -159,8 +159,8 @@ For example: Approval modes allow the policy engine to apply different sets of rules based on the CLI's operational mode. A rule in a TOML policy file can be associated with -one or more modes (e.g., `yolo`, `autoEdit`, `plan`). The rule will only be -active if the CLI is running in one of its specified modes. If a rule has no +one or more modes (for example, `yolo`, `autoEdit`, `plan`). The rule will only +be active if the CLI is running in one of its specified modes. If a rule has no modes specified, it is always active. - `default`: The standard interactive mode where most write tools require @@ -257,7 +257,7 @@ To prevent privilege escalation, the CLI enforces strict security checks on the directory are **ignored**. - **Linux / macOS:** Must be owned by `root` (UID 0) and NOT writable by group - or others (e.g., `chmod 755`). + or others (for example, `chmod 755`). - **Windows:** Must be in `C:\ProgramData`. Standard users (`Users`, `Everyone`) must NOT have `Write`, `Modify`, or `Full Control` permissions. If you see a security warning, use the folder properties to remove write permissions for @@ -386,7 +386,7 @@ policies, as it is much more robust than manually writing Fully Qualified Names > [!WARNING] -> Do not use underscores (`_`) in your MCP server names (e.g., use +> Do not use underscores (`_`) in your MCP server names (for example, use > `my-server` rather than `my_server`). The policy parser splits Fully Qualified > Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` > prefix. If your server name contains an underscore, the parser will @@ -397,7 +397,8 @@ policies, as it is much more robust than manually writing Fully Qualified Names Combine `mcpName` and `toolName` to target a single operation. When using `mcpName`, the `toolName` field should strictly be the simple name of the tool -(e.g., `search`), **not** the Fully Qualified Name (e.g., `mcp_server_search`). +(for example, `search`), **not** the Fully Qualified Name (for example, +`mcp_server_search`). ```toml # Allows the `search` tool on the `my-jira-server` MCP @@ -467,8 +468,8 @@ deny_message = "Deep codebase analysis is restricted for this session." ## Default policies -The Gemini CLI ships with a set of default policies to provide a safe -out-of-the-box experience. +Gemini CLI ships with a set of default policies to provide a safe out-of-the-box +experience. - **Read-only tools** (like `read_file`, `glob`) are generally **allowed**. - **Agent delegation** defaults to **`ask_user`** to ensure remote agents can diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 91c626fa69..a33742a7a8 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -113,12 +113,24 @@ each tool. | :-------------- | :------ | :----------------------------------------------------------------------------------------------------------------- | | `complete_task` | `Other` | Finalizes a subagent's mission and returns the result to the parent agent. This tool is not available to the user. | +### Task Tracking + +| Tool | Kind | Description | +| :----------------------- | :------ | :-------------------------------------------------------------------------- | +| `tracker_add_dependency` | `Think` | Adds a dependency between two existing tasks in the tracker. | +| `tracker_create_task` | `Think` | Creates a new task in the internal tracker to monitor progress. | +| `tracker_get_task` | `Think` | Retrieves the details and current status of a specific tracked task. | +| `tracker_list_tasks` | `Think` | Lists all tasks currently being tracked. | +| `tracker_update_task` | `Think` | Updates the status or details of an existing task. | +| `tracker_visualize` | `Think` | Generates a visual representation of the current task dependency graph. | +| `update_topic` | `Think` | Updates the current topic and status to keep the user informed of progress. | + ### Web -| Tool | Kind | Description | -| :-------------------------------------------- | :------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information. | -| [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts. In Plan Mode, this tool requires explicit user confirmation. | +| Tool | Kind | Description | +| :-------------------------------------------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information. | +| [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (for example, localhost), which may pose a security risk if used with untrusted prompts. In Plan Mode, this tool requires explicit user confirmation. | ## Under the hood diff --git a/docs/release-confidence.md b/docs/release-confidence.md index 44dca1b2f3..22769f9556 100644 --- a/docs/release-confidence.md +++ b/docs/release-confidence.md @@ -1,7 +1,7 @@ # Release confidence strategy This document outlines the strategy for gaining confidence in every release of -the Gemini CLI. It serves as a checklist and quality gate for release manager to +Gemini CLI. It serves as a checklist and quality gate for release manager to ensure we are shipping a high-quality product. ## The goal diff --git a/docs/releases.md b/docs/releases.md index c6ff1a523a..7969535960 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -45,7 +45,7 @@ promotion flow is: ### Preview These releases will not have been fully vetted and may contain regressions or -other outstanding issues. Please help us test and install with `preview` tag. +other outstanding issues. Help us test and install with `preview` tag. ```bash npm install -g @google/gemini-cli@preview @@ -126,8 +126,8 @@ specific version from any branch, tag, or commit SHA. 2. Select the **Release: Manual** workflow from the list. 3. Click the **Run workflow** dropdown button. 4. Fill in the required inputs: - - **Version**: The exact version to release (e.g., `v0.6.1`). This must be a - valid semantic version with a `v` prefix. + - **Version**: The exact version to release (for example, `v0.6.1`). This + must be a valid semantic version with a `v` prefix. - **Ref**: The branch, tag, or full commit SHA to release from. - **NPM Channel**: The npm channel to publish to. The options are `preview`, `nightly`, `latest` (for stable releases), and `dev`. The default is @@ -165,9 +165,10 @@ require a full release cycle. 3. Click the **Run workflow** dropdown button. 4. Fill in the required inputs: - **Version**: The existing package version that you want to point the tag - to (e.g., `0.5.0-preview-2`). This version **must** already be published - to the npm registry. - - **Channel**: The npm `dist-tag` to apply (e.g., `preview`, `stable`). + to (for example, `0.5.0-preview-2`). This version **must** already be + published to the npm registry. + - **Channel**: The npm `dist-tag` to apply (for example, `preview`, + `stable`). - **Dry Run**: Leave as `true` to log the action without making changes, or set to `false` to perform the live tag change. - **Environment**: Select the appropriate environment. The `dev` environment @@ -227,7 +228,7 @@ workflow. This workflow will automatically: 1. Find the latest release tag for the channel. -2. Create a release branch from that tag if one doesn't exist (e.g., +2. Create a release branch from that tag if one doesn't exist (for example, `release/v0.5.1-pr-12345`). 3. Create a new hotfix branch from the release branch. 4. Cherry-pick your specified commit into the hotfix branch. @@ -282,9 +283,8 @@ created: 6. **Update the PR description**: Consider updating the PR title and description to reflect that it includes multiple fixes. -This approach allows you to group related fixes into a single patch release -while maintaining full control over what gets included and how conflicts are -resolved. +This approach lets you group related fixes into a single patch release while +maintaining full control over what gets included and how conflicts are resolved. #### 3. Automatic release @@ -302,9 +302,9 @@ consistently and reliably. #### Troubleshooting: Older branch workflows **Issue**: If the patch trigger workflow fails with errors like "Resource not -accessible by integration" or references to non-existent workflow files (e.g., -`patch-release.yml`), this indicates the hotfix branch contains an outdated -version of the workflow files. +accessible by integration" or references to non-existent workflow files (for +example, `patch-release.yml`), this indicates the hotfix branch contains an +outdated version of the workflow files. **Root cause**: When a PR is merged, GitHub Actions runs the workflow definition from the **source branch** (the hotfix branch), not from the target branch (the @@ -428,7 +428,7 @@ This command will do the following: You can then inspect the generated tarballs to ensure that they contain the correct files and that the `package.json` files have been updated correctly. The -tarballs will be created in the root of each package's directory (e.g., +tarballs will be created in the root of each package's directory (for example, `packages/cli/google-gemini-cli-0.1.6.tgz`). By performing a dry run, you can be confident that your changes to the packaging @@ -477,9 +477,9 @@ executable that enables `npx` usage directly from the GitHub repository. 1. **The JavaScript bundle is created:** - **What happens:** The built JavaScript from both `packages/core/dist` and `packages/cli/dist`, along with all third-party JavaScript dependencies, - are bundled by `esbuild` into a single, executable JavaScript file (e.g., - `gemini.js`). The `node-pty` library is excluded from this bundle as it - contains native binaries. + are bundled by `esbuild` into a single, executable JavaScript file (for + example, `gemini.js`). The `node-pty` library is excluded from this bundle + as it contains native binaries. - **Why:** This creates a single, optimized file that contains all the necessary application code. It simplifies execution for users who want to run the CLI without a full `npm install`, as all dependencies (including @@ -540,9 +540,9 @@ The list of available labels is not currently populated correctly. If you want to add a label that does not appear alphabetically in the first 30 labels in the repo, you must use your browser's developer tools to manually modify the UI: -1. Open your browser's developer tools (e.g., Chrome DevTools). +1. Open your browser's developer tools (for example, Chrome DevTools). 2. In the `/github-settings` dialog, inspect the list of labels. 3. Locate one of the `
  • ` elements representing a label. 4. In the HTML, modify the `data-option-value` attribute of that `
  • ` element - to the desired label name (e.g., `release-failure`). + to the desired label name (for example, `release-failure`). 5. Click on your modified label in the UI to select it, then save your settings. diff --git a/docs/resources/faq.md b/docs/resources/faq.md index 8d1b42d032..834eda02ce 100644 --- a/docs/resources/faq.md +++ b/docs/resources/faq.md @@ -8,7 +8,7 @@ problems encountered while using Gemini CLI. This section addresses common questions about Gemini CLI usage, security, and troubleshooting general errors. -### Why can't I use third-party software (e.g. Claude Code, OpenClaw, OpenCode) with Gemini CLI? +### Why can't I use third-party software like Claude Code, OpenClaw, or OpenCode with Gemini CLI? Using third-party software, tools, or services to harvest or piggyback on Gemini CLI's OAuth authentication to access our backend services is a direct violation @@ -113,8 +113,8 @@ export GOOGLE_CLOUD_PROJECT="your-project-id" $env:GOOGLE_CLOUD_PROJECT="your-project-id" ``` -To make this setting permanent, add this line to your shell's startup file -(e.g., `~/.bashrc`, `~/.zshrc`). +To make this setting permanent, add this line to your shell's startup file (for +example, `~/.bashrc`, `~/.zshrc`). ### What is the best way to store my API keys securely? @@ -131,9 +131,9 @@ To store your API keys securely, you can: Manager, or a secret manager on Linux). You can then have your scripts or environment load the key from the secure storage at runtime. -### Where are the Gemini CLI configuration and settings files stored? +### Where are Gemini CLI configuration and settings files stored? -The Gemini CLI configuration is stored in two `settings.json` files: +Gemini CLI configuration is stored in two `settings.json` files: 1. In your home directory: `~/.gemini/settings.json`. 2. In your project's root directory: `./.gemini/settings.json`. diff --git a/docs/resources/tos-privacy.md b/docs/resources/tos-privacy.md index 2aaa14cb90..0696613889 100644 --- a/docs/resources/tos-privacy.md +++ b/docs/resources/tos-privacy.md @@ -1,17 +1,17 @@ # Gemini CLI: License, Terms of Service, and Privacy Notices Gemini CLI is an open-source tool that lets you interact with Google's powerful -AI services directly from your command-line interface. The Gemini CLI software -is licensed under the +AI services directly from your command-line interface. Gemini CLI software is +licensed under the [Apache 2.0 license](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE). When you use Gemini CLI to access or use Google’s services, the Terms of Service and Privacy Notices applicable to those services apply to such access and use. -Directly accessing the services powering Gemini CLI (e.g., the Gemini Code -Assist service) using third-party software, tools, or services (for example, -using OpenClaw with Gemini CLI OAuth) is a violation of applicable terms and -policies. Such actions may be grounds for suspension or termination of your -account. +Directly accessing the services powering Gemini CLI (for example, the Gemini +Code Assist service) using third-party software, tools, or services (for +example, using OpenClaw with Gemini CLI OAuth) is a violation of applicable +terms and policies. Such actions may be grounds for suspension or termination of +your account. Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy Policy. @@ -19,7 +19,7 @@ Policy. > [!NOTE] > See [quotas and pricing](quota-and-pricing.md) for the quota and -> pricing details that apply to your usage of the Gemini CLI. +> pricing details that apply to your usage of Gemini CLI. ## Supported authentication methods @@ -37,7 +37,7 @@ If you log in with your Google account and you do not already have a Gemini Code Assist account associated with your Google account, you will be directed to the sign up flow for Gemini Code Assist for individuals. If your Google account is managed by your organization, your administrator may not permit access to Gemini -Code Assist for individuals. Please see the +Code Assist for individuals. See the [Gemini Code Assist for individuals FAQs](https://developers.google.com/gemini-code-assist/resources/faqs) for further information. @@ -76,7 +76,7 @@ If you are using a Gemini API key for authentication with the [Gemini Developer API](https://ai.google.dev/gemini-api/docs), these Terms of Service and Privacy Notice documents apply: -- Terms of Service: Your use of the Gemini CLI is governed by the +- Terms of Service: Your use of Gemini CLI is governed by the [Gemini API Terms of Service](https://ai.google.dev/gemini-api/terms). These terms may differ depending on whether you are using an unpaid or paid service: - For unpaid services, refer to the @@ -92,7 +92,7 @@ If you are using a Gemini API key for authentication with a [Vertex AI GenAI API](https://cloud.google.com/vertex-ai/generative-ai/docs/reference/rest) backend, these Terms of Service and Privacy Notice documents apply: -- Terms of Service: Your use of the Gemini CLI is governed by the +- Terms of Service: Your use of Gemini CLI is governed by the [Google Cloud Platform Service Terms](https://cloud.google.com/terms/service-terms/). - Privacy Notice: The collection and use of your data is described in the [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice). diff --git a/docs/resources/troubleshooting.md b/docs/resources/troubleshooting.md index f490d41ffe..2c63e7c969 100644 --- a/docs/resources/troubleshooting.md +++ b/docs/resources/troubleshooting.md @@ -80,9 +80,9 @@ topics on: directory is in your `PATH`. You can update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`. - If you are running `gemini` from source, ensure you are using the correct - command to invoke it (e.g., `node packages/cli/dist/index.js ...`). To - update Gemini CLI, pull the latest changes from the repository, and then - rebuild using the command `npm run build`. + command to invoke it (for example, `node packages/cli/dist/index.js ...`). + To update Gemini CLI, pull the latest changes from the repository, and + then rebuild using the command `npm run build`. - **Error: `MODULE_NOT_FOUND` or import errors.** - **Cause:** Dependencies are not installed correctly, or the project hasn't @@ -101,18 +101,18 @@ topics on: configuration. - **Gemini CLI is not running in interactive mode in "CI" environments** - - **Issue:** The Gemini CLI does not enter interactive mode (no prompt - appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) - is set. This is because the `is-in-ci` package, used by the underlying UI + - **Issue:** Gemini CLI does not enter interactive mode (no prompt appears) if + an environment variable starting with `CI_` (for example, `CI_TOKEN`) is + set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment. - **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is - non-interactive, which prevents the Gemini CLI from starting in its - interactive mode. + non-interactive, which prevents Gemini CLI from starting in its interactive + mode. - **Solution:** If the `CI_` prefixed variable is not needed for the CLI to - function, you can temporarily unset it for the command. e.g., + function, you can temporarily unset it for the command. For example, `env -u CI_TOKEN gemini` - **DEBUG mode not working from project .env file** @@ -126,7 +126,7 @@ topics on: - **Warning: `npm WARN deprecated node-domexception@1.0.0` or `npm WARN deprecated glob` during install/update** - - **Issue:** When installing or updating the Gemini CLI globally via + - **Issue:** When installing or updating Gemini CLI globally via `npm install -g @google/gemini-cli` or `npm update -g @google/gemini-cli`, you might see deprecation warnings regarding `node-domexception` or old versions of `glob`. @@ -141,14 +141,14 @@ topics on: ## Exit codes -The Gemini CLI uses specific exit codes to indicate the reason for termination. -This is especially useful for scripting and automation. +Gemini CLI uses specific exit codes to indicate the reason for termination. This +is especially useful for scripting and automation. | Exit Code | Error Type | Description | | --------- | -------------------------- | --------------------------------------------------------------------------------------------------- | | 41 | `FatalAuthenticationError` | An error occurred during the authentication process. | | 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) | -| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (e.g., Docker, Podman, or Seatbelt). | +| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (for example, Docker, Podman, or Seatbelt). | | 52 | `FatalConfigError` | A configuration file (`settings.json`) is invalid or contains errors. | | 53 | `FatalTurnLimitedError` | The maximum number of conversational turns for the session was reached. (non-interactive mode only) | @@ -164,8 +164,8 @@ This is especially useful for scripting and automation. - Check the server console output for error messages or stack traces. - Increase log verbosity if configurable. For example, set the `DEBUG_MODE` environment variable to `true` or `1`. - - Use Node.js debugging tools (e.g., `node --inspect`) if you need to step - through server-side code. + - Use Node.js debugging tools (for example, `node --inspect`) if you need to + step through server-side code. - **Tool issues:** - If a specific tool is failing, try to isolate the issue by running the @@ -182,7 +182,7 @@ This is especially useful for scripting and automation. ## Existing GitHub issues similar to yours or creating new issues If you encounter an issue that was not covered here in this _Troubleshooting -guide_, consider searching the Gemini CLI +guide_, consider searching Gemini CLI [Issue tracker on GitHub](https://github.com/google-gemini/gemini-cli/issues). If you can't find an issue similar to yours, consider creating a new GitHub Issue with a detailed description. Pull requests are also welcome! diff --git a/docs/resources/uninstall.md b/docs/resources/uninstall.md index 1f5303e37f..60d8eac9b7 100644 --- a/docs/resources/uninstall.md +++ b/docs/resources/uninstall.md @@ -28,8 +28,9 @@ Remove-Item -Path (Join-Path $env:LocalAppData "npm-cache\_npx") -Recurse -Force ## Method 2: Using npm (global install) -If you installed the CLI globally (e.g., `npm install -g @google/gemini-cli`), -use the `npm uninstall` command with the `-g` flag to remove it. +If you installed the CLI globally (for example, +`npm install -g @google/gemini-cli`), use the `npm uninstall` command with the +`-g` flag to remove it. ```bash npm uninstall -g @google/gemini-cli @@ -39,7 +40,7 @@ This command completely removes the package from your system. ## Method 3: Homebrew -If you installed the CLI globally using Homebrew (e.g., +If you installed the CLI globally using Homebrew (for example, `brew install gemini-cli`), use the `brew uninstall` command to remove it. ```bash @@ -48,7 +49,7 @@ brew uninstall gemini-cli ## Method 4: MacPorts -If you installed the CLI globally using MacPorts (e.g., +If you installed the CLI globally using MacPorts (for example, `sudo port install gemini-cli`), use the `port uninstall` command to remove it. ```bash diff --git a/docs/tools/ask-user.md b/docs/tools/ask-user.md index 14770b4c99..065d2227dc 100644 --- a/docs/tools/ask-user.md +++ b/docs/tools/ask-user.md @@ -15,7 +15,7 @@ confirmation. Each question object has the following properties: - `question` (string, required): The complete question text. - `header` (string, required): A short label (max 16 chars) displayed as a - chip/tag (e.g., "Auth", "Database"). + chip/tag (for example, "Auth", "Database"). - `type` (string, optional): The type of question. Defaults to `'choice'`. - `'choice'`: Multiple-choice with options (supports multi-select). - `'text'`: Free-form text input. @@ -35,7 +35,7 @@ confirmation. - Returns the user's answers to the model. - **Output (`llmContent`):** A JSON string containing the user's answers, - indexed by question position (e.g., + indexed by question position (for example, `{"answers":{"0": "Option A", "1": "Some text"}}`). - **Confirmation:** Yes. The tool inherently involves user interaction. @@ -75,7 +75,7 @@ confirmation. "header": "Project Name", "question": "What is the name of your new project?", "type": "text", - "placeholder": "e.g., my-awesome-app" + "placeholder": "for example, my-awesome-app" } ] } diff --git a/docs/tools/file-system.md b/docs/tools/file-system.md index a6beb1d76d..83c3691dd3 100644 --- a/docs/tools/file-system.md +++ b/docs/tools/file-system.md @@ -1,7 +1,7 @@ # File system tools reference -The Gemini CLI core provides a suite of tools for interacting with the local -file system. These tools allow the model to explore and modify your codebase. +Gemini CLI core provides a suite of tools for interacting with the local file +system. These tools allow the model to explore and modify your codebase. ## Technical reference @@ -49,8 +49,8 @@ Finds files matching specific glob patterns across the workspace. - **Display name:** FindFiles - **File:** `glob.ts` - **Parameters:** - - `pattern` (string, required): The glob pattern to match against (e.g., - `"*.py"`, `"src/**/*.js"`). + - `pattern` (string, required): The glob pattern to match against (for + example, `"*.py"`, `"src/**/*.js"`). - `path` (string, optional): The absolute path to the directory to search within. If omitted, searches the tool's root directory. - `case_sensitive` (boolean, optional): Whether the search should be @@ -78,18 +78,18 @@ lines containing matches, along with their file paths and line numbers. - **File:** `grep.ts` - **Parameters:** - `pattern` (string, required): The regular expression (regex) to search for - (e.g., `"function\s+myFunction"`). + (for example, `"function\s+myFunction"`). - `path` (string, optional): The absolute path to the directory to search within. Defaults to the current working directory. - `include` (string, optional): A glob pattern to filter which files are - searched (e.g., `"*.js"`, `"src/**/*.{ts,tsx}"`). If omitted, searches most - files (respecting common ignores). + searched (for example, `"*.js"`, `"src/**/*.{ts,tsx}"`). If omitted, + searches most files (respecting common ignores). - **Behavior:** - Uses `git grep` if available in a Git repository for speed; otherwise, falls back to system `grep` or a JavaScript-based search. - Returns a list of matching lines, each prefixed with its file path (relative to the search directory) and line number. -- **Output (`llmContent`):** A formatted string of matches, e.g.: +- **Output (`llmContent`):** A formatted string of matches, for example: ``` Found 3 matches for pattern "myFunction" in path "." (filter: "*.ts"): --- diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 3baeb746df..f74ba1de12 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -1,7 +1,7 @@ -# MCP servers with the Gemini CLI +# MCP servers with Gemini CLI This document provides a guide to configuring and using Model Context Protocol -(MCP) servers with the Gemini CLI. +(MCP) servers with Gemini CLI. ## What is an MCP server? @@ -10,7 +10,7 @@ CLI through the Model Context Protocol, allowing it to interact with external systems and data sources. MCP servers act as a bridge between the Gemini model and your local environment or other services like APIs. -An MCP server enables the Gemini CLI to: +An MCP server enables Gemini CLI to: - **Discover tools:** List available tools, their descriptions, and parameters through standardized schema definitions. @@ -19,13 +19,13 @@ An MCP server enables the Gemini CLI to: - **Access resources:** Read data from specific resources that the server exposes (files, API payloads, reports, etc.). -With an MCP server, you can extend the Gemini CLI's capabilities to perform -actions beyond its built-in features, such as interacting with databases, APIs, -custom scripts, or specialized workflows. +With an MCP server, you can extend Gemini CLI's capabilities to perform actions +beyond its built-in features, such as interacting with databases, APIs, custom +scripts, or specialized workflows. ## Core integration architecture -The Gemini CLI integrates with MCP servers through a sophisticated discovery and +Gemini CLI integrates with MCP servers through a sophisticated discovery and execution system built into the core package (`packages/core/src/tools/`): ### Discovery Layer (`mcp-client.ts`) @@ -54,7 +54,7 @@ Each discovered MCP tool is wrapped in a `DiscoveredMCPTool` instance that: ### Transport mechanisms -The Gemini CLI supports three MCP transport types: +Gemini CLI supports three MCP transport types: - **Stdio Transport:** Spawns a subprocess and communicates via stdin/stdout - **SSE Transport:** Connects to Server-Sent Events endpoints @@ -88,9 +88,9 @@ in the conversation. ## How to set up your MCP server -The Gemini CLI uses the `mcpServers` configuration in your `settings.json` file -to locate and connect to MCP servers. This configuration supports multiple -servers with different transport mechanisms. +Gemini CLI uses the `mcpServers` configuration in your `settings.json` file to +locate and connect to MCP servers. This configuration supports multiple servers +with different transport mechanisms. ### Configure the MCP server in settings.json @@ -155,7 +155,8 @@ Each server configuration supports the following properties: #### Required (one of the following) - **`command`** (string): Path to the executable for Stdio transport -- **`url`** (string): SSE endpoint URL (e.g., `"http://localhost:8080/sse"`) +- **`url`** (string): SSE endpoint URL (for example, + `"http://localhost:8080/sse"`) - **`httpUrl`** (string): HTTP streaming endpoint URL #### Optional @@ -188,7 +189,7 @@ Each server configuration supports the following properties: ### Environment variable expansion Gemini CLI automatically expands environment variables in the `env` block of -your MCP server configuration. This allows you to securely reference variables +your MCP server configuration. This lets you securely reference variables defined in your shell or environment without hardcoding sensitive information directly in your `settings.json` file. @@ -241,13 +242,14 @@ specific data with that server. > [!NOTE] > Even when explicitly defined, you should avoid hardcoding secrets. -> Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to -> securely pull the value from your host environment at runtime. +> Instead, use environment variable expansion +> (for example, `"MY_KEY": "$MY_KEY"`) to securely pull the value from your host +> environment at runtime. ### OAuth support for remote MCP servers -The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using -SSE or HTTP transports. This enables secure access to MCP servers that require +Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using SSE or +HTTP transports. This enables secure access to MCP servers that require authentication. #### Automatic OAuth discovery @@ -403,7 +405,7 @@ then be used to authenticate with the MCP server. 5. **Grant all users and groups** who will access the MCP Server the necessary permissions to [impersonate the service account](https://cloud.google.com/docs/authentication/use-service-account-impersonation) - (i.e., `roles/iam.serviceAccountTokenCreator`). + (for example, `roles/iam.serviceAccountTokenCreator`). 6. **[Enable](https://console.cloud.google.com/apis/library/iamcredentials.googleapis.com) the IAM Credentials API** for your project. @@ -532,8 +534,8 @@ then be used to authenticate with the MCP server. ## Discovery process deep dive -When the Gemini CLI starts, it performs MCP server discovery through the -following detailed process: +When Gemini CLI starts, it performs MCP server discovery through the following +detailed process: ### 1. Server iteration and connection @@ -583,7 +585,7 @@ every discovered MCP tool is assigned a strict namespace. > [!WARNING] -> Do not use underscores (`_`) in your MCP server names (e.g., use +> Do not use underscores (`_`) in your MCP server names (for example, use > `my-server` rather than `my_server`). The policy parser splits Fully Qualified > Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` > prefix. If your server name contains an underscore, the parser will @@ -888,7 +890,7 @@ use. MCP tools are not limited to returning simple text. You can return rich, multi-part content, including text, images, audio, and other binary data in a -single tool response. This allows you to build powerful tools that can provide +single tool response. This lets you build powerful tools that can provide diverse information to the model in a single turn. All data returned from the tool is processed and sent to the model as context @@ -901,8 +903,8 @@ To return rich content, your tool's response must adhere to the MCP specification for a [`CallToolResult`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-result). The `content` field of the result should be an array of `ContentBlock` objects. -The Gemini CLI will correctly process this array, separating text from binary -data and packaging it for the model. +Gemini CLI will correctly process this array, separating text from binary data +and packaging it for the model. You can mix and match different content block types in the `content` array. The supported block types include: @@ -938,7 +940,7 @@ text description and an image: } ``` -When the Gemini CLI receives this response, it will: +When Gemini CLI receives this response, it will: 1. Extract all the text and combine it into a single `functionResponse` part for the model. @@ -952,8 +954,8 @@ context to the Gemini model. ## MCP prompts as slash commands In addition to tools, MCP servers can expose predefined prompts that can be -executed as slash commands within the Gemini CLI. This allows you to create -shortcuts for common or complex queries that can be easily invoked by name. +executed as slash commands within Gemini CLI. This lets you create shortcuts for +common or complex queries that can be easily invoked by name. ### Defining prompts on the server @@ -1021,8 +1023,8 @@ or, using positional arguments: /poem-writer "Gemini CLI" reverent ``` -When you run this command, the Gemini CLI executes the `prompts/get` method on -the MCP server with the provided arguments. The server is responsible for +When you run this command, Gemini CLI executes the `prompts/get` method on the +MCP server with the provided arguments. The server is responsible for substituting the arguments into the prompt template and returning the final prompt text. The CLI then sends this prompt to the model for execution. This provides a convenient way to automate and share common workflows. @@ -1030,10 +1032,10 @@ provides a convenient way to automate and share common workflows. ## Managing MCP servers with `gemini mcp` While you can always configure MCP servers by manually editing your -`settings.json` file, the Gemini CLI provides a convenient set of commands to -manage your server configurations programmatically. These commands streamline -the process of adding, listing, and removing MCP servers without needing to -directly edit JSON files. +`settings.json` file, Gemini CLI provides a convenient set of commands to manage +your server configurations programmatically. These commands streamline the +process of adding, listing, and removing MCP servers without needing to directly +edit JSON files. ### Adding a server (`gemini mcp add`) @@ -1056,9 +1058,9 @@ gemini mcp add [options] [args...] - `-s, --scope`: Configuration scope (user or project). [default: "project"] - `-t, --transport`: Transport type (stdio, sse, http). [default: "stdio"] -- `-e, --env`: Set environment variables (e.g. -e KEY=value). -- `-H, --header`: Set HTTP headers for SSE and HTTP transports (e.g. -H - "X-Api-Key: abc123" -H "Authorization: Bearer abc123"). +- `-e, --env`: Set environment variables (for example, `-e KEY=value`). +- `-H, --header`: Set HTTP headers for SSE and HTTP transports (for example, + `-H "X-Api-Key: abc123" -H "Authorization: Bearer abc123"`). - `--timeout`: Set connection timeout in milliseconds. - `--trust`: Trust the server (bypass all tool call confirmation prompts). - `--description`: Set the description for the server. diff --git a/docs/tools/shell.md b/docs/tools/shell.md index 26f0769e98..84bb76e393 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -32,7 +32,7 @@ The tool returns a JSON object containing: ## Configuration You can configure the behavior of the `run_shell_command` tool by modifying your -`settings.json` file or by using the `/settings` command in the Gemini CLI. +`settings.json` file or by using the `/settings` command in Gemini CLI. ### Enabling interactive commands @@ -93,9 +93,9 @@ applies when `tools.shell.enableInteractiveShell` is enabled. ## Interactive commands The `run_shell_command` tool now supports interactive commands by integrating a -pseudo-terminal (pty). This allows you to run commands that require real-time -user input, such as text editors (`vim`, `nano`), terminal-based UIs (`htop`), -and interactive version control operations (`git rebase -i`). +pseudo-terminal (pty). This lets you run commands that require real-time user +input, such as text editors (`vim`, `nano`), terminal-based UIs (`htop`), and +interactive version control operations (`git rebase -i`). When an interactive command is running, you can send input to it from the Gemini CLI. To focus on the interactive shell, press `Tab`. The terminal output, @@ -116,7 +116,7 @@ including complex TUIs, will be rendered correctly. When `run_shell_command` executes a command, it sets the `GEMINI_CLI=1` environment variable in the subprocess's environment. This allows scripts or -tools to detect if they are being run from within the Gemini CLI. +tools to detect if they are being run from within Gemini CLI. ## Command restrictions diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 39f073e8e0..9f8d9d1d8b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -439,6 +439,16 @@ const SETTINGS_SCHEMA = { description: 'User interface settings.', showInDialog: false, properties: { + debugRainbow: { + type: 'boolean', + label: 'Debug Rainbow', + category: 'UI', + requiresRestart: true, + default: false, + description: + 'Enable debug rainbow rendering. Only useful for debugging rendering bugs and performance issues.', + showInDialog: false, + }, theme: { type: 'string', label: 'Theme', diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx index 4b307fb9d3..0d73f95016 100644 --- a/packages/cli/src/interactiveCli.tsx +++ b/packages/cli/src/interactiveCli.tsx @@ -163,6 +163,7 @@ export async function startInteractiveUI( settings.merged.ui.incrementalRendering !== false && useAlternateBuffer && !isShpool, + debugRainbow: settings.merged.ui.debugRainbow === true, }, ); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index efdc7223ea..0bba196e1d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -89,6 +89,7 @@ import { buildUserSteeringHintPrompt, logBillingEvent, ApiKeyUpdatedEvent, + LegacyAgentProtocol, type InjectionSource, startMemoryService, } from '@google/gemini-cli-core'; @@ -118,6 +119,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; +import { useAgentStream } from './hooks/useAgentStream.js'; import { type BackgroundTask } from './hooks/useExecutionLifecycle.js'; import { useVim } from './hooks/vim.js'; import { type LoadableSettingScope, SettingScope } from '../config/settings.js'; @@ -1161,6 +1163,46 @@ Logging in with Google... Restarting Gemini CLI to continue. }; }, [config]); + const streamAgent = useMemo( + () => + config?.getAgentSessionInteractiveEnabled() + ? new LegacyAgentProtocol({ config, getPreferredEditor }) + : undefined, + [config, getPreferredEditor], + ); + + const activeStream = streamAgent + ? // eslint-disable-next-line react-hooks/rules-of-hooks + useAgentStream({ + agent: streamAgent, + addItem: historyManager.addItem, + onCancelSubmit, + isShellFocused: embeddedShellFocused, + logger, + }) + : // eslint-disable-next-line react-hooks/rules-of-hooks + useGeminiStream( + config.getGeminiClient(), + historyManager.history, + historyManager.addItem, + config, + settings, + setDebugMessage, + handleSlashCommand, + shellModeActive, + getPreferredEditor, + onAuthError, + performMemoryRefresh, + modelSwitchedFromQuotaError, + setModelSwitchedFromQuotaError, + onCancelSubmit, + setEmbeddedShellFocused, + terminalWidth, + terminalHeight, + embeddedShellFocused, + consumePendingHints, + ); + const { streamingState, submitQuery, @@ -1180,27 +1222,7 @@ Logging in with Google... Restarting Gemini CLI to continue. backgroundTasks, dismissBackgroundTask, retryStatus, - } = useGeminiStream( - config.getGeminiClient(), - historyManager.history, - historyManager.addItem, - config, - settings, - setDebugMessage, - handleSlashCommand, - shellModeActive, - getPreferredEditor, - onAuthError, - performMemoryRefresh, - modelSwitchedFromQuotaError, - setModelSwitchedFromQuotaError, - onCancelSubmit, - setEmbeddedShellFocused, - terminalWidth, - terminalHeight, - embeddedShellFocused, - consumePendingHints, - ); + } = activeStream; const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], @@ -1783,7 +1805,7 @@ Logging in with Google... Restarting Gemini CLI to continue. if (keyMatchers[Command.QUIT](key)) { // If the user presses Ctrl+C, we want to cancel any ongoing requests. // This should happen regardless of the count. - cancelOngoingRequest?.(); + void cancelOngoingRequest?.(); handleCtrlCPress(); return true; diff --git a/packages/cli/src/ui/hooks/useAgentStream.test.tsx b/packages/cli/src/ui/hooks/useAgentStream.test.tsx new file mode 100644 index 0000000000..53bb512504 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAgentStream.test.tsx @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import type { LegacyAgentProtocol } from '@google/gemini-cli-core'; +import { renderHookWithProviders } from '../../test-utils/render.js'; + +// --- MOCKS --- + +const mockLegacyAgentProtocol = vi.hoisted(() => ({ + send: vi.fn().mockResolvedValue({ streamId: 'test-stream-id' }), + subscribe: vi.fn().mockReturnValue(() => {}), + abort: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../contexts/SessionContext.js', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useSessionStats: vi.fn(() => ({ + startNewPrompt: vi.fn(), + })), + }; +}); + +// --- END MOCKS --- + +import { useAgentStream } from './useAgentStream.js'; +import { MessageType, StreamingState } from '../types.js'; + +describe('useAgentStream', () => { + const mockAddItem = vi.fn(); + const mockOnCancelSubmit = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize on mount', async () => { + await renderHookWithProviders(() => + useAgentStream({ + agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol, + addItem: mockAddItem, + onCancelSubmit: mockOnCancelSubmit, + isShellFocused: false, + }), + ); + + expect(mockLegacyAgentProtocol.subscribe).toHaveBeenCalled(); + }); + + it('should call agent.send when submitQuery is called', async () => { + const { result } = await renderHookWithProviders(() => + useAgentStream({ + agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol, + addItem: mockAddItem, + onCancelSubmit: mockOnCancelSubmit, + isShellFocused: false, + }), + ); + + await act(async () => { + await result.current.submitQuery('hello'); + }); + + expect(mockLegacyAgentProtocol.send).toHaveBeenCalledWith({ + message: { content: [{ type: 'text', text: 'hello' }] }, + }); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ type: MessageType.USER, text: 'hello' }), + expect.any(Number), + ); + }); + + it('should update streamingState based on agent_start and agent_end events', async () => { + const { result } = await renderHookWithProviders(() => + useAgentStream({ + agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol, + addItem: mockAddItem, + onCancelSubmit: mockOnCancelSubmit, + isShellFocused: false, + }), + ); + + const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock + .calls[0][0]; + + expect(result.current.streamingState).toBe(StreamingState.Idle); + + act(() => { + eventHandler({ + type: 'agent_start', + id: '1', + timestamp: '', + streamId: '', + }); + }); + expect(result.current.streamingState).toBe(StreamingState.Responding); + + act(() => { + eventHandler({ + type: 'agent_end', + reason: 'completed', + id: '2', + timestamp: '', + streamId: '', + }); + }); + expect(result.current.streamingState).toBe(StreamingState.Idle); + }); + + it('should accumulate text content and update pendingHistoryItems', async () => { + const { result } = await renderHookWithProviders(() => + useAgentStream({ + agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol, + addItem: mockAddItem, + onCancelSubmit: mockOnCancelSubmit, + isShellFocused: false, + }), + ); + + const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock + .calls[0][0]; + + act(() => { + eventHandler({ + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'Hello' }], + id: '1', + timestamp: '', + streamId: '', + }); + }); + + expect(result.current.pendingHistoryItems).toHaveLength(1); + expect(result.current.pendingHistoryItems[0]).toMatchObject({ + type: 'gemini', + text: 'Hello', + }); + + act(() => { + eventHandler({ + type: 'message', + role: 'agent', + content: [{ type: 'text', text: ' world' }], + id: '2', + timestamp: '', + streamId: '', + }); + }); + + expect(result.current.pendingHistoryItems[0].text).toBe('Hello world'); + }); + + it('should process thought events and update thought state', async () => { + const { result } = await renderHookWithProviders(() => + useAgentStream({ + agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol, + addItem: mockAddItem, + onCancelSubmit: mockOnCancelSubmit, + isShellFocused: false, + }), + ); + + const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock + .calls[0][0]; + + act(() => { + eventHandler({ + type: 'message', + role: 'agent', + content: [{ type: 'thought', thought: '**Thinking** about tests' }], + id: '1', + timestamp: '', + streamId: '', + }); + }); + + expect(result.current.thought).toEqual({ + subject: 'Thinking', + description: 'about tests', + }); + }); + + it('should call agent.abort when cancelOngoingRequest is called', async () => { + const { result } = await renderHookWithProviders(() => + useAgentStream({ + agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol, + addItem: mockAddItem, + onCancelSubmit: mockOnCancelSubmit, + isShellFocused: false, + }), + ); + + await act(async () => { + await result.current.cancelOngoingRequest(); + }); + + expect(mockLegacyAgentProtocol.abort).toHaveBeenCalled(); + expect(mockOnCancelSubmit).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/cli/src/ui/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts new file mode 100644 index 0000000000..81dbb1e9e9 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAgentStream.ts @@ -0,0 +1,528 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; +import { + getErrorMessage, + MessageSenderType, + debugLogger, + geminiPartsToContentParts, + parseThought, + CoreToolCallStatus, + type ApprovalMode, + Kind, + type ThoughtSummary, + type RetryAttemptPayload, + type AgentEvent, + type AgentProtocol, + type Logger, + type Part, +} from '@google/gemini-cli-core'; +import type { + HistoryItemWithoutId, + LoopDetectionConfirmationRequest, + IndividualToolCallDisplay, + HistoryItemToolGroup, +} from '../types.js'; +import { StreamingState, MessageType } from '../types.js'; +import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; +import { getToolGroupBorderAppearance } from '../utils/borderStyles.js'; +import { type BackgroundTask } from './useExecutionLifecycle.js'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import { useSessionStats } from '../contexts/SessionContext.js'; +import { useStateAndRef } from './useStateAndRef.js'; +import { type MinimalTrackedToolCall } from './useTurnActivityMonitor.js'; + +export interface UseAgentStreamOptions { + agent?: AgentProtocol; + addItem: UseHistoryManagerReturn['addItem']; + onCancelSubmit: (shouldRestorePrompt?: boolean) => void; + isShellFocused?: boolean; + logger?: Logger | null; +} + +/** + * useAgentStream implements the interactive agent loop using an AgentProtocol. + * It is completely agnostic to the specific agent implementation. + */ +export const useAgentStream = ({ + agent, + addItem, + onCancelSubmit, + isShellFocused, + logger, +}: UseAgentStreamOptions) => { + const [initError] = useState(null); + const [retryStatus] = useState(null); + const [streamingState, setStreamingState] = useState( + StreamingState.Idle, + ); + const [thought, setThought] = useState(null); + const [lastOutputTime, setLastOutputTime] = useState(Date.now()); + + const currentStreamIdRef = useRef(null); + const userMessageTimestampRef = useRef(0); + const geminiMessageBufferRef = useRef(''); + const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] = + useStateAndRef(null); + + const [trackedTools, , setTrackedTools] = useStateAndRef< + IndividualToolCallDisplay[] + >([]); + const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] = + useStateAndRef>(new Set()); + const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] = + useStateAndRef(true); + + const { startNewPrompt } = useSessionStats(); + + // TODO: Implement dynamic shell-related state derivation from trackedTools or dedicated refs. + // This includes activePtyId, backgroundTasks, and related visibility states to restore + // parity with legacy terminal focus detection and background task tracking. + // Note: Avoid checking ITERM_SESSION_ID for terminal detection and ensure context is sanitized. + const activePtyId = undefined; + const backgroundTaskCount = 0; + const isBackgroundTaskVisible = false; + const toggleBackgroundTasks = useCallback(() => {}, []); + const backgroundCurrentExecution = undefined; + const backgroundTasks = useMemo(() => new Map(), []); + const dismissBackgroundTask = useCallback(async (_pid: number) => {}, []); + + // Use the trackedTools to mock pendingToolCalls for inactivity monitors + const pendingToolCalls = useMemo( + (): MinimalTrackedToolCall[] => + trackedTools.map((t) => ({ + request: { + name: t.originalRequestName || t.name, + args: { command: t.description }, + callId: t.callId, + isClientInitiated: t.isClientInitiated ?? false, + prompt_id: '', + }, + status: t.status, + })), + [trackedTools], + ); + + // TODO: Support LoopDetection confirmation requests + const [loopDetectionConfirmationRequest] = + useState(null); + + const flushPendingText = useCallback(() => { + if (pendingHistoryItemRef.current) { + addItem(pendingHistoryItemRef.current, userMessageTimestampRef.current); + setPendingHistoryItem(null); + geminiMessageBufferRef.current = ''; + } + }, [addItem, pendingHistoryItemRef, setPendingHistoryItem]); + + const cancelOngoingRequest = useCallback(async () => { + if (agent) { + await agent.abort(); + setStreamingState(StreamingState.Idle); + onCancelSubmit(false); + } + }, [agent, onCancelSubmit]); + + // TODO: Support native handleApprovalModeChange for Plan Mode + const handleApprovalModeChange = useCallback( + async (newApprovalMode: ApprovalMode) => { + debugLogger.debug(`Approval mode changed to ${newApprovalMode} (stub)`); + }, + [], + ); + + const handleEvent = useCallback( + (event: AgentEvent) => { + setLastOutputTime(Date.now()); + switch (event.type) { + case 'agent_start': + setStreamingState(StreamingState.Responding); + break; + case 'agent_end': + setStreamingState(StreamingState.Idle); + flushPendingText(); + break; + case 'message': + if (event.role === 'agent') { + for (const part of event.content) { + if (part.type === 'text') { + geminiMessageBufferRef.current += part.text; + // Update pending history item with incremental text + const splitPoint = findLastSafeSplitPoint( + geminiMessageBufferRef.current, + ); + if (splitPoint === geminiMessageBufferRef.current.length) { + setPendingHistoryItem({ + type: 'gemini', + text: geminiMessageBufferRef.current, + }); + } else { + const before = geminiMessageBufferRef.current.substring( + 0, + splitPoint, + ); + const after = + geminiMessageBufferRef.current.substring(splitPoint); + addItem( + { type: 'gemini', text: before }, + userMessageTimestampRef.current, + ); + geminiMessageBufferRef.current = after; + setPendingHistoryItem({ + type: 'gemini_content', + text: after, + }); + } + } else if (part.type === 'thought') { + setThought(parseThought(part.thought)); + } + } + } + break; + case 'tool_request': { + flushPendingText(); + const legacyState = event._meta?.legacyState; + const displayName = legacyState?.displayName ?? event.name; + const isOutputMarkdown = legacyState?.isOutputMarkdown ?? false; + const desc = legacyState?.description ?? ''; + + const fallbackKind = Kind.Other; + + const newCall: IndividualToolCallDisplay = { + callId: event.requestId, + name: displayName, + originalRequestName: event.name, + description: desc, + status: CoreToolCallStatus.Scheduled, + isClientInitiated: false, + renderOutputAsMarkdown: isOutputMarkdown, + kind: legacyState?.kind ?? fallbackKind, + confirmationDetails: undefined, + resultDisplay: undefined, + }; + setTrackedTools((prev) => [...prev, newCall]); + break; + } + case 'tool_update': { + setTrackedTools((prev) => + prev.map((tc): IndividualToolCallDisplay => { + if (tc.callId !== event.requestId) return tc; + + const legacyState = event._meta?.legacyState; + const evtStatus = legacyState?.status; + + let status = tc.status; + if (evtStatus === 'executing') + status = CoreToolCallStatus.Executing; + else if (evtStatus === 'error') status = CoreToolCallStatus.Error; + else if (evtStatus === 'success') + status = CoreToolCallStatus.Success; + + const liveOutput = + event.displayContent?.[0]?.type === 'text' + ? event.displayContent[0].text + : tc.resultDisplay; + const progressMessage = + legacyState?.progressMessage ?? tc.progressMessage; + const progress = legacyState?.progress ?? tc.progress; + const progressTotal = + legacyState?.progressTotal ?? tc.progressTotal; + const ptyId = legacyState?.pid ?? tc.ptyId; + const description = legacyState?.description ?? tc.description; + + return { + ...tc, + status, + resultDisplay: liveOutput, + progressMessage, + progress, + progressTotal, + ptyId, + description, + }; + }), + ); + break; + } + case 'tool_response': { + setTrackedTools((prev) => + prev.map((tc): IndividualToolCallDisplay => { + if (tc.callId !== event.requestId) return tc; + + const legacyState = event._meta?.legacyState; + const outputFile = legacyState?.outputFile; + const resultDisplay = + event.displayContent?.[0]?.type === 'text' + ? event.displayContent[0].text + : tc.resultDisplay; + + return { + ...tc, + status: event.isError + ? CoreToolCallStatus.Error + : CoreToolCallStatus.Success, + resultDisplay, + outputFile, + }; + }), + ); + break; + } + + case 'error': + addItem( + { type: MessageType.ERROR, text: event.message }, + userMessageTimestampRef.current, + ); + break; + + case 'initialize': + case 'session_update': + case 'elicitation_request': + case 'elicitation_response': + case 'usage': + case 'custom': + // These events are currently not handled in the UI + break; + + default: + debugLogger.error('Unknown agent event type:', event); + event satisfies never; + break; + } + }, + [ + addItem, + flushPendingText, + setPendingHistoryItem, + setTrackedTools, + setStreamingState, + setThought, + setLastOutputTime, + ], + ); + + useEffect(() => { + const unsubscribe = agent?.subscribe(handleEvent); + return () => unsubscribe?.(); + }, [agent, handleEvent]); + + const submitQuery = useCallback( + async ( + query: Part[] | string, + options?: { isContinuation: boolean }, + _prompt_id?: string, + ) => { + if (!agent) return; + + const timestamp = Date.now(); + setLastOutputTime(timestamp); + userMessageTimestampRef.current = timestamp; + + geminiMessageBufferRef.current = ''; + + if (!options?.isContinuation) { + if (typeof query === 'string') { + addItem({ type: MessageType.USER, text: query }, timestamp); + void logger?.logMessage(MessageSenderType.USER, query); + } + startNewPrompt(); + } + + const parts = geminiPartsToContentParts( + typeof query === 'string' ? [{ text: query }] : query, + ); + + try { + const { streamId } = await agent.send({ + message: { content: parts }, + }); + currentStreamIdRef.current = streamId; + } catch (err) { + addItem( + { type: MessageType.ERROR, text: getErrorMessage(err) }, + timestamp, + ); + } + }, + [agent, addItem, logger, startNewPrompt], + ); + + useEffect(() => { + if (trackedTools.length > 0) { + const isNewBatch = !trackedTools.some((tc) => + pushedToolCallIdsRef.current.has(tc.callId), + ); + if (isNewBatch) { + setPushedToolCallIds(new Set()); + setIsFirstToolInGroup(true); + } + } else if (streamingState === StreamingState.Idle) { + setPushedToolCallIds(new Set()); + setIsFirstToolInGroup(true); + } + }, [ + trackedTools, + pushedToolCallIdsRef, + setPushedToolCallIds, + setIsFirstToolInGroup, + streamingState, + ]); + + // Push completed tools to history + useEffect(() => { + const toolsToPush: IndividualToolCallDisplay[] = []; + for (let i = 0; i < trackedTools.length; i++) { + const tc = trackedTools[i]; + if (pushedToolCallIdsRef.current.has(tc.callId)) continue; + + if ( + tc.status === 'success' || + tc.status === 'error' || + tc.status === 'cancelled' + ) { + toolsToPush.push(tc); + } else { + break; + } + } + + if (toolsToPush.length > 0) { + const newPushed = new Set(pushedToolCallIdsRef.current); + for (const tc of toolsToPush) { + newPushed.add(tc.callId); + } + + const isLastInBatch = + toolsToPush[toolsToPush.length - 1] === + trackedTools[trackedTools.length - 1]; + + const appearance = getToolGroupBorderAppearance( + { type: 'tool_group', tools: trackedTools }, + activePtyId, + !!isShellFocused, + [], + backgroundTasks, + ); + + const historyItem: HistoryItemToolGroup = { + type: 'tool_group', + tools: toolsToPush, + borderTop: isFirstToolInGroupRef.current, + borderBottom: isLastInBatch, + ...appearance, + }; + + addItem(historyItem); + setPushedToolCallIds(newPushed); + setIsFirstToolInGroup(false); + } + }, [ + trackedTools, + pushedToolCallIdsRef, + isFirstToolInGroupRef, + setPushedToolCallIds, + setIsFirstToolInGroup, + addItem, + activePtyId, + isShellFocused, + backgroundTasks, + ]); + + const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => { + const remainingTools = trackedTools.filter( + (tc) => !pushedToolCallIds.has(tc.callId), + ); + + const items: HistoryItemWithoutId[] = []; + + const appearance = getToolGroupBorderAppearance( + { type: 'tool_group', tools: trackedTools }, + activePtyId, + !!isShellFocused, + [], + backgroundTasks, + ); + + if (remainingTools.length > 0) { + items.push({ + type: 'tool_group', + tools: remainingTools, + borderTop: pushedToolCallIds.size === 0, + borderBottom: false, + ...appearance, + }); + } + + const allTerminal = + trackedTools.length > 0 && + trackedTools.every( + (tc) => + tc.status === 'success' || + tc.status === 'error' || + tc.status === 'cancelled', + ); + + const allPushed = + trackedTools.length > 0 && + trackedTools.every((tc) => pushedToolCallIds.has(tc.callId)); + + const anyVisibleInHistory = pushedToolCallIds.size > 0; + const anyVisibleInPending = remainingTools.length > 0; + + if ( + trackedTools.length > 0 && + !(allTerminal && allPushed) && + (anyVisibleInHistory || anyVisibleInPending) + ) { + items.push({ + type: 'tool_group' as const, + tools: [], + borderTop: false, + borderBottom: true, + ...appearance, + }); + } + + return items; + }, [ + trackedTools, + pushedToolCallIds, + activePtyId, + isShellFocused, + backgroundTasks, + ]); + + const pendingHistoryItems = useMemo( + () => + [pendingHistoryItem, ...pendingToolGroupItems].filter( + (i): i is HistoryItemWithoutId => i !== undefined && i !== null, + ), + [pendingHistoryItem, pendingToolGroupItems], + ); + + return { + streamingState, + submitQuery, + initError, + pendingHistoryItems, + thought, + cancelOngoingRequest, + pendingToolCalls, + handleApprovalModeChange, + activePtyId, + loopDetectionConfirmationRequest, + lastOutputTime, + backgroundTaskCount, + isBackgroundTaskVisible, + toggleBackgroundTasks, + backgroundCurrentExecution, + backgroundTasks, + retryStatus, + dismissBackgroundTask, + }; +}; diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts index 6ef39b7a5d..cb4e3bd17d 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts @@ -11,7 +11,6 @@ import { useSessionBrowser, convertSessionToHistoryFormats, } from './useSessionBrowser.js'; -import * as fs from 'node:fs/promises'; import path from 'node:path'; import { getSessionFiles, type SessionInfo } from '../../utils/sessionUtils.js'; import { @@ -19,6 +18,7 @@ import { type ConversationRecord, type MessageRecord, CoreToolCallStatus, + loadConversationRecord, } from '@google/gemini-cli-core'; import { coreEvents, @@ -46,6 +46,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { clear: vi.fn(), hydrate: vi.fn(), }, + loadConversationRecord: vi.fn(), }; }); @@ -55,7 +56,6 @@ const MOCKED_SESSION_ID = 'test-session-123'; const MOCKED_CURRENT_SESSION_ID = 'current-session-id'; describe('useSessionBrowser', () => { - const mockedFs = vi.mocked(fs); const mockedPath = vi.mocked(path); const mockedGetSessionFiles = vi.mocked(getSessionFiles); @@ -98,7 +98,7 @@ describe('useSessionBrowser', () => { fileName: MOCKED_FILENAME, } as SessionInfo; mockedGetSessionFiles.mockResolvedValue([mockSession]); - mockedFs.readFile.mockResolvedValue(JSON.stringify(mockConversation)); + vi.mocked(loadConversationRecord).mockResolvedValue(mockConversation); const { result } = await renderHook(() => useSessionBrowser(mockConfig, mockOnLoadHistory), @@ -107,9 +107,8 @@ describe('useSessionBrowser', () => { await act(async () => { await result.current.handleResumeSession(mockSession); }); - expect(mockedFs.readFile).toHaveBeenCalledWith( + expect(loadConversationRecord).toHaveBeenCalledWith( `${MOCKED_CHATS_DIR}/${MOCKED_FILENAME}`, - 'utf8', ); expect(mockConfig.setSessionId).toHaveBeenCalledWith( 'existing-session-456', @@ -125,7 +124,9 @@ describe('useSessionBrowser', () => { id: MOCKED_SESSION_ID, fileName: MOCKED_FILENAME, } as SessionInfo; - mockedFs.readFile.mockRejectedValue(new Error('File not found')); + vi.mocked(loadConversationRecord).mockRejectedValue( + new Error('File not found'), + ); const { result } = await renderHook(() => useSessionBrowser(mockConfig, mockOnLoadHistory), @@ -149,7 +150,7 @@ describe('useSessionBrowser', () => { id: MOCKED_SESSION_ID, fileName: MOCKED_FILENAME, } as SessionInfo; - mockedFs.readFile.mockResolvedValue('invalid json'); + vi.mocked(loadConversationRecord).mockResolvedValue(null); const { result } = await renderHook(() => useSessionBrowser(mockConfig, mockOnLoadHistory), diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 4e86c2d92e..b42e1c5a72 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -6,14 +6,13 @@ import { useState, useCallback } from 'react'; import type { HistoryItemWithoutId } from '../types.js'; -import * as fs from 'node:fs/promises'; import path from 'node:path'; import { coreEvents, convertSessionToClientHistory, uiTelemetryService, + loadConversationRecord, type Config, - type ConversationRecord, type ResumedSessionData, } from '@google/gemini-cli-core'; import { @@ -61,10 +60,12 @@ export const useSessionBrowser = ( const originalFilePath = path.join(chatsDir, fileName); // Load up the conversation. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const conversation: ConversationRecord = JSON.parse( - await fs.readFile(originalFilePath, 'utf8'), - ); + const conversation = await loadConversationRecord(originalFilePath); + if (!conversation) { + throw new Error( + `Failed to parse conversation from ${originalFilePath}`, + ); + } // Use the old session's ID to continue it. const existingSessionId = conversation.sessionId; diff --git a/packages/cli/src/ui/hooks/useShellInactivityStatus.ts b/packages/cli/src/ui/hooks/useShellInactivityStatus.ts index 092e58baae..a1a9175904 100644 --- a/packages/cli/src/ui/hooks/useShellInactivityStatus.ts +++ b/packages/cli/src/ui/hooks/useShellInactivityStatus.ts @@ -5,20 +5,22 @@ */ import { useInactivityTimer } from './useInactivityTimer.js'; -import { useTurnActivityMonitor } from './useTurnActivityMonitor.js'; +import { + useTurnActivityMonitor, + type MinimalTrackedToolCall, +} from './useTurnActivityMonitor.js'; import { SHELL_FOCUS_HINT_DELAY_MS, SHELL_ACTION_REQUIRED_TITLE_DELAY_MS, SHELL_SILENT_WORKING_TITLE_DELAY_MS, } from '../constants.js'; import type { StreamingState } from '../types.js'; -import { type TrackedToolCall } from './useToolScheduler.js'; interface ShellInactivityStatusProps { activePtyId: number | string | null | undefined; lastOutputTime: number; streamingState: StreamingState; - pendingToolCalls: TrackedToolCall[]; + pendingToolCalls: MinimalTrackedToolCall[]; embeddedShellFocused: boolean; isInteractiveShellEnabled: boolean; } diff --git a/packages/cli/src/ui/hooks/useToolScheduler.ts b/packages/cli/src/ui/hooks/useToolScheduler.ts index 3b457c4479..c379529ba5 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.ts @@ -79,6 +79,7 @@ export function useToolScheduler( React.Dispatch>, CancelAllFn, number, + Scheduler, ] { // State stores tool calls organized by their originating schedulerId const [toolCallsMap, setToolCallsMap] = useState< @@ -319,6 +320,7 @@ export function useToolScheduler( setToolCallsForDisplay, cancelAll, lastToolOutputTime, + scheduler, ]; } diff --git a/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts b/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts index 8cd7883007..b7297889f3 100644 --- a/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts +++ b/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts @@ -6,8 +6,16 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { StreamingState } from '../types.js'; -import { hasRedirection } from '@google/gemini-cli-core'; -import { type TrackedToolCall } from './useToolScheduler.js'; +import { + hasRedirection, + type CoreToolCallStatus, + type ToolCallRequestInfo, +} from '@google/gemini-cli-core'; + +export interface MinimalTrackedToolCall { + status: CoreToolCallStatus; + request: ToolCallRequestInfo; +} export interface TurnActivityStatus { operationStartTime: number; @@ -21,7 +29,7 @@ export interface TurnActivityStatus { export const useTurnActivityMonitor = ( streamingState: StreamingState, activePtyId: number | string | null | undefined, - pendingToolCalls: TrackedToolCall[] = [], + pendingToolCalls: MinimalTrackedToolCall[] = [], ): TurnActivityStatus => { const [operationStartTime, setOperationStartTime] = useState(0); diff --git a/packages/cli/src/ui/utils/borderStyles.ts b/packages/cli/src/ui/utils/borderStyles.ts index 7b7dba5fc5..fb9ef11fec 100644 --- a/packages/cli/src/ui/utils/borderStyles.ts +++ b/packages/cli/src/ui/utils/borderStyles.ts @@ -29,7 +29,10 @@ export function getToolGroupBorderAppearance( item: | HistoryItem | HistoryItemWithoutId - | { type: 'tool_group'; tools: TrackedToolCall[] }, + | { + type: 'tool_group'; + tools: Array; + }, activeShellPtyId: number | null | undefined, embeddedShellFocused: boolean | undefined, allPendingItems: HistoryItemWithoutId[] = [], @@ -41,7 +44,7 @@ export function getToolGroupBorderAppearance( // If this item has no tools, it's a closing slice for the current batch. // We need to look at the last pending item to determine the batch's appearance. - const toolsToInspect: Array = + const toolsToInspect = item.tools.length > 0 ? item.tools : allPendingItems diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 6f72b20381..647ed77727 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -12,6 +12,7 @@ import { type Storage, type ConversationRecord, type MessageRecord, + loadConversationRecord, } from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import path from 'node:path'; @@ -250,23 +251,27 @@ export const getAllSessionFiles = async ( try { const files = await fs.readdir(chatsDir); const sessionFiles = files - .filter((f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json')) + .filter( + (f) => + f.startsWith(SESSION_FILE_PREFIX) && + (f.endsWith('.json') || f.endsWith('.jsonl')), + ) .sort(); // Sort by filename, which includes timestamp const sessionPromises = sessionFiles.map( async (file): Promise => { const filePath = path.join(chatsDir, file); try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const content: ConversationRecord = JSON.parse( - await fs.readFile(filePath, 'utf8'), - ); + const content = await loadConversationRecord(filePath, { + metadataOnly: !options.includeFullContent, + }); + if (!content) { + return { fileName: file, sessionInfo: null }; + } // Validate required fields if ( !content.sessionId || - !content.messages || - !Array.isArray(content.messages) || !content.startTime || !content.lastUpdated ) { @@ -275,7 +280,7 @@ export const getAllSessionFiles = async ( } // Skip sessions that only contain system messages (info, error, warning) - if (!hasUserOrAssistantMessage(content.messages)) { + if (!content.hasUserOrAssistantMessage) { return { fileName: file, sessionInfo: null }; } @@ -285,7 +290,9 @@ export const getAllSessionFiles = async ( return { fileName: file, sessionInfo: null }; } - const firstUserMessage = extractFirstUserMessage(content.messages); + const firstUserMessage = content.firstUserMessage + ? cleanMessage(content.firstUserMessage) + : extractFirstUserMessage(content.messages); const isCurrentSession = currentSessionId ? file.includes(currentSessionId.slice(0, 8)) : false; @@ -310,11 +317,11 @@ export const getAllSessionFiles = async ( const sessionInfo: SessionInfo = { id: content.sessionId, - file: file.replace('.json', ''), + file: file.replace(/\.jsonl?$/, ''), fileName: file, startTime: content.startTime, lastUpdated: content.lastUpdated, - messageCount: content.messages.length, + messageCount: content.messageCount ?? content.messages.length, displayName: content.summary ? stripUnsafeCharacters(content.summary) : firstUserMessage, @@ -505,10 +512,10 @@ export class SessionSelector { const sessionPath = path.join(chatsDir, sessionInfo.fileName); try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const sessionData: ConversationRecord = JSON.parse( - await fs.readFile(sessionPath, 'utf8'), - ); + const sessionData = await loadConversationRecord(sessionPath); + if (!sessionData) { + throw new Error('Failed to load session data'); + } const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`; diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index 757dbdb952..94763c7d40 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -76,7 +76,6 @@ export class LegacyAgentProtocol implements AgentProtocol { this._config = deps.config; this._client = deps.client ?? deps.config.getGeminiClient(); this._promptId = deps.promptId ?? deps.config.promptId ?? ''; - if (deps.scheduler) { this._scheduler = deps.scheduler; } else { diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index c824344cfe..26f0cc88e3 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -141,6 +141,7 @@ vi.mock('../core/geminiChat.js', () => ({ CHUNK: 'chunk', }, GeminiChat: vi.fn().mockImplementation(() => ({ + initialize: vi.fn(), sendMessageStream: mockSendMessageStream, getHistory: vi.fn((_curated?: boolean) => [...mockChatHistory]), setHistory: mockSetHistory, @@ -434,6 +435,7 @@ describe('LocalAgentExecutor', () => { MockedGeminiChat.mockImplementation( () => ({ + initialize: vi.fn(), sendMessageStream: mockSendMessageStream, setSystemInstruction: mockSetSystemInstruction, getHistory: vi.fn((_curated?: boolean) => [...mockChatHistory]), diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index b47c843735..a65a74a0a9 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -1027,15 +1027,16 @@ export class LocalAgentExecutor { : undefined; try { - return new GeminiChat( + const chat = new GeminiChat( this.executionContext, systemInstruction, [{ functionDeclarations: tools }], startHistory, undefined, undefined, - 'subagent', ); + await chat.initialize(undefined, 'subagent'); + return chat; } catch (e: unknown) { await reportError( e, diff --git a/packages/core/src/agents/subagent-tool-wrapper.test.ts b/packages/core/src/agents/subagent-tool-wrapper.test.ts deleted file mode 100644 index 4e2cdb64e6..0000000000 --- a/packages/core/src/agents/subagent-tool-wrapper.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SubagentToolWrapper } from './subagent-tool-wrapper.js'; -import { LocalSubagentInvocation } from './local-invocation.js'; -import { makeFakeConfig } from '../test-utils/config.js'; -import type { LocalAgentDefinition, AgentInputs } from './types.js'; -import type { Config } from '../config/config.js'; -import { Kind } from '../tools/tools.js'; -import type { MessageBus } from '../confirmation-bus/message-bus.js'; -import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; - -// Mock dependencies to isolate the SubagentToolWrapper class -vi.mock('./local-invocation.js'); - -const MockedLocalSubagentInvocation = vi.mocked(LocalSubagentInvocation); - -// Define reusable test data -let mockConfig: Config; -let mockMessageBus: MessageBus; - -const mockDefinition: LocalAgentDefinition = { - kind: 'local', - name: 'TestAgent', - displayName: 'Test Agent Display Name', - description: 'An agent for testing.', - inputConfig: { - inputSchema: { - type: 'object', - properties: { - goal: { type: 'string', description: 'The goal.' }, - priority: { - type: 'number', - description: 'The priority.', - }, - }, - required: ['goal'], - }, - }, - modelConfig: { - model: 'gemini-test-model', - generateContentConfig: { - temperature: 0, - topP: 1, - }, - }, - runConfig: { maxTimeMinutes: 5 }, - promptConfig: { systemPrompt: 'You are a test agent.' }, -}; - -describe('SubagentToolWrapper', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockConfig = makeFakeConfig(); - // .config is already set correctly by the getter on the instance. - Object.defineProperty(mockConfig, 'promptId', { - get: () => 'test-prompt-id', - configurable: true, - }); - mockMessageBus = createMockMessageBus(); - }); - - describe('constructor', () => { - it('should correctly configure the tool properties from the agent definition', () => { - const wrapper = new SubagentToolWrapper( - mockDefinition, - mockConfig, - mockMessageBus, - ); - - expect(wrapper.name).toBe(mockDefinition.name); - expect(wrapper.displayName).toBe(mockDefinition.displayName); - expect(wrapper.description).toBe(mockDefinition.description); - expect(wrapper.kind).toBe(Kind.Agent); - expect(wrapper.isOutputMarkdown).toBe(true); - expect(wrapper.canUpdateOutput).toBe(true); - }); - - it('should fall back to the agent name for displayName if it is not provided', () => { - const definitionWithoutDisplayName = { - ...mockDefinition, - displayName: undefined, - }; - const wrapper = new SubagentToolWrapper( - definitionWithoutDisplayName, - mockConfig, - mockMessageBus, - ); - expect(wrapper.displayName).toBe(definitionWithoutDisplayName.name); - }); - - it('should generate a valid tool schema using the definition and converted schema', () => { - const wrapper = new SubagentToolWrapper( - mockDefinition, - mockConfig, - mockMessageBus, - ); - const schema = wrapper.schema; - - expect(schema.name).toBe(mockDefinition.name); - expect(schema.description).toBe(mockDefinition.description); - expect(schema.parametersJsonSchema).toEqual({ - ...(mockDefinition.inputConfig.inputSchema as Record), - properties: { - ...(( - mockDefinition.inputConfig.inputSchema as Record - )['properties'] as Record), - wait_for_previous: { - type: 'boolean', - description: - 'Set to true to wait for all previously requested tools in this turn to complete before starting. Set to false (or omit) to run in parallel. Use true when this tool depends on the output of previous tools.', - }, - }, - }); - }); - }); - - describe('createInvocation', () => { - it('should create a LocalSubagentInvocation with the correct parameters', () => { - const wrapper = new SubagentToolWrapper( - mockDefinition, - mockConfig, - mockMessageBus, - ); - const params: AgentInputs = { goal: 'Test the invocation', priority: 1 }; - - // The public `build` method calls the protected `createInvocation` after validation - const invocation = wrapper.build(params); - - expect(invocation).toBeInstanceOf(LocalSubagentInvocation); - expect(MockedLocalSubagentInvocation).toHaveBeenCalledExactlyOnceWith( - mockDefinition, - mockConfig, - params, - mockMessageBus, - mockDefinition.name, - mockDefinition.displayName, - ); - }); - - it('should pass the messageBus to the LocalSubagentInvocation constructor', () => { - const specificMessageBus = { - publish: vi.fn(), - subscribe: vi.fn(), - unsubscribe: vi.fn(), - } as unknown as MessageBus; - const wrapper = new SubagentToolWrapper( - mockDefinition, - mockConfig, - specificMessageBus, - ); - const params: AgentInputs = { goal: 'Test the invocation', priority: 1 }; - - wrapper.build(params); - - expect(MockedLocalSubagentInvocation).toHaveBeenCalledWith( - mockDefinition, - mockConfig, - params, - specificMessageBus, - mockDefinition.name, - mockDefinition.displayName, - ); - }); - - it('should throw a validation error for invalid parameters before creating an invocation', () => { - const wrapper = new SubagentToolWrapper( - mockDefinition, - mockConfig, - mockMessageBus, - ); - // Missing the required 'goal' parameter - const invalidParams = { priority: 1 }; - - // The `build` method in the base class performs JSON schema validation - // before calling the protected `createInvocation` method. - expect(() => wrapper.build(invalidParams)).toThrow( - "params must have required property 'goal'", - ); - expect(MockedLocalSubagentInvocation).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/core/src/agents/subagent-tool-wrapper.ts b/packages/core/src/agents/subagent-tool-wrapper.ts deleted file mode 100644 index 30a30d76d0..0000000000 --- a/packages/core/src/agents/subagent-tool-wrapper.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - BaseDeclarativeTool, - Kind, - type ToolInvocation, - type ToolResult, -} from '../tools/tools.js'; - -import { type AgentLoopContext } from '../config/agent-loop-context.js'; -import type { AgentDefinition, AgentInputs } from './types.js'; -import { LocalSubagentInvocation } from './local-invocation.js'; -import { RemoteAgentInvocation } from './remote-invocation.js'; -import { BrowserAgentInvocation } from './browser/browserAgentInvocation.js'; -import { BROWSER_AGENT_NAME } from './browser/browserAgentDefinition.js'; -import type { MessageBus } from '../confirmation-bus/message-bus.js'; - -/** - * A tool wrapper that dynamically exposes a subagent as a standard, - * strongly-typed `DeclarativeTool`. - */ -export class SubagentToolWrapper extends BaseDeclarativeTool< - AgentInputs, - ToolResult -> { - /** - * Constructs the tool wrapper. - * - * The constructor dynamically generates the JSON schema for the tool's - * parameters based on the subagent's input configuration. - * - * @param definition The `AgentDefinition` of the subagent to wrap. - * @param context The execution context. - * @param messageBus Optional message bus for policy enforcement. - */ - constructor( - private readonly definition: AgentDefinition, - private readonly context: AgentLoopContext, - messageBus: MessageBus, - ) { - super( - definition.name, - definition.displayName ?? definition.name, - definition.description, - Kind.Agent, - definition.inputConfig.inputSchema, - messageBus, - /* isOutputMarkdown */ true, - /* canUpdateOutput */ true, - ); - } - - /** - * Creates an invocation instance for executing the subagent. - * - * This method is called by the tool framework when the parent agent decides - * to use this tool. - * - * @param params The validated input parameters from the parent agent's call. - * @returns A `ToolInvocation` instance ready for execution. - */ - protected createInvocation( - params: AgentInputs, - messageBus: MessageBus, - _toolName?: string, - _toolDisplayName?: string, - ): ToolInvocation { - const definition = this.definition; - const effectiveMessageBus = messageBus; - - if (definition.kind === 'remote') { - return new RemoteAgentInvocation( - definition, - this.context, - params, - effectiveMessageBus, - _toolName, - _toolDisplayName, - ); - } - - // Special handling for browser agent - needs async MCP setup - if (definition.name === BROWSER_AGENT_NAME) { - return new BrowserAgentInvocation( - this.context, - params, - effectiveMessageBus, - _toolName, - _toolDisplayName, - ); - } - - return new LocalSubagentInvocation( - definition, - this.context, - params, - effectiveMessageBus, - _toolName, - _toolDisplayName, - ); - } -} diff --git a/packages/core/src/agents/subagent-tool.test.ts b/packages/core/src/agents/subagent-tool.test.ts deleted file mode 100644 index e184558f81..0000000000 --- a/packages/core/src/agents/subagent-tool.test.ts +++ /dev/null @@ -1,424 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SubagentTool } from './subagent-tool.js'; -import { SubagentToolWrapper } from './subagent-tool-wrapper.js'; -import { - Kind, - type DeclarativeTool, - type ToolCallConfirmationDetails, - type ToolInvocation, - type ToolResult, -} from '../tools/tools.js'; -import type { - LocalAgentDefinition, - RemoteAgentDefinition, - AgentInputs, -} from './types.js'; -import { makeFakeConfig } from '../test-utils/config.js'; -import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; -import type { Config } from '../config/config.js'; -import type { MessageBus } from '../confirmation-bus/message-bus.js'; -import { - GeminiCliOperation, - GEN_AI_AGENT_DESCRIPTION, - GEN_AI_AGENT_NAME, -} from '../telemetry/constants.js'; -import type { ToolRegistry } from 'src/tools/tool-registry.js'; - -vi.mock('./subagent-tool-wrapper.js'); - -// Mock runInDevTraceSpan -const runInDevTraceSpan = vi.hoisted(() => - vi.fn(async (opts, fn) => { - const metadata = { attributes: opts.attributes || {} }; - return fn({ - metadata, - }); - }), -); - -vi.mock('../telemetry/trace.js', () => ({ - runInDevTraceSpan, -})); - -const MockSubagentToolWrapper = vi.mocked(SubagentToolWrapper); - -const testDefinition: LocalAgentDefinition = { - kind: 'local', - name: 'LocalAgent', - description: 'A local agent.', - inputConfig: { inputSchema: { type: 'object', properties: {} } }, - modelConfig: { model: 'test', generateContentConfig: {} }, - runConfig: { maxTimeMinutes: 1 }, - promptConfig: { systemPrompt: 'test' }, -}; - -const testRemoteDefinition: RemoteAgentDefinition = { - kind: 'remote', - name: 'RemoteAgent', - description: 'A remote agent.', - inputConfig: { - inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, - }, - agentCardUrl: 'http://example.com/agent', -}; - -describe('SubAgentInvocation', () => { - let mockConfig: Config; - let mockMessageBus: MessageBus; - let mockInnerInvocation: ToolInvocation; - - beforeEach(() => { - vi.clearAllMocks(); - mockConfig = makeFakeConfig(); - // .config is already set correctly by the getter on the instance. - Object.defineProperty(mockConfig, 'promptId', { - get: () => 'test-prompt-id', - configurable: true, - }); - mockMessageBus = createMockMessageBus(); - mockInnerInvocation = { - shouldConfirmExecute: vi.fn(), - execute: vi.fn(), - params: {}, - getDescription: vi.fn(), - toolLocations: vi.fn(), - }; - - MockSubagentToolWrapper.prototype.build = vi - .fn() - .mockReturnValue(mockInnerInvocation); - }); - - it('should have Kind.Agent', () => { - const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus); - expect(tool.kind).toBe(Kind.Agent); - }); - - it('should delegate shouldConfirmExecute to the inner sub-invocation (local)', async () => { - const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus); - const params = {}; - // @ts-expect-error - accessing protected method for testing - const invocation = tool.createInvocation(params, mockMessageBus); - - vi.mocked(mockInnerInvocation.shouldConfirmExecute).mockResolvedValue( - false, - ); - - const abortSignal = new AbortController().signal; - const result = await invocation.shouldConfirmExecute(abortSignal); - - expect(result).toBe(false); - expect(mockInnerInvocation.shouldConfirmExecute).toHaveBeenCalledWith( - abortSignal, - ); - expect(MockSubagentToolWrapper).toHaveBeenCalledWith( - testDefinition, - mockConfig, - mockMessageBus, - ); - }); - - it('should return the correct description', () => { - const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus); - const params = {}; - // @ts-expect-error - accessing protected method for testing - const invocation = tool.createInvocation(params, mockMessageBus); - expect(invocation.getDescription()).toBe( - "Delegating to agent 'LocalAgent'", - ); - }); - - it('should delegate shouldConfirmExecute to the inner sub-invocation (remote)', async () => { - const tool = new SubagentTool( - testRemoteDefinition, - mockConfig, - mockMessageBus, - ); - const params = { query: 'test' }; - // @ts-expect-error - accessing protected method for testing - const invocation = tool.createInvocation(params, mockMessageBus); - - const confirmationDetails = { - type: 'info', - title: 'Confirm', - prompt: 'Prompt', - onConfirm: vi.fn(), - } as const; - vi.mocked(mockInnerInvocation.shouldConfirmExecute).mockResolvedValue( - confirmationDetails as unknown as ToolCallConfirmationDetails, - ); - - const abortSignal = new AbortController().signal; - const result = await invocation.shouldConfirmExecute(abortSignal); - - expect(result).toBe(confirmationDetails); - expect(mockInnerInvocation.shouldConfirmExecute).toHaveBeenCalledWith( - abortSignal, - ); - expect(MockSubagentToolWrapper).toHaveBeenCalledWith( - testRemoteDefinition, - mockConfig, - mockMessageBus, - ); - }); - - it('should delegate execute to the inner sub-invocation', async () => { - const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus); - const params = {}; - // @ts-expect-error - accessing protected method for testing - const invocation = tool.createInvocation(params, mockMessageBus); - - const mockResult: ToolResult = { - llmContent: 'success', - returnDisplay: 'success', - }; - vi.mocked(mockInnerInvocation.execute).mockResolvedValue(mockResult); - - const abortSignal = new AbortController().signal; - const updateOutput = vi.fn(); - const result = await invocation.execute(abortSignal, updateOutput); - - expect(result).toBe(mockResult); - expect(mockInnerInvocation.execute).toHaveBeenCalledWith( - abortSignal, - updateOutput, - ); - - expect(runInDevTraceSpan).toHaveBeenCalledWith( - expect.objectContaining({ - operation: GeminiCliOperation.AgentCall, - attributes: expect.objectContaining({ - [GEN_AI_AGENT_NAME]: testDefinition.name, - [GEN_AI_AGENT_DESCRIPTION]: testDefinition.description, - }), - }), - expect.any(Function), - ); - - // Verify metadata was set on the span - const spanCallback = vi.mocked(runInDevTraceSpan).mock.calls[0][1]; - const mockMetadata = { input: undefined, output: undefined }; - const mockSpan = { metadata: mockMetadata }; - await spanCallback(mockSpan as Parameters[0]); - expect(mockMetadata.input).toBe(params); - expect(mockMetadata.output).toBe(mockResult); - }); - - describe('withUserHints', () => { - it('should NOT modify query for local agents', async () => { - mockConfig = makeFakeConfig({ modelSteering: true }); - mockConfig.injectionService.addInjection('Test Hint', 'user_steering'); - - const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus); - const params = { query: 'original query' }; - // @ts-expect-error - accessing private method for testing - const invocation = tool.createInvocation(params, mockMessageBus); - - // @ts-expect-error - accessing private method for testing - const hintedParams = invocation.withUserHints(params); - - expect(hintedParams.query).toBe('original query'); - }); - - it('should NOT modify query for remote agents if model steering is disabled', async () => { - mockConfig = makeFakeConfig({ modelSteering: false }); - mockConfig.injectionService.addInjection('Test Hint', 'user_steering'); - - const tool = new SubagentTool( - testRemoteDefinition, - mockConfig, - mockMessageBus, - ); - const params = { query: 'original query' }; - // @ts-expect-error - accessing private method for testing - const invocation = tool.createInvocation(params, mockMessageBus); - - // @ts-expect-error - accessing private method for testing - const hintedParams = invocation.withUserHints(params); - - expect(hintedParams.query).toBe('original query'); - }); - - it('should NOT modify query for remote agents if there are no hints', async () => { - mockConfig = makeFakeConfig({ modelSteering: true }); - - const tool = new SubagentTool( - testRemoteDefinition, - mockConfig, - mockMessageBus, - ); - const params = { query: 'original query' }; - // @ts-expect-error - accessing private method for testing - const invocation = tool.createInvocation(params, mockMessageBus); - - // @ts-expect-error - accessing private method for testing - const hintedParams = invocation.withUserHints(params); - - expect(hintedParams.query).toBe('original query'); - }); - - it('should prepend hints to query for remote agents when hints exist and steering is enabled', async () => { - mockConfig = makeFakeConfig({ modelSteering: true }); - - const tool = new SubagentTool( - testRemoteDefinition, - mockConfig, - mockMessageBus, - ); - const params = { query: 'original query' }; - // @ts-expect-error - accessing private method for testing - const invocation = tool.createInvocation(params, mockMessageBus); - - mockConfig.injectionService.addInjection('Hint 1', 'user_steering'); - mockConfig.injectionService.addInjection('Hint 2', 'user_steering'); - - // @ts-expect-error - accessing private method for testing - const hintedParams = invocation.withUserHints(params); - - expect(hintedParams.query).toContain('Hint 1'); - expect(hintedParams.query).toContain('Hint 2'); - expect(hintedParams.query).toMatch(/original query$/); - }); - - it('should NOT include legacy hints added before the invocation was created', async () => { - mockConfig = makeFakeConfig({ modelSteering: true }); - mockConfig.injectionService.addInjection('Legacy Hint', 'user_steering'); - - const tool = new SubagentTool( - testRemoteDefinition, - mockConfig, - mockMessageBus, - ); - const params = { query: 'original query' }; - - // Creation of invocation captures the current hint state - // @ts-expect-error - accessing private method for testing - const invocation = tool.createInvocation(params, mockMessageBus); - - // Verify no hints are present yet - // @ts-expect-error - accessing private method for testing - let hintedParams = invocation.withUserHints(params); - expect(hintedParams.query).toBe('original query'); - - // Add a new hint after creation - mockConfig.injectionService.addInjection('New Hint', 'user_steering'); - // @ts-expect-error - accessing private method for testing - hintedParams = invocation.withUserHints(params); - - expect(hintedParams.query).toContain('New Hint'); - expect(hintedParams.query).not.toContain('Legacy Hint'); - }); - - it('should NOT modify query if query is missing or not a string', async () => { - mockConfig = makeFakeConfig({ modelSteering: true }); - mockConfig.injectionService.addInjection('Hint', 'user_steering'); - - const tool = new SubagentTool( - testRemoteDefinition, - mockConfig, - mockMessageBus, - ); - const params = { other: 'param' }; - // @ts-expect-error - accessing private method for testing - const invocation = tool.createInvocation(params, mockMessageBus); - - // @ts-expect-error - accessing private method for testing - const hintedParams = invocation.withUserHints(params); - - expect(hintedParams).toEqual(params); - }); - }); -}); - -describe('SubagentTool Read-Only logic', () => { - let mockConfig: Config; - let mockMessageBus: MessageBus; - - beforeEach(() => { - vi.clearAllMocks(); - mockConfig = makeFakeConfig(); - // .config is already set correctly by the getter on the instance. - Object.defineProperty(mockConfig, 'promptId', { - get: () => 'test-prompt-id', - configurable: true, - }); - mockMessageBus = createMockMessageBus(); - }); - - it('should be false for remote agents', () => { - const tool = new SubagentTool( - testRemoteDefinition, - mockConfig, - mockMessageBus, - ); - expect(tool.isReadOnly).toBe(false); - }); - - it('should be true for local agent with only read-only tools', () => { - const readOnlyTool = { - name: 'read', - isReadOnly: true, - } as unknown as DeclarativeTool; - const registry = { - getTool: (name: string) => (name === 'read' ? readOnlyTool : undefined), - }; - vi.spyOn(mockConfig, 'toolRegistry', 'get').mockReturnValue( - registry as unknown as ToolRegistry, - ); - - const defWithTools: LocalAgentDefinition = { - ...testDefinition, - toolConfig: { tools: ['read'] }, - }; - const tool = new SubagentTool(defWithTools, mockConfig, mockMessageBus); - expect(tool.isReadOnly).toBe(true); - }); - - it('should be false for local agent with at least one non-read-only tool', () => { - const readOnlyTool = { - name: 'read', - isReadOnly: true, - } as unknown as DeclarativeTool; - const mutatorTool = { - name: 'write', - isReadOnly: false, - } as unknown as DeclarativeTool; - const registry = { - getTool: (name: string) => { - if (name === 'read') return readOnlyTool; - if (name === 'write') return mutatorTool; - return undefined; - }, - }; - vi.spyOn(mockConfig, 'toolRegistry', 'get').mockReturnValue( - registry as unknown as ToolRegistry, - ); - - const defWithTools: LocalAgentDefinition = { - ...testDefinition, - toolConfig: { tools: ['read', 'write'] }, - }; - const tool = new SubagentTool(defWithTools, mockConfig, mockMessageBus); - expect(tool.isReadOnly).toBe(false); - }); - - it('should be true for local agent with no tools', () => { - const registry = { getTool: () => undefined }; - vi.spyOn(mockConfig, 'toolRegistry', 'get').mockReturnValue( - registry as unknown as ToolRegistry, - ); - - const defNoTools: LocalAgentDefinition = { - ...testDefinition, - toolConfig: { tools: [] }, - }; - const tool = new SubagentTool(defNoTools, mockConfig, mockMessageBus); - expect(tool.isReadOnly).toBe(true); - }); -}); diff --git a/packages/core/src/agents/subagent-tool.ts b/packages/core/src/agents/subagent-tool.ts deleted file mode 100644 index e689098f5a..0000000000 --- a/packages/core/src/agents/subagent-tool.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - BaseDeclarativeTool, - Kind, - type ToolInvocation, - type ToolResult, - BaseToolInvocation, - type ToolCallConfirmationDetails, - isTool, - type ToolLiveOutput, -} from '../tools/tools.js'; -import type { Config } from '../config/config.js'; -import { type AgentLoopContext } from '../config/agent-loop-context.js'; -import type { MessageBus } from '../confirmation-bus/message-bus.js'; -import type { AgentDefinition, AgentInputs } from './types.js'; -import { SubagentToolWrapper } from './subagent-tool-wrapper.js'; -import { SchemaValidator } from '../utils/schemaValidator.js'; -import { formatUserHintsForModel } from '../utils/fastAckHelper.js'; -import { runInDevTraceSpan } from '../telemetry/trace.js'; -import { - GeminiCliOperation, - GEN_AI_AGENT_DESCRIPTION, - GEN_AI_AGENT_NAME, -} from '../telemetry/constants.js'; - -export class SubagentTool extends BaseDeclarativeTool { - constructor( - private readonly definition: AgentDefinition, - private readonly context: AgentLoopContext, - messageBus: MessageBus, - ) { - const inputSchema = definition.inputConfig.inputSchema; - - // Validate schema on construction - const schemaError = SchemaValidator.validateSchema(inputSchema); - if (schemaError) { - throw new Error( - `Invalid schema for agent ${definition.name}: ${schemaError}`, - ); - } - - super( - definition.name, - definition.displayName ?? definition.name, - definition.description, - Kind.Agent, - inputSchema, - messageBus, - /* isOutputMarkdown */ true, - /* canUpdateOutput */ true, - ); - } - - private _memoizedIsReadOnly: boolean | undefined; - - override get isReadOnly(): boolean { - if (this._memoizedIsReadOnly !== undefined) { - return this._memoizedIsReadOnly; - } - // No try-catch here. If getToolRegistry() throws, we let it throw. - // This is an invariant: you can't check read-only status if the system isn't initialized. - this._memoizedIsReadOnly = SubagentTool.checkIsReadOnly( - this.definition, - this.context, - ); - return this._memoizedIsReadOnly; - } - - private static checkIsReadOnly( - definition: AgentDefinition, - context: AgentLoopContext, - ): boolean { - if (definition.kind === 'remote') { - return false; - } - const tools = definition.toolConfig?.tools ?? []; - const registry = context.toolRegistry; - - if (!registry) { - return false; - } - - for (const tool of tools) { - if (typeof tool === 'string') { - const resolvedTool = registry.getTool(tool); - if (!resolvedTool || !resolvedTool.isReadOnly) { - return false; - } - } else if (isTool(tool)) { - if (!tool.isReadOnly) { - return false; - } - } else { - // FunctionDeclaration - we don't know, so assume NOT read-only - return false; - } - } - return true; - } - - protected createInvocation( - params: AgentInputs, - messageBus: MessageBus, - _toolName?: string, - _toolDisplayName?: string, - ): ToolInvocation { - return new SubAgentInvocation( - params, - this.definition, - this.context, - messageBus, - _toolName, - _toolDisplayName, - ); - } -} - -class SubAgentInvocation extends BaseToolInvocation { - private readonly startIndex: number; - - constructor( - params: AgentInputs, - private readonly definition: AgentDefinition, - private readonly context: AgentLoopContext, - messageBus: MessageBus, - _toolName?: string, - _toolDisplayName?: string, - ) { - super( - params, - messageBus, - _toolName ?? definition.name, - _toolDisplayName ?? definition.displayName ?? definition.name, - ); - this.startIndex = context.config.injectionService.getLatestInjectionIndex(); - } - - private get config(): Config { - return this.context.config; - } - - getDescription(): string { - return `Delegating to agent '${this.definition.name}'`; - } - - override async shouldConfirmExecute( - abortSignal: AbortSignal, - ): Promise { - const invocation = this.buildSubInvocation( - this.definition, - this.withUserHints(this.params), - ); - return invocation.shouldConfirmExecute(abortSignal); - } - - async execute( - signal: AbortSignal, - updateOutput?: (output: ToolLiveOutput) => void, - ): Promise { - const validationError = SchemaValidator.validate( - this.definition.inputConfig.inputSchema, - this.params, - ); - - if (validationError) { - throw new Error( - `Invalid arguments for agent '${this.definition.name}': ${validationError}. Input schema: ${JSON.stringify(this.definition.inputConfig.inputSchema)}.`, - ); - } - - const invocation = this.buildSubInvocation( - this.definition, - this.withUserHints(this.params), - ); - - return runInDevTraceSpan( - { - operation: GeminiCliOperation.AgentCall, - logPrompts: this.context.config.getTelemetryLogPromptsEnabled(), - sessionId: this.context.config.getSessionId(), - attributes: { - [GEN_AI_AGENT_NAME]: this.definition.name, - [GEN_AI_AGENT_DESCRIPTION]: this.definition.description, - }, - }, - async ({ metadata }) => { - metadata.input = this.params; - const result = await invocation.execute(signal, updateOutput); - metadata.output = result; - return result; - }, - ); - } - - private withUserHints(agentArgs: AgentInputs): AgentInputs { - if (this.definition.kind !== 'remote') { - return agentArgs; - } - - const userHints = this.config.injectionService.getInjectionsAfter( - this.startIndex, - 'user_steering', - ); - const formattedHints = formatUserHintsForModel(userHints); - if (!formattedHints) { - return agentArgs; - } - - const query = agentArgs['query']; - if (typeof query !== 'string' || query.trim().length === 0) { - return agentArgs; - } - - return { - ...agentArgs, - query: `${formattedHints}\n\n${query}`, - }; - } - - private buildSubInvocation( - definition: AgentDefinition, - agentArgs: AgentInputs, - ): ToolInvocation { - const wrapper = new SubagentToolWrapper( - definition, - this.context, - this.messageBus, - ); - - return wrapper.build(agentArgs); - } -} diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index cb4b645ab3..40be9c2236 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -424,6 +424,7 @@ async function authWithUserCode(client: OAuth2Client): Promise { '\n\n', ); + let authTimeoutId: NodeJS.Timeout | undefined; const code = await new Promise((resolve, reject) => { const rl = readline.createInterface({ input: process.stdin, @@ -431,20 +432,29 @@ async function authWithUserCode(client: OAuth2Client): Promise { terminal: true, }); - const timeout = setTimeout(() => { - rl.close(); - reject( + const abortController = new AbortController(); + authTimeoutId = setTimeout(() => { + abortController.abort( new FatalAuthenticationError( 'Authorization timed out after 5 minutes.', ), ); }, 300000); // 5 minute timeout + authTimeoutId.unref(); + + const onAbort = () => { + rl.close(); + reject(abortController.signal.reason); + }; + abortController.signal.addEventListener('abort', onAbort, { once: true }); rl.question('Enter the authorization code: ', (code) => { - clearTimeout(timeout); + abortController.signal.removeEventListener('abort', onAbort); rl.close(); resolve(code.trim()); }); + }).finally(() => { + if (authTimeoutId) clearTimeout(authTimeoutId); }); if (!code) { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index ecbf16ccf0..a43921e144 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -192,10 +192,6 @@ vi.mock('../agents/registry.js', () => { return { AgentRegistry: AgentRegistryMock }; }); -vi.mock('../agents/subagent-tool.js', () => ({ - SubagentTool: vi.fn(), -})); - vi.mock('../resources/resource-registry.js', () => ({ ResourceRegistry: vi.fn(), })); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 7d476f8135..d49e027369 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -353,7 +353,9 @@ export class Storage { const chatsDir = path.join(this.getProjectTempDir(), 'chats'); try { const files = await fs.promises.readdir(chatsDir); - const jsonFiles = files.filter((f) => f.endsWith('.json')); + const jsonFiles = files.filter( + (f) => f.endsWith('.json') || f.endsWith('.jsonl'), + ); const sessions = await Promise.all( jsonFiles.map(async (file) => { diff --git a/packages/core/src/confirmation-bus/message-bus.test.ts b/packages/core/src/confirmation-bus/message-bus.test.ts index 8f5c51d7d5..9e2e43455b 100644 --- a/packages/core/src/confirmation-bus/message-bus.test.ts +++ b/packages/core/src/confirmation-bus/message-bus.test.ts @@ -348,4 +348,66 @@ describe('MessageBus', () => { ); }); }); + + describe('subscribe with AbortSignal', () => { + it('should remove listener when signal is aborted', async () => { + const handler = vi.fn(); + const controller = new AbortController(); + + messageBus.subscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler, { + signal: controller.signal, + }); + + const message: ToolExecutionSuccess = { + type: MessageBusType.TOOL_EXECUTION_SUCCESS as const, + toolCall: { name: 'test' }, + result: 'test', + }; + + controller.abort(); + + await messageBus.publish(message); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('should not add listener if signal is already aborted', async () => { + const handler = vi.fn(); + const controller = new AbortController(); + controller.abort(); + + messageBus.subscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler, { + signal: controller.signal, + }); + + const message: ToolExecutionSuccess = { + type: MessageBusType.TOOL_EXECUTION_SUCCESS as const, + toolCall: { name: 'test' }, + result: 'test', + }; + + await messageBus.publish(message); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('should remove abort listener when unsubscribe is called', async () => { + const handler = vi.fn(); + const controller = new AbortController(); + const signal = controller.signal; + + const removeEventListenerSpy = vi.spyOn(signal, 'removeEventListener'); + + messageBus.subscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler, { + signal, + }); + + messageBus.unsubscribe(MessageBusType.TOOL_EXECUTION_SUCCESS, handler); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + }); + }); }); diff --git a/packages/core/src/confirmation-bus/message-bus.ts b/packages/core/src/confirmation-bus/message-bus.ts index 72f1c1c15a..a14022ada5 100644 --- a/packages/core/src/confirmation-bus/message-bus.ts +++ b/packages/core/src/confirmation-bus/message-bus.ts @@ -13,6 +13,11 @@ import { safeJsonStringify } from '../utils/safeJsonStringify.js'; import { debugLogger } from '../utils/debugLogger.js'; export class MessageBus extends EventEmitter { + private listenerToAbortCleanup = new WeakMap< + object, + Map void> + >(); + constructor( private readonly policyEngine: PolicyEngine, private readonly debug = false, @@ -145,7 +150,36 @@ export class MessageBus extends EventEmitter { subscribe( type: T['type'], listener: (message: T) => void, + options?: { signal?: AbortSignal }, ): void { + if (options?.signal) { + const signal = options.signal; + if (signal.aborted) return; + + if (this.listenerToAbortCleanup.get(listener)?.has(type)) return; + + const abortHandler = () => { + this.off(type, listener); + const typeToCleanup = this.listenerToAbortCleanup.get(listener); + if (typeToCleanup) { + typeToCleanup.delete(type); + if (typeToCleanup.size === 0) { + this.listenerToAbortCleanup.delete(listener); + } + } + }; + signal.addEventListener('abort', abortHandler, { once: true }); + + let typeToCleanup = this.listenerToAbortCleanup.get(listener); + if (!typeToCleanup) { + typeToCleanup = new Map void>(); + this.listenerToAbortCleanup.set(listener, typeToCleanup); + } + typeToCleanup.set(type, () => { + signal.removeEventListener('abort', abortHandler); + }); + } + this.on(type, listener); } @@ -154,6 +188,17 @@ export class MessageBus extends EventEmitter { listener: (message: T) => void, ): void { this.off(type, listener); + const typeToCleanup = this.listenerToAbortCleanup.get(listener); + if (typeToCleanup) { + const cleanup = typeToCleanup.get(type); + if (cleanup) { + cleanup(); + typeToCleanup.delete(type); + } + if (typeToCleanup.size === 0) { + this.listenerToAbortCleanup.delete(listener); + } + } } /** diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index f8178488bd..e28ea9cfa4 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -63,6 +63,10 @@ vi.mock('node:fs', () => { writeFileSync: vi.fn((path: string, data: string) => { mockFileSystem.set(path, data); }), + appendFileSync: vi.fn((path: string, data: string) => { + const current = mockFileSystem.get(path) || ''; + mockFileSystem.set(path, current + data); + }), readFileSync: vi.fn((path: string) => { if (mockFileSystem.has(path)) { return mockFileSystem.get(path); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index c892cbe5f3..7641a8c82d 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -385,7 +385,7 @@ export class GeminiClient { try { const systemMemory = this.config.getSystemInstructionMemory(); const systemInstruction = getCoreSystemPrompt(this.config, systemMemory); - return new GeminiChat( + const chat = new GeminiChat( this.config, systemInstruction, tools, @@ -396,6 +396,8 @@ export class GeminiClient { return this.getChatTools(modelId); }, ); + await chat.initialize(resumedSessionData, 'main'); + return chat; } catch (error) { await reportError( error, diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index e822fd7fd6..d4a3f40aad 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -48,6 +48,10 @@ vi.mock('node:fs', () => { writeFileSync: vi.fn((path: string, data: string) => { mockFileSystem.set(path, data); }), + appendFileSync: vi.fn((path: string, data: string) => { + const current = mockFileSystem.get(path) || ''; + mockFileSystem.set(path, current + data); + }), readFileSync: vi.fn((path: string) => { if (mockFileSystem.has(path)) { return mockFileSystem.get(path); @@ -1082,8 +1086,10 @@ describe('GeminiChat', () => { ); const { default: fs } = await import('node:fs'); - const writeFileSync = vi.mocked(fs.writeFileSync); - const writeCountBefore = writeFileSync.mock.calls.length; + const appendFileSync = vi.mocked(fs.appendFileSync); + const writeCountBefore = appendFileSync.mock.calls.length; + + await chat.initialize(); const stream = await chat.sendMessageStream( { model: 'test-model' }, @@ -1096,17 +1102,19 @@ describe('GeminiChat', () => { // consume } - const newWrites = writeFileSync.mock.calls.slice(writeCountBefore); + const newWrites = appendFileSync.mock.calls.slice(writeCountBefore); expect(newWrites.length).toBeGreaterThan(0); - const lastWriteData = JSON.parse( - newWrites[newWrites.length - 1][1] as string, - ) as { messages: Array<{ type: string }> }; + const geminiWrite = newWrites.find((w) => { + try { + const data = JSON.parse(w[1] as string); + return data.type === 'gemini'; + } catch { + return false; + } + }); - const geminiMessages = lastWriteData.messages.filter( - (m) => m.type === 'gemini', - ); - expect(geminiMessages.length).toBeGreaterThan(0); + expect(geminiWrite).toBeDefined(); }); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index c5246af714..7e9aa47676 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -258,16 +258,21 @@ export class GeminiChat { private history: Content[] = [], resumedSessionData?: ResumedSessionData, private readonly onModelChanged?: (modelId: string) => Promise, - kind: 'main' | 'subagent' = 'main', ) { validateHistory(history); this.chatRecordingService = new ChatRecordingService(context); - this.chatRecordingService.initialize(resumedSessionData, kind); this.lastPromptTokenCount = estimateTokenCountSync( this.history.flatMap((c) => c.parts || []), ); } + async initialize( + resumedSessionData?: ResumedSessionData, + kind: 'main' | 'subagent' = 'main', + ) { + await this.chatRecordingService.initialize(resumedSessionData, kind); + } + setSystemInstruction(sysInstr: string) { this.systemInstruction = sysInstr; } diff --git a/packages/core/src/core/loggingContentGenerator.test.ts b/packages/core/src/core/loggingContentGenerator.test.ts index 2b8249d539..7f3b1a9f33 100644 --- a/packages/core/src/core/loggingContentGenerator.test.ts +++ b/packages/core/src/core/loggingContentGenerator.test.ts @@ -315,6 +315,100 @@ describe('LoggingContentGenerator', () => { return true; }); }); + + it('should decode Uint8Array data in Gaxios errors', async () => { + const req = { contents: [], model: 'gemini-pro' }; + + const gaxiosError = Object.assign(new Error('Gaxios Error'), { + response: { data: new Uint8Array([72, 101, 108, 108, 111]) }, + }); + + vi.mocked(wrapped.generateContent).mockRejectedValue(gaxiosError); + + await expect( + loggingContentGenerator.generateContent( + req, + 'prompt-123', + LlmRole.MAIN, + ), + ).rejects.toSatisfy((error: unknown) => { + const gError = error as { response: { data: unknown } }; + expect(gError.response.data).toBe('Hello'); + return true; + }); + }); + + it('should decode multi-byte UTF-8 from comma-separated byte strings', async () => { + const req = { contents: [], model: 'gemini-pro' }; + + // "Héllo" in UTF-8 bytes: H=72, é=195,169, l=108, l=108, o=111 + const utf8Data = '72,195,169,108,108,111'; + const gaxiosError = Object.assign(new Error('Gaxios Error'), { + response: { data: utf8Data }, + }); + + vi.mocked(wrapped.generateContent).mockRejectedValue(gaxiosError); + + await expect( + loggingContentGenerator.generateContent( + req, + 'prompt-123', + LlmRole.MAIN, + ), + ).rejects.toSatisfy((error: unknown) => { + const gError = error as { response: { data: unknown } }; + expect(gError.response.data).toBe('Héllo'); + return true; + }); + }); + + it('should decode 3-byte UTF-8 from comma-separated byte strings', async () => { + const req = { contents: [], model: 'gemini-pro' }; + + // "こんにちは" in UTF-8 bytes (3 bytes per character) + const utf8Data = + '227,129,147,227,130,147,227,129,171,227,129,161,227,129,175'; + const gaxiosError = Object.assign(new Error('Gaxios Error'), { + response: { data: utf8Data }, + }); + + vi.mocked(wrapped.generateContent).mockRejectedValue(gaxiosError); + + await expect( + loggingContentGenerator.generateContent( + req, + 'prompt-123', + LlmRole.MAIN, + ), + ).rejects.toSatisfy((error: unknown) => { + const gError = error as { response: { data: unknown } }; + expect(gError.response.data).toBe('こんにちは'); + return true; + }); + }); + + it('should reject byte strings with values outside 0-255 range', async () => { + const req = { contents: [], model: 'gemini-pro' }; + + const outOfRange = '72,256,108'; + const gaxiosError = Object.assign(new Error('Gaxios Error'), { + response: { data: outOfRange }, + }); + + vi.mocked(wrapped.generateContent).mockRejectedValue(gaxiosError); + + await expect( + loggingContentGenerator.generateContent( + req, + 'prompt-123', + LlmRole.MAIN, + ), + ).rejects.toSatisfy((error: unknown) => { + const gError = error as { response: { data: unknown } }; + expect(gError.response.data).toBe(outOfRange); + return true; + }); + }); }); it('should NOT log error on AbortError (user cancellation)', async () => { diff --git a/packages/core/src/core/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator.ts index 027a7ae622..1c8579df9a 100644 --- a/packages/core/src/core/loggingContentGenerator.ts +++ b/packages/core/src/core/loggingContentGenerator.ts @@ -276,8 +276,10 @@ export class LoggingContentGenerator implements ContentGenerator { } private _fixGaxiosErrorData(error: unknown): void { - // Fix for raw ASCII buffer strings appearing in dev with the latest - // Gaxios updates. + // Fix for raw buffer data appearing in Gaxios errors. + // Gaxios may return the response body as a Uint8Array, a Buffer, or + // a string of comma-separated byte values (e.g. "72,101,108,108,111"). + // All three forms need to be decoded as UTF-8. if ( typeof error === 'object' && error !== null && @@ -288,11 +290,20 @@ export class LoggingContentGenerator implements ContentGenerator { ) { const response = error.response as { data: unknown }; const data = response.data; - if (typeof data === 'string' && data.includes(',')) { + + if (data instanceof Uint8Array) { + // Gaxios returned raw bytes directly + response.data = new TextDecoder().decode(data); + } else if (typeof data === 'string' && data.includes(',')) { + // Gaxios returned bytes as a comma-separated string try { - const charCodes = data.split(',').map(Number); - if (charCodes.every((code) => !isNaN(code))) { - response.data = String.fromCharCode(...charCodes); + const byteValues = data.split(',').map(Number); + if ( + byteValues.every((b) => Number.isInteger(b) && b >= 0 && b <= 255) + ) { + response.data = new TextDecoder().decode( + new Uint8Array(byteValues), + ); } } catch { // If parsing fails, just leave it alone diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 5cd4460e97..251ccb4a5e 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -1023,31 +1023,35 @@ describe('MCPOAuthProvider', () => { }); it('should handle callback timeout', async () => { - vi.mocked(http.createServer).mockImplementation( - () => mockHttpServer as unknown as http.Server, - ); + vi.useFakeTimers(); + try { + vi.mocked(http.createServer).mockImplementation( + () => mockHttpServer as unknown as http.Server, + ); - mockHttpServer.listen.mockImplementation((port, callback) => { - callback?.(); - // Don't trigger callback - simulate timeout - }); + mockHttpServer.listen.mockImplementation((port, callback) => { + callback?.(); + // Don't trigger callback - simulate timeout + }); - // Mock setTimeout to trigger timeout immediately - const originalSetTimeout = global.setTimeout; - global.setTimeout = vi.fn((callback, delay) => { - if (delay === 5 * 60 * 1000) { - // 5 minute timeout - callback(); - } - return originalSetTimeout(callback, 0); - }) as unknown as typeof setTimeout; + const authProvider = new MCPOAuthProvider(); - const authProvider = new MCPOAuthProvider(); - await expect( - authProvider.authenticate('test-server', mockConfig), - ).rejects.toThrow('OAuth callback timeout'); + const authPromise = authProvider + .authenticate('test-server', mockConfig) + .catch((e: Error) => { + if (e.message !== 'OAuth callback timeout') throw e; + return e; + }); - global.setTimeout = originalSetTimeout; + // Advance timers by 5 minutes + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + + const error = await authPromise; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('OAuth callback timeout'); + } finally { + vi.useRealTimers(); + } }); it('should use port from redirectUri if provided', async () => { diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index c468a79cb0..0cbe0a3e13 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -80,6 +80,9 @@ priority = 40 modes = ["plan"] denyMessage = "You are in Plan Mode with access to read-only tools. Execution of scripts (including those from skills) is blocked." +# Explicitly allowed tools in Plan Mode (interactive: ask user, non-interactive: deny) +# Priority 50 overrides the catch-all (40) and also ensures we override default tier ALLOW rules (e.g. from read-only.toml). + [[rule]] toolName = "*" mcpName = "*" @@ -89,15 +92,6 @@ priority = 50 modes = ["plan"] interactive = true -[[rule]] -toolName = "*" -mcpName = "*" -toolAnnotations = { readOnlyHint = true } -decision = "deny" -priority = 50 -modes = ["plan"] -interactive = false - # Allow specific subagents in Plan mode. # We use argsPattern to match the agent_name argument for invoke_agent. [[rule]] @@ -115,13 +109,6 @@ priority = 50 modes = ["plan"] interactive = true -[[rule]] -toolName = ["ask_user", "save_memory", "web_fetch", "activate_skill"] -decision = "deny" -priority = 50 -modes = ["plan"] -interactive = false - # Allow write_file and replace for .md files in the plans directory (cross-platform) # We split this into two rules to avoid ReDoS checker issues with nested optional segments. # This rule handles the case where there is a session ID in the plan file path diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts index 24612bbb09..b9584062bc 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts @@ -339,4 +339,61 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => { const envIndex = args.indexOf(`${includeDir}/.env`); expect(args[envIndex - 2]).toBe('--bind'); }); + + it('binds git worktree directories if present', async () => { + const worktreeGitDir = '/path/to/worktree/.git'; + const mainGitDir = '/path/to/main/.git'; + + const args = await buildBwrapArgs({ + ...defaultOptions, + resolvedPaths: createResolvedPaths({ + gitWorktree: { + worktreeGitDir, + mainGitDir, + }, + }), + }); + + expect(args).toContain(worktreeGitDir); + expect(args).toContain(mainGitDir); + expect(args[args.indexOf(worktreeGitDir) - 1]).toBe('--ro-bind-try'); + expect(args[args.indexOf(mainGitDir) - 1]).toBe('--ro-bind-try'); + }); + + it('enforces read-only binding for git worktrees even if workspaceWrite is true', async () => { + const worktreeGitDir = '/path/to/worktree/.git'; + + const args = await buildBwrapArgs({ + ...defaultOptions, + workspaceWrite: true, + resolvedPaths: createResolvedPaths({ + gitWorktree: { + worktreeGitDir, + }, + }), + }); + + expect(args[args.indexOf(worktreeGitDir) - 1]).toBe('--ro-bind-try'); + }); + + it('git worktree read-only bindings should override previous policyWrite bindings', async () => { + const worktreeGitDir = '/custom/worktree/.git'; + + const args = await buildBwrapArgs({ + ...defaultOptions, + resolvedPaths: createResolvedPaths({ + policyWrite: ['/custom/worktree'], + gitWorktree: { + worktreeGitDir, + }, + }), + }); + + const writeBindIndex = args.indexOf('/custom/worktree'); + const worktreeBindIndex = args.lastIndexOf(worktreeGitDir); + + expect(writeBindIndex).toBeGreaterThan(-1); + expect(worktreeBindIndex).toBeGreaterThan(-1); + expect(worktreeBindIndex).toBeGreaterThan(writeBindIndex); + }); }); diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts index d7172b648e..d7fec044e3 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts @@ -11,7 +11,7 @@ import { getSecretFileFindArgs, type ResolvedSandboxPaths, } from '../../services/sandboxManager.js'; -import { resolveGitWorktreePaths, isErrnoException } from '../utils/fsUtils.js'; +import { isErrnoException } from '../utils/fsUtils.js'; import { spawnAsync } from '../../utils/shell-utils.js'; import { debugLogger } from '../../utils/debugLogger.js'; @@ -70,16 +70,6 @@ export async function buildBwrapArgs( bwrapArgs.push(bindFlag, workspace.resolved, workspace.resolved); } - const { worktreeGitDir, mainGitDir } = resolveGitWorktreePaths( - workspace.resolved, - ); - if (worktreeGitDir) { - bwrapArgs.push(bindFlag, worktreeGitDir, worktreeGitDir); - } - if (mainGitDir) { - bwrapArgs.push(bindFlag, mainGitDir, mainGitDir); - } - for (const includeDir of resolvedPaths.globalIncludes) { bwrapArgs.push('--ro-bind-try', includeDir, includeDir); } @@ -113,6 +103,18 @@ export async function buildBwrapArgs( } } + // Grant read-only access to git worktrees/submodules. We do this last in order to + // ensure that these rules aren't overwritten by broader write policies. + if (resolvedPaths.gitWorktree) { + const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree; + if (worktreeGitDir) { + bwrapArgs.push('--ro-bind-try', worktreeGitDir, worktreeGitDir); + } + if (mainGitDir) { + bwrapArgs.push('--ro-bind-try', mainGitDir, mainGitDir); + } + } + for (const p of resolvedPaths.forbidden) { if (!fs.existsSync(p)) continue; try { diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts index 19ba8303ae..e8801b055b 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts @@ -142,5 +142,62 @@ describe.skipIf(os.platform() === 'win32')('seatbeltArgsBuilder', () => { expect(denyIndex).toBeGreaterThan(allowIndex); }); }); + + describe('git worktree paths', () => { + it('enforces read-only binding for git worktrees even if workspaceWrite is true', () => { + const worktreeGitDir = '/path/to/worktree/.git'; + const mainGitDir = '/path/to/main/.git'; + + const profile = buildSeatbeltProfile({ + resolvedPaths: { + ...defaultResolvedPaths, + gitWorktree: { + worktreeGitDir, + mainGitDir, + }, + }, + workspaceWrite: true, + }); + + // Should grant read access + expect(profile).toContain( + `(allow file-read* (subpath "${worktreeGitDir}"))`, + ); + expect(profile).toContain( + `(allow file-read* (subpath "${mainGitDir}"))`, + ); + + // Should NOT grant write access + expect(profile).not.toContain( + `(allow file-read* file-write* (subpath "${worktreeGitDir}"))`, + ); + expect(profile).not.toContain( + `(allow file-read* file-write* (subpath "${mainGitDir}"))`, + ); + }); + + it('git worktree read-only rules should override previous policyAllowed write paths', () => { + const worktreeGitDir = '/custom/worktree/.git'; + const profile = buildSeatbeltProfile({ + resolvedPaths: { + ...defaultResolvedPaths, + policyAllowed: ['/custom/worktree'], + gitWorktree: { + worktreeGitDir, + }, + }, + }); + + const allowString = `(allow file-read* file-write* (subpath "/custom/worktree"))`; + const denyString = `(deny file-write* (subpath "${worktreeGitDir}"))`; + + expect(profile).toContain(allowString); + expect(profile).toContain(denyString); + + const allowIndex = profile.indexOf(allowString); + const denyIndex = profile.indexOf(denyString); + expect(denyIndex).toBeGreaterThan(allowIndex); + }); + }); }); }); diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts index 967cd8f183..abbf1a6d92 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -16,7 +16,7 @@ import { SECRET_FILES, type ResolvedSandboxPaths, } from '../../services/sandboxManager.js'; -import { tryRealpath, resolveGitWorktreePaths } from '../utils/fsUtils.js'; +import { resolveToRealPath } from '../../utils/paths.js'; /** * Options for building macOS Seatbelt profile. @@ -52,21 +52,21 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { profile += `(allow file-write* (subpath "${escapeSchemeString(resolvedPaths.workspace.resolved)}"))\n`; } - const tmpPath = tryRealpath(os.tmpdir()); + const tmpPath = resolveToRealPath(os.tmpdir()); profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(tmpPath)}"))\n`; - // Auto-detect and support git worktrees by granting read and write access to the underlying git directory - const { worktreeGitDir, mainGitDir } = resolveGitWorktreePaths( - resolvedPaths.workspace.resolved, - ); - if (worktreeGitDir) { - profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(worktreeGitDir)}"))\n`; - } - if (mainGitDir) { - profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(mainGitDir)}"))\n`; + // Support git worktrees/submodules; read-only to prevent malicious hook/config modification (RCE). + if (resolvedPaths.gitWorktree) { + const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree; + if (worktreeGitDir) { + profile += `(allow file-read* (subpath "${escapeSchemeString(worktreeGitDir)}"))\n`; + } + if (mainGitDir) { + profile += `(allow file-read* (subpath "${escapeSchemeString(mainGitDir)}"))\n`; + } } - const nodeRootPath = tryRealpath( + const nodeRootPath = resolveToRealPath( path.dirname(path.dirname(process.execPath)), ); profile += `(allow file-read* (subpath "${escapeSchemeString(nodeRootPath)}"))\n`; @@ -79,7 +79,7 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { for (const p of paths) { if (!p.trim()) continue; try { - let resolved = tryRealpath(p); + let resolved = resolveToRealPath(p); // If this is a 'bin' directory (like /usr/local/bin or homebrew/bin), // also grant read access to its parent directory so that symlinked @@ -148,7 +148,7 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { resolvedPaths.workspace.resolved, GOVERNANCE_FILES[i].path, ); - const realGovernanceFile = tryRealpath(governanceFile); + const realGovernanceFile = resolveToRealPath(governanceFile); // Determine if it should be treated as a directory (subpath) or a file (literal). // .git is generally a directory, while ignore files are literals. @@ -170,6 +170,18 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { } } + // Grant read-only access to git worktrees/submodules. We do this last in order to + // ensure that these rules aren't overwritten by broader write policies. + if (resolvedPaths.gitWorktree) { + const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree; + if (worktreeGitDir) { + profile += `(deny file-write* (subpath "${escapeSchemeString(worktreeGitDir)}"))\n`; + } + if (mainGitDir) { + profile += `(deny file-write* (subpath "${escapeSchemeString(mainGitDir)}"))\n`; + } + } + // Add explicit deny rules for secret files (.env, .env.*) in the workspace and allowed paths. // We use regex rules to avoid expensive file discovery scans. // Anchoring to workspace/allowed paths to avoid over-blocking. diff --git a/packages/core/src/sandbox/utils/fsUtils.test.ts b/packages/core/src/sandbox/utils/fsUtils.test.ts index 9439050680..460fb9d26b 100644 --- a/packages/core/src/sandbox/utils/fsUtils.test.ts +++ b/packages/core/src/sandbox/utils/fsUtils.test.ts @@ -4,49 +4,117 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import fs from 'node:fs'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import fsPromises from 'node:fs/promises'; import path from 'node:path'; -import os from 'node:os'; -import { tryRealpath } from './fsUtils.js'; +import { resolveGitWorktreePaths } from './fsUtils.js'; + +vi.mock('node:fs/promises', async () => { + const actual = + await vi.importActual( + 'node:fs/promises', + ); + return { + ...actual, + default: { + ...actual, + lstat: vi.fn(), + readFile: vi.fn(), + }, + lstat: vi.fn(), + readFile: vi.fn(), + }; +}); + +vi.mock('../../utils/paths.js', async () => { + const actual = await vi.importActual( + '../../utils/paths.js', + ); + return { + ...actual, + resolveToRealPath: vi.fn((p) => p), + }; +}); describe('fsUtils', () => { - let tempDir: string; - let realTempDir: string; + describe('resolveGitWorktreePaths', () => { + const workspace = path.resolve('/workspace'); + const gitPath = path.join(workspace, '.git'); - beforeAll(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fs-utils-test-')); - realTempDir = fs.realpathSync(tempDir); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - afterAll(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - describe('tryRealpath', () => { - it('should throw error for paths with null bytes', () => { - expect(() => tryRealpath(path.join(tempDir, 'foo\0bar'))).toThrow( - 'Invalid path', + it('should return empty if .git does not exist', async () => { + vi.mocked(fsPromises.lstat).mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) as never, ); + const result = await resolveGitWorktreePaths(workspace); + expect(result).toEqual({}); }); - it('should resolve existing paths', () => { - const resolved = tryRealpath(tempDir); - expect(resolved).toBe(realTempDir); + it('should return empty if .git is a directory', async () => { + vi.mocked(fsPromises.lstat).mockResolvedValue({ + isFile: () => false, + } as never); + const result = await resolveGitWorktreePaths(workspace); + expect(result).toEqual({}); }); - it('should handle non-existent paths by resolving parent', () => { - const nonExistentPath = path.join(tempDir, 'non-existent-file-12345'); - const expected = path.join(realTempDir, 'non-existent-file-12345'); - const resolved = tryRealpath(nonExistentPath); - expect(resolved).toBe(expected); + it('should resolve worktree paths from .git file', async () => { + const mainGitDir = path.resolve('/project/.git'); + const worktreeGitDir = path.join(mainGitDir, 'worktrees', 'feature'); + + vi.mocked(fsPromises.lstat).mockResolvedValue({ + isFile: () => true, + } as never); + vi.mocked(fsPromises.readFile).mockImplementation(((p: string) => { + if (p === gitPath) return Promise.resolve(`gitdir: ${worktreeGitDir}`); + if (p === path.join(worktreeGitDir, 'gitdir')) + return Promise.resolve(gitPath); + return Promise.reject(new Error('ENOENT')); + }) as never); + + const result = await resolveGitWorktreePaths(workspace); + expect(result).toEqual({ + worktreeGitDir, + mainGitDir, + }); }); - it('should handle nested non-existent paths', () => { - const nonExistentPath = path.join(tempDir, 'dir1', 'dir2', 'file'); - const expected = path.join(realTempDir, 'dir1', 'dir2', 'file'); - const resolved = tryRealpath(nonExistentPath); - expect(resolved).toBe(expected); + it('should reject worktree if backlink is missing or invalid', async () => { + const worktreeGitDir = path.resolve('/git/worktrees/feature'); + + vi.mocked(fsPromises.lstat).mockResolvedValue({ + isFile: () => true, + } as never); + vi.mocked(fsPromises.readFile).mockImplementation(((p: string) => { + if (p === gitPath) return Promise.resolve(`gitdir: ${worktreeGitDir}`); + return Promise.reject(new Error('ENOENT')); + }) as never); + + const result = await resolveGitWorktreePaths(workspace); + expect(result).toEqual({}); + }); + + it('should support submodules via config check', async () => { + const submoduleGitDir = path.resolve('/project/.git/modules/sub'); + + vi.mocked(fsPromises.lstat).mockResolvedValue({ + isFile: () => true, + } as never); + vi.mocked(fsPromises.readFile).mockImplementation(((p: string) => { + if (p === gitPath) return Promise.resolve(`gitdir: ${submoduleGitDir}`); + if (p === path.join(submoduleGitDir, 'config')) + return Promise.resolve(`[core]\n\tworktree = ${workspace}`); + return Promise.reject(new Error('ENOENT')); + }) as never); + + const result = await resolveGitWorktreePaths(workspace); + expect(result).toEqual({ + worktreeGitDir: submoduleGitDir, + mainGitDir: path.resolve('/project/.git'), + }); }); }); }); diff --git a/packages/core/src/sandbox/utils/fsUtils.ts b/packages/core/src/sandbox/utils/fsUtils.ts index 2e3eda1342..c9729caf26 100644 --- a/packages/core/src/sandbox/utils/fsUtils.ts +++ b/packages/core/src/sandbox/utils/fsUtils.ts @@ -4,68 +4,55 @@ * SPDX-License-Identifier: Apache-2.0 */ -import fs from 'node:fs'; +import fs from 'node:fs/promises'; import path from 'node:path'; -import { assertValidPathString } from '../../utils/paths.js'; +import { resolveToRealPath } from '../../utils/paths.js'; export function isErrnoException(e: unknown): e is NodeJS.ErrnoException { return e instanceof Error && 'code' in e; } -export function tryRealpath(p: string): string { - assertValidPathString(p); - try { - return fs.realpathSync(p); - } catch (e) { - if (isErrnoException(e) && e.code === 'ENOENT') { - const parentDir = path.dirname(p); - if (parentDir === p) { - return p; - } - return path.join(tryRealpath(parentDir), path.basename(p)); - } - throw e; - } -} - -export function resolveGitWorktreePaths(workspacePath: string): { +export async function resolveGitWorktreePaths(workspacePath: string): Promise<{ worktreeGitDir?: string; mainGitDir?: string; -} { +}> { try { const gitPath = path.join(workspacePath, '.git'); - const gitStat = fs.lstatSync(gitPath); + const gitStat = await fs.lstat(gitPath); if (gitStat.isFile()) { - const gitContent = fs.readFileSync(gitPath, 'utf8'); + const gitContent = await fs.readFile(gitPath, 'utf8'); const match = gitContent.match(/^gitdir:\s+(.+)$/m); if (match && match[1]) { let worktreeGitDir = match[1].trim(); if (!path.isAbsolute(worktreeGitDir)) { worktreeGitDir = path.resolve(workspacePath, worktreeGitDir); } - const resolvedWorktreeGitDir = tryRealpath(worktreeGitDir); + const resolvedWorktreeGitDir = resolveToRealPath(worktreeGitDir); // Security check: Verify the bidirectional link to prevent sandbox escape let isValid = false; try { const backlinkPath = path.join(resolvedWorktreeGitDir, 'gitdir'); - const backlink = fs.readFileSync(backlinkPath, 'utf8').trim(); + const backlink = (await fs.readFile(backlinkPath, 'utf8')).trim(); // The backlink must resolve to the workspace's .git file - if (tryRealpath(backlink) === tryRealpath(gitPath)) { + if (resolveToRealPath(backlink) === resolveToRealPath(gitPath)) { isValid = true; } } catch { // Fallback for submodules: check core.worktree in config try { const configPath = path.join(resolvedWorktreeGitDir, 'config'); - const config = fs.readFileSync(configPath, 'utf8'); + const config = await fs.readFile(configPath, 'utf8'); const match = config.match(/^\s*worktree\s*=\s*(.+)$/m); if (match && match[1]) { const worktreePath = path.resolve( resolvedWorktreeGitDir, match[1].trim(), ); - if (tryRealpath(worktreePath) === tryRealpath(workspacePath)) { + if ( + resolveToRealPath(worktreePath) === + resolveToRealPath(workspacePath) + ) { isValid = true; } } @@ -78,7 +65,7 @@ export function resolveGitWorktreePaths(workspacePath: string): { return {}; // Reject: valid worktrees/submodules must have a readable backlink } - const mainGitDir = tryRealpath( + const mainGitDir = resolveToRealPath( path.dirname(path.dirname(resolvedWorktreeGitDir)), ); return { diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts index 40902b9121..b504d92f72 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts @@ -10,6 +10,7 @@ import os from 'node:os'; import path from 'node:path'; import { WindowsSandboxManager } from './WindowsSandboxManager.js'; import * as sandboxManager from '../../services/sandboxManager.js'; +import * as paths from '../../utils/paths.js'; import type { SandboxRequest } from '../../services/sandboxManager.js'; import { spawnAsync } from '../../utils/shell-utils.js'; import type { SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; @@ -44,9 +45,7 @@ describe('WindowsSandboxManager', () => { beforeEach(() => { vi.spyOn(os, 'platform').mockReturnValue('win32'); - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => - p.toString(), - ); + vi.spyOn(paths, 'resolveToRealPath').mockImplementation((p) => p); // Mock existsSync to skip the csc.exe auto-compilation of helper during unit tests. const originalExistsSync = fs.existsSync; @@ -299,6 +298,60 @@ describe('WindowsSandboxManager', () => { } }); + it('should NOT grant Low Integrity access to git worktree paths (enforce read-only)', async () => { + const worktreeGitDir = createTempDir('worktree-git'); + const mainGitDir = createTempDir('main-git'); + + try { + vi.spyOn(sandboxManager, 'resolveSandboxPaths').mockResolvedValue({ + workspace: { original: testCwd, resolved: testCwd }, + forbidden: [], + globalIncludes: [], + policyAllowed: [], + policyRead: [], + policyWrite: [], + gitWorktree: { + worktreeGitDir, + mainGitDir, + }, + }); + + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + }; + + await manager.prepareCommand(req); + + const icaclsArgs = vi + .mocked(spawnAsync) + .mock.calls.filter((c) => c[0] === 'icacls') + .map((c) => c[1]); + + // Verify that no icacls grants were issued for the git directories + expect(icaclsArgs).not.toContainEqual([ + worktreeGitDir, + '/grant', + '*S-1-16-4096:(OI)(CI)(M)', + '/setintegritylevel', + '(OI)(CI)Low', + ]); + + expect(icaclsArgs).not.toContainEqual([ + mainGitDir, + '/grant', + '*S-1-16-4096:(OI)(CI)(M)', + '/setintegritylevel', + '(OI)(CI)Low', + ]); + } finally { + fs.rmSync(worktreeGitDir, { recursive: true, force: true }); + fs.rmSync(mainGitDir, { recursive: true, force: true }); + } + }); + it('should grant Low Integrity access to additional write paths', async () => { const extraWritePath = createTempDir('extra-write'); try { diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index 86d1eda641..2cf736f865 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -299,7 +299,9 @@ export class WindowsSandboxManager implements SandboxManager { ) : false; - if (!isReadonlyMode || isApproved) { + const workspaceWrite = !isReadonlyMode || isApproved || isYolo; + + if (workspaceWrite) { await this.grantLowIntegrityAccess(resolvedPaths.workspace.resolved); writableRoots.push(resolvedPaths.workspace.resolved); } @@ -345,6 +347,12 @@ export class WindowsSandboxManager implements SandboxManager { } } + // Support git worktrees/submodules; read-only to prevent malicious hook/config modification (RCE). + // Read access is inherited; skip grantLowIntegrityAccess to ensure write protection. + if (resolvedPaths.gitWorktree) { + // No-op for read access. + } + // 2. Collect secret files and apply protective ACLs // On Windows, we explicitly deny access to secret files for Low Integrity // processes to ensure they cannot be read or written. diff --git a/packages/core/src/scheduler/scheduler.test.ts b/packages/core/src/scheduler/scheduler.test.ts index e0fe7b873c..aaa5d48f5d 100644 --- a/packages/core/src/scheduler/scheduler.test.ts +++ b/packages/core/src/scheduler/scheduler.test.ts @@ -49,6 +49,7 @@ import { resolveConfirmation } from './confirmation.js'; import { checkPolicy, updatePolicy } from './policy.js'; import { ToolExecutor } from './tool-executor.js'; import { ToolModificationHandler } from './tool-modifier.js'; +import { MessageBusType, type Message } from '../confirmation-bus/types.js'; vi.mock('./state-manager.js'); vi.mock('./confirmation.js'); @@ -1299,6 +1300,64 @@ describe('Scheduler (Orchestrator)', () => { }); }); + describe('Fallback Handlers', () => { + it('should respond to TOOL_CONFIRMATION_REQUEST with requiresUserConfirmation: true', async () => { + const listeners: Record< + string, + Array<(message: Message) => void | Promise> + > = {}; + + const mockBus = { + subscribe: vi.fn( + ( + type: string, + handler: (message: Message) => void | Promise, + ) => { + listeners[type] = listeners[type] || []; + listeners[type].push(handler); + }, + ), + publish: vi.fn(async (message: Message) => { + const type = message.type as string; + if (listeners[type]) { + for (const handler of listeners[type]) { + await handler(message); + } + } + }), + } as unknown as MessageBus; + + const scheduler = new Scheduler({ + context: mockConfig, + messageBus: mockBus, + getPreferredEditor, + schedulerId: 'fallback-test', + }); + + const handler = vi.fn(); + mockBus.subscribe(MessageBusType.TOOL_CONFIRMATION_RESPONSE, handler); + + await mockBus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_REQUEST, + correlationId: 'test-correlation-id', + toolCall: { name: 'test-tool' }, + }); + + // Wait for async handler to fire + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: 'test-correlation-id', + confirmed: false, + requiresUserConfirmation: true, + }), + ); + + scheduler.dispose(); + }); + }); + describe('Cleanup', () => { it('should unregister McpProgress listener on dispose()', () => { const onSpy = vi.spyOn(coreEvents, 'on'); @@ -1323,6 +1382,40 @@ describe('Scheduler (Orchestrator)', () => { expect.any(Function), ); }); + + it('should abort disposeController signal on dispose()', () => { + const mockSubscribe = + vi.fn< + ( + type: unknown, + listener: unknown, + options?: { signal?: AbortSignal }, + ) => void + >(); + const mockBus = { + subscribe: mockSubscribe, + publish: vi.fn(), + } as unknown as MessageBus; + + let capturedSignal: AbortSignal | undefined; + mockSubscribe.mockImplementation((type, listener, options) => { + capturedSignal = options?.signal; + }); + + const s = new Scheduler({ + context: mockConfig, + messageBus: mockBus, + getPreferredEditor, + schedulerId: 'cleanup-test-2', + }); + + expect(capturedSignal).toBeDefined(); + expect(capturedSignal?.aborted).toBe(false); + + s.dispose(); + + expect(capturedSignal?.aborted).toBe(true); + }); }); }); diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index 2f95748597..fef22968e1 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -93,8 +93,7 @@ const createErrorResponse = ( * Coordinates execution via state updates and event listening. */ export class Scheduler { - // Tracks which MessageBus instances have the legacy listener attached to prevent duplicates. - private static subscribedMessageBuses = new WeakSet(); + private readonly disposeController = new AbortController(); private readonly state: SchedulerStateManager; private readonly executor: ToolExecutor; @@ -136,6 +135,7 @@ export class Scheduler { dispose(): void { coreEvents.off(CoreEvent.McpProgress, this.handleMcpProgress); + this.disposeController.abort(); } private readonly handleMcpProgress = (payload: McpProgressPayload) => { @@ -163,26 +163,25 @@ export class Scheduler { }); }; - private setupMessageBusListener(messageBus: MessageBus): void { - if (Scheduler.subscribedMessageBuses.has(messageBus)) { - return; - } + private readonly handleToolConfirmationRequest = async ( + request: ToolConfirmationRequest, + ) => { + await this.messageBus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: request.correlationId, + confirmed: false, + requiresUserConfirmation: true, + }); + }; + private setupMessageBusListener(messageBus: MessageBus): void { // TODO: Optimize policy checks. Currently, tools check policy via // MessageBus even though the Scheduler already checked it. messageBus.subscribe( MessageBusType.TOOL_CONFIRMATION_REQUEST, - async (request: ToolConfirmationRequest) => { - await messageBus.publish({ - type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, - correlationId: request.correlationId, - confirmed: false, - requiresUserConfirmation: true, - }); - }, + this.handleToolConfirmationRequest, + { signal: this.disposeController.signal }, ); - - Scheduler.subscribedMessageBuses.add(messageBus); } /** diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index d542b8c7cb..22ba6c2c03 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -5,11 +5,42 @@ */ import { expect, it, describe, vi, beforeEach, afterEach } from 'vitest'; -import fs from 'node:fs'; +import * as fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + const fsModule = { + ...actual, + mkdirSync: vi.fn(actual.mkdirSync), + appendFileSync: vi.fn(actual.appendFileSync), + writeFileSync: vi.fn(actual.writeFileSync), + readFileSync: vi.fn(actual.readFileSync), + unlinkSync: vi.fn(actual.unlinkSync), + existsSync: vi.fn(actual.existsSync), + readdirSync: vi.fn(actual.readdirSync), + promises: { + ...actual.promises, + stat: vi.fn(actual.promises.stat), + readFile: vi.fn(actual.promises.readFile), + unlink: vi.fn(actual.promises.unlink), + readdir: vi.fn(actual.promises.readdir), + open: vi.fn(actual.promises.open), + rm: vi.fn(actual.promises.rm), + mkdir: vi.fn(actual.promises.mkdir), + writeFile: vi.fn(actual.promises.writeFile), + }, + }; + return { + ...fsModule, + default: fsModule, + }; +}); + import { ChatRecordingService, + loadConversationRecord, type ConversationRecord, type ToolCallRecord, type MessageRecord, @@ -21,9 +52,11 @@ import type { Config } from '../config/config.js'; import { getProjectHash } from '../utils/paths.js'; vi.mock('../utils/paths.js'); -vi.mock('node:crypto', () => { +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); let count = 0; return { + ...actual, randomUUID: vi.fn(() => `test-uuid-${count++}`), createHash: vi.fn(() => ({ update: vi.fn(() => ({ @@ -38,6 +71,9 @@ describe('ChatRecordingService', () => { let mockConfig: Config; let testTempDir: string; + afterEach(() => { + vi.restoreAllMocks(); + }); beforeEach(async () => { testTempDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'chat-recording-test-'), @@ -89,8 +125,8 @@ describe('ChatRecordingService', () => { }); describe('initialize', () => { - it('should create a new session if none is provided', () => { - chatRecordingService.initialize(); + it('should create a new session if none is provided', async () => { + await chatRecordingService.initialize(); chatRecordingService.recordMessage({ type: 'user', content: 'ping', @@ -101,11 +137,11 @@ describe('ChatRecordingService', () => { expect(fs.existsSync(chatsDir)).toBe(true); const files = fs.readdirSync(chatsDir); expect(files.length).toBeGreaterThan(0); - expect(files[0]).toMatch(/^session-.*-test-ses\.json$/); + expect(files[0]).toMatch(/^session-.*-test-ses\.jsonl$/); }); - it('should include the conversation kind when specified', () => { - chatRecordingService.initialize(undefined, 'subagent'); + it('should include the conversation kind when specified', async () => { + await chatRecordingService.initialize(undefined, 'subagent'); chatRecordingService.recordMessage({ type: 'user', content: 'ping', @@ -113,13 +149,13 @@ describe('ChatRecordingService', () => { }); const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; expect(conversation.kind).toBe('subagent'); }); - it('should create a subdirectory for subagents if parentSessionId is present', () => { + it('should create a subdirectory for subagents if parentSessionId is present', async () => { const parentSessionId = 'test-parent-uuid'; Object.defineProperty(mockConfig, 'parentSessionId', { value: parentSessionId, @@ -127,7 +163,7 @@ describe('ChatRecordingService', () => { configurable: true, }); - chatRecordingService.initialize(undefined, 'subagent'); + await chatRecordingService.initialize(undefined, 'subagent'); chatRecordingService.recordMessage({ type: 'user', content: 'ping', @@ -140,19 +176,19 @@ describe('ChatRecordingService', () => { const files = fs.readdirSync(subagentDir); expect(files.length).toBeGreaterThan(0); - expect(files[0]).toBe('test-session-id.json'); + expect(files[0]).toBe('test-session-id.jsonl'); }); - it('should inherit workspace directories for subagents during initialization', () => { + it('should inherit workspace directories for subagents during initialization', async () => { const mockDirectories = ['/project/dir1', '/project/dir2']; vi.mocked(mockConfig.getWorkspaceContext).mockReturnValue({ getDirectories: vi.fn().mockReturnValue(mockDirectories), } as unknown as WorkspaceContext); // Initialize as a subagent - chatRecordingService.initialize(undefined, 'subagent'); + await chatRecordingService.initialize(undefined, 'subagent'); - // Recording a message triggers the disk write (deferred until then) + // Recording a message triggers the disk write chatRecordingService.recordMessage({ type: 'user', content: 'ping', @@ -160,43 +196,53 @@ describe('ChatRecordingService', () => { }); const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; expect(conversation.kind).toBe('subagent'); expect(conversation.directories).toEqual(mockDirectories); }); - it('should resume from an existing session if provided', () => { + it('should resume from an existing session if provided', async () => { const chatsDir = path.join(testTempDir, 'chats'); fs.mkdirSync(chatsDir, { recursive: true }); - const sessionFile = path.join(chatsDir, 'session.json'); + const sessionFile = path.join(chatsDir, 'session.jsonl'); const initialData = { sessionId: 'old-session-id', projectHash: 'test-project-hash', messages: [], }; - fs.writeFileSync(sessionFile, JSON.stringify(initialData)); + fs.writeFileSync( + sessionFile, + JSON.stringify({ ...initialData, messages: undefined }) + + '\n' + + (initialData.messages || []) + .map((m: unknown) => JSON.stringify(m)) + .join('\n') + + '\n', + ); - chatRecordingService.initialize({ + await chatRecordingService.initialize({ filePath: sessionFile, conversation: { sessionId: 'old-session-id', } as ConversationRecord, }); - const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8')); + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; expect(conversation.sessionId).toBe('old-session-id'); }); }); describe('recordMessage', () => { - beforeEach(() => { - chatRecordingService.initialize(); + beforeEach(async () => { + await chatRecordingService.initialize(); }); - it('should record a new message', () => { + it('should record a new message', async () => { chatRecordingService.recordMessage({ type: 'user', content: 'Hello', @@ -205,9 +251,9 @@ describe('ChatRecordingService', () => { }); const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; expect(conversation.messages).toHaveLength(1); expect(conversation.messages[0].content).toBe('Hello'); @@ -215,7 +261,7 @@ describe('ChatRecordingService', () => { expect(conversation.messages[0].type).toBe('user'); }); - it('should create separate messages when recording multiple messages', () => { + it('should create separate messages when recording multiple messages', async () => { chatRecordingService.recordMessage({ type: 'user', content: 'World', @@ -223,17 +269,17 @@ describe('ChatRecordingService', () => { }); const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; expect(conversation.messages).toHaveLength(1); expect(conversation.messages[0].content).toBe('World'); }); }); describe('recordThought', () => { - it('should queue a thought', () => { - chatRecordingService.initialize(); + it('should queue a thought', async () => { + await chatRecordingService.initialize(); chatRecordingService.recordThought({ subject: 'Thinking', description: 'Thinking...', @@ -246,11 +292,11 @@ describe('ChatRecordingService', () => { }); describe('recordMessageTokens', () => { - beforeEach(() => { - chatRecordingService.initialize(); + beforeEach(async () => { + await chatRecordingService.initialize(); }); - it('should update the last message with token info', () => { + it('should update the last message with token info', async () => { chatRecordingService.recordMessage({ type: 'gemini', content: 'Response', @@ -265,9 +311,9 @@ describe('ChatRecordingService', () => { }); const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; const geminiMsg = conversation.messages[0] as MessageRecord & { type: 'gemini'; }; @@ -281,7 +327,7 @@ describe('ChatRecordingService', () => { }); }); - it('should queue token info if the last message already has tokens', () => { + it('should queue token info if the last message already has tokens', async () => { chatRecordingService.recordMessage({ type: 'gemini', content: 'Response', @@ -313,11 +359,11 @@ describe('ChatRecordingService', () => { }); }); - it('should not write to disk when queuing tokens (no last gemini message)', () => { - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); + it('should not write to disk when queuing tokens (no last gemini message)', async () => { + const appendFileSyncSpy = vi.mocked(fs.appendFileSync); // Clear spy call count after initialize writes the initial file - writeFileSyncSpy.mockClear(); + appendFileSyncSpy.mockClear(); // No gemini message recorded yet, so tokens should only be queued chatRecordingService.recordMessageTokens({ @@ -328,7 +374,7 @@ describe('ChatRecordingService', () => { }); // writeFileSync should NOT have been called since we only queued - expect(writeFileSyncSpy).not.toHaveBeenCalled(); + expect(appendFileSyncSpy).not.toHaveBeenCalled(); // @ts-expect-error private property expect(chatRecordingService.queuedTokens).toEqual({ @@ -339,11 +385,9 @@ describe('ChatRecordingService', () => { thoughts: 0, tool: 0, }); - - writeFileSyncSpy.mockRestore(); }); - it('should not write to disk when queuing tokens (last message already has tokens)', () => { + it('should not write to disk when queuing tokens (last message already has tokens)', async () => { chatRecordingService.recordMessage({ type: 'gemini', content: 'Response', @@ -358,8 +402,8 @@ describe('ChatRecordingService', () => { cachedContentTokenCount: 0, }); - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); - writeFileSyncSpy.mockClear(); + const appendFileSyncSpy = vi.mocked(fs.appendFileSync); + appendFileSyncSpy.mockClear(); // Second call should only queue, NOT write to disk chatRecordingService.recordMessageTokens({ @@ -369,18 +413,17 @@ describe('ChatRecordingService', () => { cachedContentTokenCount: 0, }); - expect(writeFileSyncSpy).not.toHaveBeenCalled(); - writeFileSyncSpy.mockRestore(); + expect(appendFileSyncSpy).not.toHaveBeenCalled(); }); - it('should use in-memory cache and not re-read from disk on subsequent operations', () => { + it('should use in-memory cache and not re-read from disk on subsequent operations', async () => { chatRecordingService.recordMessage({ type: 'gemini', content: 'Response', model: 'gemini-pro', }); - const readFileSyncSpy = vi.spyOn(fs, 'readFileSync'); + const readFileSyncSpy = vi.mocked(fs.readFileSync); readFileSyncSpy.mockClear(); // These operations should all use the in-memory cache @@ -401,16 +444,15 @@ describe('ChatRecordingService', () => { // readFileSync should NOT have been called since we use the in-memory cache expect(readFileSyncSpy).not.toHaveBeenCalled(); - readFileSyncSpy.mockRestore(); }); }); describe('recordToolCalls', () => { - beforeEach(() => { - chatRecordingService.initialize(); + beforeEach(async () => { + await chatRecordingService.initialize(); }); - it('should add new tool calls to the last message', () => { + it('should add new tool calls to the last message', async () => { chatRecordingService.recordMessage({ type: 'gemini', content: '', @@ -427,9 +469,9 @@ describe('ChatRecordingService', () => { chatRecordingService.recordToolCalls('gemini-pro', [toolCall]); const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; const geminiMsg = conversation.messages[0] as MessageRecord & { type: 'gemini'; }; @@ -437,7 +479,7 @@ describe('ChatRecordingService', () => { expect(geminiMsg.toolCalls![0].name).toBe('testTool'); }); - it('should preserve dynamic description and NOT overwrite with generic one', () => { + it('should preserve dynamic description and NOT overwrite with generic one', async () => { chatRecordingService.recordMessage({ type: 'gemini', content: '', @@ -457,9 +499,9 @@ describe('ChatRecordingService', () => { chatRecordingService.recordToolCalls('gemini-pro', [toolCall]); const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; const geminiMsg = conversation.messages[0] as MessageRecord & { type: 'gemini'; }; @@ -467,7 +509,7 @@ describe('ChatRecordingService', () => { expect(geminiMsg.toolCalls![0].description).toBe(dynamicDescription); }); - it('should create a new message if the last message is not from gemini', () => { + it('should create a new message if the last message is not from gemini', async () => { chatRecordingService.recordMessage({ type: 'user', content: 'call a tool', @@ -484,9 +526,9 @@ describe('ChatRecordingService', () => { chatRecordingService.recordToolCalls('gemini-pro', [toolCall]); const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; expect(conversation.messages).toHaveLength(2); expect(conversation.messages[1].type).toBe('gemini'); expect( @@ -513,9 +555,9 @@ describe('ChatRecordingService', () => { // Create main session file with timestamp const sessionFile = path.join( chatsDir, - `session-2023-01-01T00-00-${shortId}.json`, + `session-2023-01-01T00-00-${shortId}.jsonl`, ); - fs.writeFileSync(sessionFile, JSON.stringify({ sessionId })); + fs.writeFileSync(sessionFile, JSON.stringify({ sessionId }) + '\n'); const logFile = path.join(logsDir, `session-${sessionId}.jsonl`); fs.writeFileSync(logFile, '{}'); @@ -547,20 +589,21 @@ describe('ChatRecordingService', () => { // Create parent session file const parentFile = path.join( chatsDir, - `session-2023-01-01T00-00-${shortId}.json`, + `session-2023-01-01T00-00-${shortId}.jsonl`, ); fs.writeFileSync( parentFile, - JSON.stringify({ sessionId: parentSessionId }), + JSON.stringify({ sessionId: parentSessionId }) + '\n', ); // Create subagent session file in subdirectory const subagentDir = path.join(chatsDir, parentSessionId); fs.mkdirSync(subagentDir, { recursive: true }); - const subagentFile = path.join(subagentDir, `${subagentSessionId}.json`); + const subagentFile = path.join(subagentDir, `${subagentSessionId}.jsonl`); fs.writeFileSync( subagentFile, - JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }), + JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }) + + '\n', ); // Create logs for both @@ -609,21 +652,22 @@ describe('ChatRecordingService', () => { // Create parent session file const parentFile = path.join( chatsDir, - `session-2023-01-01T00-00-${shortId}.json`, + `session-2023-01-01T00-00-${shortId}.jsonl`, ); fs.writeFileSync( parentFile, - JSON.stringify({ sessionId: parentSessionId }), + JSON.stringify({ sessionId: parentSessionId }) + '\n', ); // Create legacy subagent session file (flat in chatsDir) const subagentFile = path.join( chatsDir, - `session-2023-01-01T00-01-${shortId}.json`, + `session-2023-01-01T00-01-${shortId}.jsonl`, ); fs.writeFileSync( subagentFile, - JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }), + JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }) + + '\n', ); // Call with parent sessionId @@ -643,8 +687,8 @@ describe('ChatRecordingService', () => { fs.mkdirSync(logsDir, { recursive: true }); const basename = `session-2023-01-01T00-00-${shortId}`; - const sessionFile = path.join(chatsDir, `${basename}.json`); - fs.writeFileSync(sessionFile, JSON.stringify({ sessionId })); + const sessionFile = path.join(chatsDir, `${basename}.jsonl`); + fs.writeFileSync(sessionFile, JSON.stringify({ sessionId }) + '\n'); const logFile = path.join(logsDir, `session-${sessionId}.jsonl`); fs.writeFileSync(logFile, '{}'); @@ -664,11 +708,11 @@ describe('ChatRecordingService', () => { }); describe('recordDirectories', () => { - beforeEach(() => { - chatRecordingService.initialize(); + beforeEach(async () => { + await chatRecordingService.initialize(); }); - it('should save directories to the conversation', () => { + it('should save directories to the conversation', async () => { chatRecordingService.recordMessage({ type: 'user', content: 'ping', @@ -680,16 +724,16 @@ describe('ChatRecordingService', () => { ]); const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; expect(conversation.directories).toEqual([ '/path/to/dir1', '/path/to/dir2', ]); }); - it('should overwrite existing directories', () => { + it('should overwrite existing directories', async () => { chatRecordingService.recordMessage({ type: 'user', content: 'ping', @@ -699,16 +743,16 @@ describe('ChatRecordingService', () => { chatRecordingService.recordDirectories(['/new/dir1', '/new/dir2']); const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; expect(conversation.directories).toEqual(['/new/dir1', '/new/dir2']); }); }); describe('rewindTo', () => { - it('should rewind the conversation to a specific message ID', () => { - chatRecordingService.initialize(); + it('should rewind the conversation to a specific message ID', async () => { + await chatRecordingService.initialize(); // Record some messages chatRecordingService.recordMessage({ type: 'user', @@ -727,9 +771,9 @@ describe('ChatRecordingService', () => { }); const sessionFile = chatRecordingService.getConversationFilePath()!; - let conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + let conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; const secondMsgId = conversation.messages[1].id; const result = chatRecordingService.rewindTo(secondMsgId); @@ -738,14 +782,14 @@ describe('ChatRecordingService', () => { expect(result!.messages).toHaveLength(1); expect(result!.messages[0].content).toBe('msg1'); - conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; expect(conversation.messages).toHaveLength(1); }); - it('should return the original conversation if the message ID is not found', () => { - chatRecordingService.initialize(); + it('should return the original conversation if the message ID is not found', async () => { + await chatRecordingService.initialize(); chatRecordingService.recordMessage({ type: 'user', content: 'msg1', @@ -760,33 +804,31 @@ describe('ChatRecordingService', () => { }); describe('ENOSPC (disk full) graceful degradation - issue #16266', () => { - it('should disable recording and not throw when ENOSPC occurs during initialize', () => { + it('should disable recording and not throw when ENOSPC occurs during initialize', async () => { const enospcError = new Error('ENOSPC: no space left on device'); (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; - const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => { + const mkdirSyncSpy = vi.mocked(fs.mkdirSync).mockImplementation(() => { throw enospcError; }); // Should not throw - expect(() => chatRecordingService.initialize()).not.toThrow(); + await expect(chatRecordingService.initialize()).resolves.not.toThrow(); // Recording should be disabled (conversationFile set to null) expect(chatRecordingService.getConversationFilePath()).toBeNull(); mkdirSyncSpy.mockRestore(); }); - it('should disable recording and not throw when ENOSPC occurs during writeConversation', () => { - chatRecordingService.initialize(); + it('should disable recording and not throw when ENOSPC occurs during writeConversation', async () => { + await chatRecordingService.initialize(); const enospcError = new Error('ENOSPC: no space left on device'); (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => { - throw enospcError; - }); + vi.mocked(fs.appendFileSync).mockImplementation(() => { + throw enospcError; + }); // Should not throw when recording a message expect(() => @@ -799,17 +841,16 @@ describe('ChatRecordingService', () => { // Recording should be disabled (conversationFile set to null) expect(chatRecordingService.getConversationFilePath()).toBeNull(); - writeFileSyncSpy.mockRestore(); }); - it('should skip recording operations when recording is disabled', () => { - chatRecordingService.initialize(); + it('should skip recording operations when recording is disabled', async () => { + await chatRecordingService.initialize(); const enospcError = new Error('ENOSPC: no space left on device'); (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') + const appendFileSyncSpy = vi + .mocked(fs.appendFileSync) .mockImplementationOnce(() => { throw enospcError; }); @@ -821,7 +862,7 @@ describe('ChatRecordingService', () => { }); // Reset mock to track subsequent calls - writeFileSyncSpy.mockClear(); + appendFileSyncSpy.mockClear(); // Subsequent calls should be no-ops (not call writeFileSync) chatRecordingService.recordMessage({ @@ -838,21 +879,18 @@ describe('ChatRecordingService', () => { chatRecordingService.saveSummary('Test summary'); // writeFileSync should not have been called for any of these - expect(writeFileSyncSpy).not.toHaveBeenCalled(); - writeFileSyncSpy.mockRestore(); + expect(appendFileSyncSpy).not.toHaveBeenCalled(); }); - it('should return null from getConversation when recording is disabled', () => { - chatRecordingService.initialize(); + it('should return null from getConversation when recording is disabled', async () => { + await chatRecordingService.initialize(); const enospcError = new Error('ENOSPC: no space left on device'); (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => { - throw enospcError; - }); + vi.mocked(fs.appendFileSync).mockImplementation(() => { + throw enospcError; + }); // Trigger ENOSPC chatRecordingService.recordMessage({ @@ -864,20 +902,17 @@ describe('ChatRecordingService', () => { // getConversation should return null when disabled expect(chatRecordingService.getConversation()).toBeNull(); expect(chatRecordingService.getConversationFilePath()).toBeNull(); - writeFileSyncSpy.mockRestore(); }); - it('should still throw for non-ENOSPC errors', () => { - chatRecordingService.initialize(); + it('should still throw for non-ENOSPC errors', async () => { + await chatRecordingService.initialize(); const otherError = new Error('Permission denied'); (otherError as NodeJS.ErrnoException).code = 'EACCES'; - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => { - throw otherError; - }); + vi.mocked(fs.appendFileSync).mockImplementation(() => { + throw otherError; + }); // Should throw for non-ENOSPC errors expect(() => @@ -890,16 +925,15 @@ describe('ChatRecordingService', () => { // Recording should NOT be disabled for non-ENOSPC errors (file path still exists) expect(chatRecordingService.getConversationFilePath()).not.toBeNull(); - writeFileSyncSpy.mockRestore(); }); }); describe('updateMessagesFromHistory', () => { - beforeEach(() => { - chatRecordingService.initialize(); + beforeEach(async () => { + await chatRecordingService.initialize(); }); - it('should update tool results from API history (masking sync)', () => { + it('should update tool results from API history (masking sync)', async () => { // 1. Record an initial message and tool call chatRecordingService.recordMessage({ type: 'gemini', @@ -949,9 +983,9 @@ describe('ChatRecordingService', () => { // 4. Verify disk content const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; const geminiMsg = conversation.messages[0]; if (geminiMsg.type !== 'gemini') @@ -968,8 +1002,8 @@ describe('ChatRecordingService', () => { output: maskedSnippet, }); }); - it('should preserve multi-modal sibling parts during sync', () => { - chatRecordingService.initialize(); + it('should preserve multi-modal sibling parts during sync', async () => { + await chatRecordingService.initialize(); const callId = 'multi-modal-call'; const originalResult: Part[] = [ { @@ -1019,9 +1053,9 @@ describe('ChatRecordingService', () => { chatRecordingService.updateMessagesFromHistory(history); const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; const lastMsg = conversation.messages[0] as MessageRecord & { type: 'gemini'; @@ -1035,8 +1069,8 @@ describe('ChatRecordingService', () => { expect(result[1].inlineData!.mimeType).toBe('image/png'); }); - it('should handle parts appearing BEFORE the functionResponse in a content block', () => { - chatRecordingService.initialize(); + it('should handle parts appearing BEFORE the functionResponse in a content block', async () => { + await chatRecordingService.initialize(); const callId = 'prefix-part-call'; chatRecordingService.recordMessage({ @@ -1075,9 +1109,9 @@ describe('ChatRecordingService', () => { chatRecordingService.updateMessagesFromHistory(history); const sessionFile = chatRecordingService.getConversationFilePath()!; - const conversation = JSON.parse( - fs.readFileSync(sessionFile, 'utf8'), - ) as ConversationRecord; + const conversation = (await loadConversationRecord( + sessionFile, + )) as ConversationRecord; const lastMsg = conversation.messages[0] as MessageRecord & { type: 'gemini'; @@ -1088,15 +1122,15 @@ describe('ChatRecordingService', () => { expect(result[1].functionResponse!.id).toBe(callId); }); - it('should not write to disk when no tool calls match', () => { + it('should not write to disk when no tool calls match', async () => { chatRecordingService.recordMessage({ type: 'gemini', content: 'Response with no tool calls', model: 'gemini-pro', }); - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); - writeFileSyncSpy.mockClear(); + const appendFileSyncSpy = vi.mocked(fs.appendFileSync); + appendFileSyncSpy.mockClear(); // History with a tool call ID that doesn't exist in the conversation const history: Content[] = [ @@ -1117,17 +1151,16 @@ describe('ChatRecordingService', () => { chatRecordingService.updateMessagesFromHistory(history); // No tool calls matched, so writeFileSync should NOT have been called - expect(writeFileSyncSpy).not.toHaveBeenCalled(); - writeFileSyncSpy.mockRestore(); + expect(appendFileSyncSpy).not.toHaveBeenCalled(); }); }); describe('ENOENT (missing directory) handling', () => { - it('should ensure directory exists before writing conversation file', () => { - chatRecordingService.initialize(); + it('should ensure directory exists before writing conversation file', async () => { + await chatRecordingService.initialize(); - const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync'); - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); + const mkdirSyncSpy = vi.mocked(fs.mkdirSync); + const appendFileSyncSpy = vi.mocked(fs.appendFileSync); chatRecordingService.recordMessage({ type: 'user', @@ -1144,13 +1177,12 @@ describe('ChatRecordingService', () => { // mkdirSync should be called before writeFileSync const mkdirCallOrder = mkdirSyncSpy.mock.invocationCallOrder; - const writeCallOrder = writeFileSyncSpy.mock.invocationCallOrder; + const writeCallOrder = appendFileSyncSpy.mock.invocationCallOrder; const lastMkdir = mkdirCallOrder[mkdirCallOrder.length - 1]; const lastWrite = writeCallOrder[writeCallOrder.length - 1]; expect(lastMkdir).toBeLessThan(lastWrite); mkdirSyncSpy.mockRestore(); - writeFileSyncSpy.mockRestore(); }); }); }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 7cd63999ce..a63a820ec8 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -4,16 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type Status } from '../scheduler/types.js'; import { type ThoughtSummary } from '../utils/thoughtUtils.js'; import { getProjectHash } from '../utils/paths.js'; import path from 'node:path'; -import fs from 'node:fs'; +import * as fs from 'node:fs'; import { sanitizeFilenamePart } from '../utils/fileUtils.js'; +import { isNodeError } from '../utils/errors.js'; import { deleteSessionArtifactsAsync, deleteSubagentSessionDirAndArtifactsAsync, } from '../utils/sessionOperations.js'; +import readline from 'node:readline'; import { randomUUID } from 'node:crypto'; import type { Content, @@ -22,10 +23,21 @@ import type { GenerateContentResponseUsageMetadata, } from '@google/genai'; import { debugLogger } from '../utils/debugLogger.js'; -import type { ToolResultDisplay } from '../tools/tools.js'; import type { AgentLoopContext } from '../config/agent-loop-context.js'; - -export const SESSION_FILE_PREFIX = 'session-'; +import { + SESSION_FILE_PREFIX, + type TokensSummary, + type ToolCallRecord, + type ConversationRecordExtra, + type MessageRecord, + type ConversationRecord, + type ResumedSessionData, + type LoadConversationOptions, + type RewindRecord, + type MetadataUpdateRecord, + type PartialMetadataRecord, +} from './chatRecordingTypes.js'; +export * from './chatRecordingTypes.js'; /** * Warning message shown when recording is disabled due to disk full. @@ -35,109 +47,208 @@ const ENOSPC_WARNING_MESSAGE = 'The conversation will continue but will not be saved to disk. ' + 'Free up disk space and restart to enable recording.'; -/** - * Token usage summary for a message or conversation. - */ -export interface TokensSummary { - input: number; // promptTokenCount - output: number; // candidatesTokenCount - cached: number; // cachedContentTokenCount - thoughts?: number; // thoughtsTokenCount - tool?: number; // toolUsePromptTokenCount - total: number; // totalTokenCount +function hasProperty( + obj: unknown, + prop: T, +): obj is { [key in T]: unknown } { + return obj !== null && typeof obj === 'object' && prop in obj; } -/** - * Base fields common to all messages. - */ -export interface BaseMessageRecord { - id: string; - timestamp: string; - content: PartListUnion; - displayContent?: PartListUnion; +function isStringProperty( + obj: unknown, + prop: T, +): obj is { [key in T]: string } { + return hasProperty(obj, prop) && typeof obj[prop] === 'string'; } -/** - * Record of a tool call execution within a conversation. - */ -export interface ToolCallRecord { - id: string; - name: string; - args: Record; - result?: PartListUnion | null; - status: Status; - timestamp: string; - // UI-specific fields for display purposes - displayName?: string; - description?: string; - resultDisplay?: ToolResultDisplay; - renderOutputAsMarkdown?: boolean; - /** Experimental: original name of the tool requested by the model before unwrapping */ - originalRequestName?: string; - /** Experimental: original arguments of the tool requested by the model before unwrapping */ - originalRequestArgs?: Record; +function isObjectProperty( + obj: unknown, + prop: T, +): obj is { [key in T]: object } { + return ( + hasProperty(obj, prop) && + obj[prop] !== null && + typeof obj[prop] === 'object' + ); } -/** - * Message type and message type-specific fields. - */ -export type ConversationRecordExtra = - | { - type: 'user' | 'info' | 'error' | 'warning'; +function isRewindRecord(record: unknown): record is RewindRecord { + return isStringProperty(record, '$rewindTo'); +} + +function isMessageRecord(record: unknown): record is MessageRecord { + return isStringProperty(record, 'id'); +} + +function isMetadataUpdateRecord( + record: unknown, +): record is MetadataUpdateRecord { + return isObjectProperty(record, '$set'); +} + +function isPartialMetadataRecord( + record: unknown, +): record is PartialMetadataRecord { + return ( + isStringProperty(record, 'sessionId') && + isStringProperty(record, 'projectHash') + ); +} + +function isTextPart(part: unknown): part is { text: string } { + return isStringProperty(part, 'text'); +} + +function isSessionIdRecord(record: unknown): record is { sessionId: string } { + return isStringProperty(record, 'sessionId'); +} + +export async function loadConversationRecord( + filePath: string, + options?: LoadConversationOptions, +): Promise< + | (ConversationRecord & { + messageCount?: number; + firstUserMessage?: string; + hasUserOrAssistantMessage?: boolean; + }) + | null +> { + if (!fs.existsSync(filePath)) { + return null; + } + + try { + const fileStream = fs.createReadStream(filePath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + let metadata: Partial = {}; + const messagesMap = new Map(); + const messageIds: string[] = []; + let firstUserMessageStr: string | undefined; + let hasUserOrAssistant = false; + + for await (const line of rl) { + if (!line.trim()) continue; + try { + const record = JSON.parse(line) as unknown; + if (isRewindRecord(record)) { + const rewindId = record.$rewindTo; + if (options?.metadataOnly) { + const idx = messageIds.indexOf(rewindId); + if (idx !== -1) { + messageIds.splice(idx); + } else { + messageIds.length = 0; + } + // For metadataOnly we can't perfectly un-track hasUserOrAssistant if it was rewinded, + // but we can assume false if messageIds is empty. + if (messageIds.length === 0) hasUserOrAssistant = false; + } else { + let found = false; + const idsToDelete: string[] = []; + for (const [id] of messagesMap) { + if (id === rewindId) found = true; + if (found) idsToDelete.push(id); + } + if (found) { + for (const id of idsToDelete) { + messagesMap.delete(id); + } + } else { + messagesMap.clear(); + } + } + } else if (isMessageRecord(record)) { + const id = record.id; + if ( + hasProperty(record, 'type') && + (record.type === 'user' || record.type === 'gemini') + ) { + hasUserOrAssistant = true; + } + // Track message count and first user message + if (options?.metadataOnly) { + messageIds.push(id); + } + if ( + !firstUserMessageStr && + hasProperty(record, 'type') && + record['type'] === 'user' && + hasProperty(record, 'content') && + record['content'] + ) { + // Basic extraction of first user message for display + const rawContent = record['content']; + if (Array.isArray(rawContent)) { + firstUserMessageStr = rawContent + .map((p: unknown) => (isTextPart(p) ? p['text'] : '')) + .join(''); + } else if (typeof rawContent === 'string') { + firstUserMessageStr = rawContent; + } + } + + if (!options?.metadataOnly) { + messagesMap.set(id, record); + if ( + options?.maxMessages && + messagesMap.size > options.maxMessages + ) { + const firstKey = messagesMap.keys().next().value; + if (typeof firstKey === 'string') messagesMap.delete(firstKey); + } + } + } else if (isMetadataUpdateRecord(record)) { + // Metadata update + metadata = { + ...metadata, + ...record.$set, + }; + } else if (isPartialMetadataRecord(record)) { + // Initial metadata line + metadata = { ...metadata, ...record }; + } + } catch { + // ignore parse errors on individual lines + } } - | { - type: 'gemini'; - toolCalls?: ToolCallRecord[]; - thoughts?: Array; - tokens?: TokensSummary | null; - model?: string; + + if (!metadata.sessionId || !metadata.projectHash) { + return await parseLegacyRecordFallback(filePath, options); + } + + return { + sessionId: metadata.sessionId, + projectHash: metadata.projectHash, + startTime: metadata.startTime || new Date().toISOString(), + lastUpdated: metadata.lastUpdated || new Date().toISOString(), + summary: metadata.summary, + directories: metadata.directories, + kind: metadata.kind, + experimentalDynamicTools: metadata.experimentalDynamicTools, + messages: Array.from(messagesMap.values()), + messageCount: options?.metadataOnly + ? messageIds.length + : messagesMap.size, + firstUserMessage: firstUserMessageStr, + hasUserOrAssistantMessage: options?.metadataOnly + ? hasUserOrAssistant + : Array.from(messagesMap.values()).some( + (m) => m.type === 'user' || m.type === 'gemini', + ), }; - -/** - * A single message record in a conversation. - */ -export type MessageRecord = BaseMessageRecord & ConversationRecordExtra; - -/** - * Complete conversation record stored in session files. - */ -export interface ConversationRecord { - sessionId: string; - projectHash: string; - startTime: string; - lastUpdated: string; - messages: MessageRecord[]; - summary?: string; - /** Workspace directories added during the session via /dir add */ - directories?: string[]; - /** The kind of conversation (main agent or subagent) */ - kind?: 'main' | 'subagent'; - /** Experimental: Whether dynamic tools documentation-injection was enabled */ - experimentalDynamicTools?: boolean; + } catch (error) { + debugLogger.error('Error loading conversation record from JSONL:', error); + return null; + } } -/** - * Data structure for resuming an existing session. - */ -export interface ResumedSessionData { - conversation: ConversationRecord; - filePath: string; -} - -/** - * Service for automatically recording chat conversations to disk. - * - * This service provides comprehensive conversation recording that captures: - * - All user and assistant messages - * - Tool calls and their execution results - * - Token usage statistics - * - Assistant thoughts and reasoning - * - * Sessions are stored as JSON files in ~/.gemini/tmp//chats/ - */ export class ChatRecordingService { private conversationFile: string | null = null; - private cachedLastConvData: string | null = null; private cachedConversation: ConversationRecord | null = null; private sessionId: string; private projectHash: string; @@ -152,33 +263,49 @@ export class ChatRecordingService { this.projectHash = getProjectHash(context.config.getProjectRoot()); } - /** - * Initializes the chat recording service: creates a new conversation file and associates it with - * this service instance, or resumes from an existing session if resumedSessionData is provided. - * - * @param resumedSessionData Data from a previous session to resume from. - * @param kind The kind of conversation (main or subagent). - */ - initialize( + async initialize( resumedSessionData?: ResumedSessionData, kind?: 'main' | 'subagent', - ): void { + ): Promise { try { this.kind = kind; if (resumedSessionData) { - // Resume from existing session this.conversationFile = resumedSessionData.filePath; this.sessionId = resumedSessionData.conversation.sessionId; this.kind = resumedSessionData.conversation.kind; - // Update the session ID in the existing file - this.updateConversation((conversation) => { - conversation.sessionId = this.sessionId; - }); + const loadedRecord = await loadConversationRecord( + this.conversationFile, + ); + if (loadedRecord) { + this.cachedConversation = loadedRecord; + this.projectHash = this.cachedConversation.projectHash; - // Clear any cached data to force fresh reads - this.cachedLastConvData = null; - this.cachedConversation = null; + if (this.conversationFile.endsWith('.json')) { + this.conversationFile = this.conversationFile + 'l'; // e.g. session-foo.jsonl + + // Migrate the entire legacy record to the new file + const initialMetadata = { + sessionId: this.sessionId, + projectHash: this.projectHash, + startTime: this.cachedConversation.startTime, + lastUpdated: this.cachedConversation.lastUpdated, + kind: this.cachedConversation.kind, + directories: this.cachedConversation.directories, + summary: this.cachedConversation.summary, + experimentalDynamicTools: this.cachedConversation.experimentalDynamicTools, + }; + this.appendRecord(initialMetadata); + for (const msg of this.cachedConversation.messages) { + this.appendRecord(msg); + } + } + + // Update the session ID in the existing file + this.updateMetadata({ sessionId: this.sessionId }); + } else { + throw new Error('Failed to load resumed session data from file'); + } } else { // Create new session this.sessionId = this.context.promptId; @@ -215,12 +342,12 @@ export class ChatRecordingService { let filename: string; if (this.kind === 'subagent') { - filename = `${safeSessionId}.json`; + filename = `${safeSessionId}.jsonl`; } else { filename = `${SESSION_FILE_PREFIX}${timestamp}-${safeSessionId.slice( 0, 8, - )}.json`; + )}.jsonl`; } this.conversationFile = path.join(chatsDir, filename); @@ -233,38 +360,77 @@ export class ChatRecordingService { ] : undefined; - this.writeConversation({ + const initialMetadata: PartialMetadataRecord = { sessionId: this.sessionId, projectHash: this.projectHash, startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), - messages: [], - directories, kind: this.kind, + directories, experimentalDynamicTools: this.context.config.getExperimentalDynamicTools(), - }); + }; + + this.appendRecord(initialMetadata); + this.cachedConversation = { + ...initialMetadata, + startTime: initialMetadata.startTime!, + lastUpdated: initialMetadata.lastUpdated!, + messages: [], + }; } - // Clear any queued data since this is a fresh start this.queuedThoughts = []; this.queuedTokens = null; } catch (error) { - // Handle disk full (ENOSPC) gracefully - disable recording but allow CLI to continue - if ( - error instanceof Error && - 'code' in error && - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (error as NodeJS.ErrnoException).code === 'ENOSPC' - ) { + if (isNodeError(error) && error.code === 'ENOSPC') { this.conversationFile = null; debugLogger.warn(ENOSPC_WARNING_MESSAGE); - return; // Don't throw - allow the CLI to continue + return; } debugLogger.error('Error initializing chat recording service:', error); throw error; } } + private appendRecord(record: unknown): void { + if (!this.conversationFile) return; + try { + const line = JSON.stringify(record) + '\n'; + fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true }); + fs.appendFileSync(this.conversationFile, line); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOSPC') { + this.conversationFile = null; + debugLogger.warn(ENOSPC_WARNING_MESSAGE); + } else { + throw error; + } + } + } + + private updateMetadata(updates: Partial): void { + if (!this.cachedConversation) return; + Object.assign(this.cachedConversation, updates); + this.appendRecord({ $set: updates }); + } + + private pushMessage(msg: MessageRecord): void { + if (!this.cachedConversation) return; + + // We append the full message to the log + this.appendRecord(msg); + + // Now update memory + const index = this.cachedConversation.messages.findIndex( + (m) => m.id === msg.id, + ); + if (index !== -1) { + this.cachedConversation.messages[index] = msg; + } else { + this.cachedConversation.messages.push(msg); + } + } + private getLastMessage( conversation: ConversationRecord, ): MessageRecord | undefined { @@ -285,69 +451,47 @@ export class ChatRecordingService { }; } - /** - * Records a message in the conversation. - */ recordMessage(message: { model: string | undefined; type: ConversationRecordExtra['type']; content: PartListUnion; displayContent?: PartListUnion; }): void { - if (!this.conversationFile) return; + if (!this.conversationFile || !this.cachedConversation) return; try { - this.updateConversation((conversation) => { - const msg = this.newMessage( - message.type, - message.content, - message.displayContent, - ); - if (msg.type === 'gemini') { - // If it's a new Gemini message then incorporate any queued thoughts. - conversation.messages.push({ - ...msg, - thoughts: this.queuedThoughts, - tokens: this.queuedTokens, - model: message.model, - }); - this.queuedThoughts = []; - this.queuedTokens = null; - } else { - // Or else just add it. - conversation.messages.push(msg); - } - }); + const msg = this.newMessage( + message.type, + message.content, + message.displayContent, + ); + if (msg.type === 'gemini') { + msg.thoughts = this.queuedThoughts; + msg.tokens = this.queuedTokens; + msg.model = message.model; + this.queuedThoughts = []; + this.queuedTokens = null; + } + this.pushMessage(msg); + this.updateMetadata({ lastUpdated: new Date().toISOString() }); } catch (error) { debugLogger.error('Error saving message to chat history.', error); throw error; } } - /** - * Records a thought from the assistant's reasoning process. - */ recordThought(thought: ThoughtSummary): void { if (!this.conversationFile) return; - - try { - this.queuedThoughts.push({ - ...thought, - timestamp: new Date().toISOString(), - }); - } catch (error) { - debugLogger.error('Error saving thought to chat history.', error); - throw error; - } + this.queuedThoughts.push({ + ...thought, + timestamp: new Date().toISOString(), + }); } - /** - * Updates the tokens for the last message in the conversation (which should be by Gemini). - */ recordMessageTokens( respUsageMetadata: GenerateContentResponseUsageMetadata, ): void { - if (!this.conversationFile) return; + if (!this.conversationFile || !this.cachedConversation) return; try { const tokens = { @@ -358,17 +502,12 @@ export class ChatRecordingService { tool: respUsageMetadata.toolUsePromptTokenCount ?? 0, total: respUsageMetadata.totalTokenCount ?? 0, }; - const conversation = this.readConversation(); - const lastMsg = this.getLastMessage(conversation); - // If the last message already has token info, it's because this new token info is for a - // new message that hasn't been recorded yet. + const lastMsg = this.getLastMessage(this.cachedConversation); if (lastMsg && lastMsg.type === 'gemini' && !lastMsg.tokens) { lastMsg.tokens = tokens; this.queuedTokens = null; - this.writeConversation(conversation); + this.pushMessage(lastMsg); } else { - // Only queue tokens in memory; no disk I/O needed since the - // conversation record itself hasn't changed. this.queuedTokens = tokens; } } catch (error) { @@ -380,14 +519,9 @@ export class ChatRecordingService { } } - /** - * Adds tool calls to the last message in the conversation (which should be by Gemini). - * This method enriches tool calls with metadata from the ToolRegistry. - */ recordToolCalls(model: string, toolCalls: ToolCallRecord[]): void { - if (!this.conversationFile) return; + if (!this.conversationFile || !this.cachedConversation) return; - // Enrich tool calls with metadata from the ToolRegistry const toolRegistry = this.context.toolRegistry; const enrichedToolCalls = toolCalls.map((toolCall) => { const toolInstance = toolRegistry.getTool(toolCall.name); @@ -401,74 +535,52 @@ export class ChatRecordingService { }); try { - this.updateConversation((conversation) => { - const lastMsg = this.getLastMessage(conversation); - // If a tool call was made, but the last message isn't from Gemini, it's because Gemini is - // calling tools without starting the message with text. So the user submits a prompt, and - // Gemini immediately calls a tool (maybe with some thinking first). In that case, create - // a new empty Gemini message. - // Also if there are any queued thoughts, it means this tool call(s) is from a new Gemini - // message--because it's thought some more since we last, if ever, created a new Gemini - // message from tool calls, when we dequeued the thoughts. - if ( - !lastMsg || - lastMsg.type !== 'gemini' || - this.queuedThoughts.length > 0 - ) { - const newMsg: MessageRecord = { - ...this.newMessage('gemini' as const, ''), - // This isn't strictly necessary, but TypeScript apparently can't - // tell that the first parameter to newMessage() becomes the - // resulting message's type, and so it thinks that toolCalls may - // not be present. Confirming the type here satisfies it. - type: 'gemini' as const, - toolCalls: enrichedToolCalls, - thoughts: this.queuedThoughts, - model, - }; - // If there are any queued thoughts join them to this message. - if (this.queuedThoughts.length > 0) { - newMsg.thoughts = this.queuedThoughts; - this.queuedThoughts = []; - } - // If there's any queued tokens info join it to this message. - if (this.queuedTokens) { - newMsg.tokens = this.queuedTokens; - this.queuedTokens = null; - } - conversation.messages.push(newMsg); - } else { - // The last message is an existing Gemini message that we need to update. + const lastMsg = this.getLastMessage(this.cachedConversation); + if ( + !lastMsg || + lastMsg.type !== 'gemini' || + this.queuedThoughts.length > 0 + ) { + const newMsg: MessageRecord = { + ...this.newMessage('gemini' as const, ''), + type: 'gemini' as const, + toolCalls: enrichedToolCalls, + thoughts: this.queuedThoughts, + model, + }; + if (this.queuedThoughts.length > 0) { + newMsg.thoughts = this.queuedThoughts; + this.queuedThoughts = []; + } + if (this.queuedTokens) { + newMsg.tokens = this.queuedTokens; + this.queuedTokens = null; + } + this.pushMessage(newMsg); + } else { + if (!lastMsg.toolCalls) { + lastMsg.toolCalls = []; + } + // Deep clone toolCalls to avoid modifying memory references directly + const updatedToolCalls = [...lastMsg.toolCalls]; - // Update any existing tool call entries. - if (!lastMsg.toolCalls) { - lastMsg.toolCalls = []; - } - lastMsg.toolCalls = lastMsg.toolCalls.map((toolCall) => { - // If there are multiple tool calls with the same ID, this will take the first one. - const incomingToolCall = toolCalls.find( - (tc) => tc.id === toolCall.id, - ); - if (incomingToolCall) { - // Merge in the new data to keep preserve thoughts, etc., that were assigned to older - // versions of the tool call. - return { ...toolCall, ...incomingToolCall }; - } else { - return toolCall; - } - }); - - // Add any new tools calls that aren't in the message yet. - for (const toolCall of enrichedToolCalls) { - const existingToolCall = lastMsg.toolCalls.find( - (tc) => tc.id === toolCall.id, - ); - if (!existingToolCall) { - lastMsg.toolCalls.push(toolCall); - } + for (const toolCall of enrichedToolCalls) { + const index = updatedToolCalls.findIndex( + (tc) => tc.id === toolCall.id, + ); + if (index !== -1) { + updatedToolCalls[index] = { + ...updatedToolCalls[index], + ...toolCall, + }; + } else { + updatedToolCalls.push(toolCall); } } - }); + + lastMsg.toolCalls = updatedToolCalls; + this.pushMessage(lastMsg); + } } catch (error) { debugLogger.error( 'Error adding tool call to message in chat history.', @@ -478,171 +590,29 @@ export class ChatRecordingService { } } - /** - * Loads up the conversation record from disk. - * - * NOTE: The returned object is the live in-memory cache reference. - * Any mutations to it will be visible to all subsequent reads. - * Callers that mutate the result MUST call writeConversation() to - * persist the changes to disk. - */ - private readConversation(): ConversationRecord { - if (this.cachedConversation) { - return this.cachedConversation; - } - try { - this.cachedLastConvData = fs.readFileSync(this.conversationFile!, 'utf8'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - this.cachedConversation = JSON.parse(this.cachedLastConvData); - if (this.cachedConversation) { - this.cachedConversation.experimentalDynamicTools = - this.context.config.getExperimentalDynamicTools(); - } else { - // File is corrupt or contains "null". Fallback to an empty conversation. - this.cachedConversation = { - sessionId: this.sessionId, - projectHash: this.projectHash, - startTime: new Date().toISOString(), - lastUpdated: new Date().toISOString(), - messages: [], - kind: this.kind, - experimentalDynamicTools: this.context.config.getExperimentalDynamicTools(), - }; - } - return this.cachedConversation; - } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - debugLogger.error('Error reading conversation file.', error); - throw error; - } - - // Placeholder empty conversation if file doesn't exist. - this.cachedConversation = { - sessionId: this.sessionId, - projectHash: this.projectHash, - startTime: new Date().toISOString(), - lastUpdated: new Date().toISOString(), - messages: [], - kind: this.kind, - experimentalDynamicTools: this.context.config.getExperimentalDynamicTools(), - }; - return this.cachedConversation; - } - } - - /** - * Saves the conversation record; overwrites the file. - */ - private writeConversation( - conversation: ConversationRecord, - { allowEmpty = false }: { allowEmpty?: boolean } = {}, - ): void { - try { - if (!this.conversationFile) return; - - // Cache the conversation state even if we don't write to disk yet. - // This ensures that subsequent reads (e.g. during recordMessage) - // see the initial state (like directories) instead of trying to - // read a non-existent file from disk. - this.cachedConversation = conversation; - - // Don't write the file yet until there's at least one message. - if (conversation.messages.length === 0 && !allowEmpty) return; - - const newContent = JSON.stringify(conversation, null, 2); - // Skip the disk write if nothing actually changed (e.g. - // updateMessagesFromHistory found no matching tool calls to update). - // Compare before updating lastUpdated so the timestamp doesn't - // cause a false diff. - if (this.cachedLastConvData === newContent) return; - conversation.lastUpdated = new Date().toISOString(); - const contentToWrite = JSON.stringify(conversation, null, 2); - this.cachedLastConvData = contentToWrite; - // Ensure directory exists before writing (handles cases where temp dir was cleaned) - fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true }); - fs.writeFileSync(this.conversationFile, contentToWrite); - } catch (error) { - // Handle disk full (ENOSPC) gracefully - disable recording but allow conversation to continue - if ( - error instanceof Error && - 'code' in error && - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (error as NodeJS.ErrnoException).code === 'ENOSPC' - ) { - this.conversationFile = null; - this.cachedConversation = null; - debugLogger.warn(ENOSPC_WARNING_MESSAGE); - return; // Don't throw - allow the conversation to continue - } - debugLogger.error('Error writing conversation file.', error); - throw error; - } - } - - /** - * Convenient helper for updating the conversation without file reading and writing and time - * updating boilerplate. - */ - private updateConversation( - updateFn: (conversation: ConversationRecord) => void, - ) { - const conversation = this.readConversation(); - updateFn(conversation); - this.writeConversation(conversation); - } - - /** - * Saves a summary for the current session. - */ saveSummary(summary: string): void { if (!this.conversationFile) return; - try { - this.updateConversation((conversation) => { - conversation.summary = summary; - }); + this.updateMetadata({ summary }); } catch (error) { debugLogger.error('Error saving summary to chat history.', error); - // Don't throw - we want graceful degradation } } - /** - * Records workspace directories to the session file. - * Called when directories are added via /dir add. - */ recordDirectories(directories: readonly string[]): void { if (!this.conversationFile) return; - try { - this.updateConversation((conversation) => { - conversation.directories = [...directories]; - }); + this.updateMetadata({ directories: [...directories] }); } catch (error) { debugLogger.error('Error saving directories to chat history.', error); - // Don't throw - we want graceful degradation } } - /** - * Gets the current conversation data (for summary generation). - */ getConversation(): ConversationRecord | null { if (!this.conversationFile) return null; - - try { - return this.readConversation(); - } catch (error) { - debugLogger.error('Error reading conversation for summary.', error); - return null; - } + return this.cachedConversation; } - /** - * Gets the path to the current conversation file. - * Returns null if the service hasn't been initialized yet or recording is disabled. - */ getConversationFilePath(): string | null { return this.conversationFile; } @@ -658,7 +628,6 @@ export class ChatRecordingService { try { const tempDir = this.context.config.storage.getProjectTempDir(); const chatsDir = path.join(tempDir, 'chats'); - const shortId = this.deriveShortId(sessionIdOrBasename); // Using stat instead of existsSync for async sanity @@ -666,8 +635,10 @@ export class ChatRecordingService { return; // Nothing to delete } - const matchingFiles = this.getMatchingSessionFiles(chatsDir, shortId); - + const matchingFiles = await this.getMatchingSessionFiles( + chatsDir, + shortId, + ); for (const file of matchingFiles) { await this.deleteSessionAndArtifacts(chatsDir, file, tempDir); } @@ -677,13 +648,10 @@ export class ChatRecordingService { } } - /** - * Derives an 8-character shortId from a sessionId, filename, or basename. - */ private deriveShortId(sessionIdOrBasename: string): string { let shortId = sessionIdOrBasename; if (sessionIdOrBasename.startsWith(SESSION_FILE_PREFIX)) { - const withoutExt = sessionIdOrBasename.replace('.json', ''); + const withoutExt = sessionIdOrBasename.replace(/\.jsonl?$/, ''); const parts = withoutExt.split('-'); shortId = parts[parts.length - 1]; } else if (sessionIdOrBasename.length >= 8) { @@ -699,14 +667,15 @@ export class ChatRecordingService { return shortId; } - /** - * Finds all session files matching the pattern session-*-.json - */ - private getMatchingSessionFiles(chatsDir: string, shortId: string): string[] { - const files = fs.readdirSync(chatsDir); + private async getMatchingSessionFiles( + chatsDir: string, + shortId: string, + ): Promise { + const files = await fs.promises.readdir(chatsDir); return files.filter( (f) => - f.startsWith(SESSION_FILE_PREFIX) && f.endsWith(`-${shortId}.json`), + f.startsWith(SESSION_FILE_PREFIX) && + (f.endsWith(`-${shortId}.json`) || f.endsWith(`-${shortId}.jsonl`)), ); } @@ -720,15 +689,34 @@ export class ChatRecordingService { ): Promise { const filePath = path.join(chatsDir, file); try { - const fileContent = await fs.promises.readFile(filePath, 'utf8'); - const content = JSON.parse(fileContent) as unknown; + const CHUNK_SIZE = 4096; + const buffer = Buffer.alloc(CHUNK_SIZE); + let firstLine: string; + let fd: fs.promises.FileHandle | undefined; + try { + fd = await fs.promises.open(filePath, 'r'); + const { bytesRead } = await fd.read(buffer, 0, CHUNK_SIZE, 0); + if (bytesRead === 0) { + await fd.close(); + await fs.promises.unlink(filePath); + return; + } + const contentChunk = buffer.toString('utf8', 0, bytesRead); + const newlineIndex = contentChunk.indexOf('\n'); + firstLine = + newlineIndex !== -1 + ? contentChunk.substring(0, newlineIndex) + : contentChunk; + } finally { + if (fd !== undefined) { + await fd.close(); + } + } + const content = JSON.parse(firstLine) as unknown; let fullSessionId: string | undefined; - if (content && typeof content === 'object' && 'sessionId' in content) { - const id = (content as Record)['sessionId']; - if (typeof id === 'string') { - fullSessionId = id; - } + if (isSessionIdRecord(content)) { + fullSessionId = content['sessionId']; } // Delete the session file @@ -753,11 +741,9 @@ export class ChatRecordingService { * All messages from (and including) the specified ID onwards are removed. */ rewindTo(messageId: string): ConversationRecord | null { - if (!this.conversationFile) { - return null; - } - const conversation = this.readConversation(); - const messageIndex = conversation.messages.findIndex( + if (!this.conversationFile || !this.cachedConversation) return null; + + const messageIndex = this.cachedConversation.messages.findIndex( (m) => m.id === messageId, ); @@ -765,67 +751,60 @@ export class ChatRecordingService { debugLogger.error( 'Message to rewind to not found in conversation history', ); - return conversation; + return this.cachedConversation; } - conversation.messages = conversation.messages.slice(0, messageIndex); - this.writeConversation(conversation, { allowEmpty: true }); - return conversation; + this.cachedConversation.messages = this.cachedConversation.messages.slice( + 0, + messageIndex, + ); + this.appendRecord({ $rewindTo: messageId }); + return this.cachedConversation; } - /** - * Updates the conversation history based on the provided API Content array. - * This is used to persist changes made to the history (like masking) back to disk. - */ updateMessagesFromHistory(history: readonly Content[]): void { - if (!this.conversationFile) return; + if (!this.conversationFile || !this.cachedConversation) return; try { - this.updateConversation((conversation) => { - // Create a map of tool results from the API history for quick lookup by call ID. - // We store the full list of parts associated with each tool call ID to preserve - // multi-modal data and proper trajectory structure. - const partsMap = new Map(); - for (const content of history) { - if (content.role === 'user' && content.parts) { - // Find all unique call IDs in this message - const callIds = content.parts - .map((p) => p.functionResponse?.id) - .filter((id): id is string => !!id); + const partsMap = new Map(); + for (const content of history) { + if (content.role === 'user' && content.parts) { + const callIds = content.parts + .map((p) => p.functionResponse?.id) + .filter((id): id is string => !!id); - if (callIds.length === 0) continue; + if (callIds.length === 0) continue; - // Use the first ID as a seed to capture any "leading" non-ID parts - // in this specific content block. - let currentCallId = callIds[0]; - for (const part of content.parts) { - if (part.functionResponse?.id) { - currentCallId = part.functionResponse.id; - } + let currentCallId = callIds[0]; + for (const part of content.parts) { + if (part.functionResponse?.id) { + currentCallId = part.functionResponse.id; + } - if (!partsMap.has(currentCallId)) { - partsMap.set(currentCallId, []); - } - partsMap.get(currentCallId)!.push(part); + if (!partsMap.has(currentCallId)) { + partsMap.set(currentCallId, []); + } + partsMap.get(currentCallId)!.push(part); + } + } + } + + for (const message of this.cachedConversation.messages) { + let msgChanged = false; + if (message.type === 'gemini' && message.toolCalls) { + for (const toolCall of message.toolCalls) { + const newParts = partsMap.get(toolCall.id); + if (newParts !== undefined) { + toolCall.result = newParts; + msgChanged = true; } } } - - // Update the conversation records tool results if they've changed. - for (const message of conversation.messages) { - if (message.type === 'gemini' && message.toolCalls) { - for (const toolCall of message.toolCalls) { - const newParts = partsMap.get(toolCall.id); - if (newParts !== undefined) { - // Store the results as proper Parts (including functionResponse) - // instead of stringifying them as text parts. This ensures the - // tool trajectory is correctly reconstructed upon session resumption. - toolCall.result = newParts; - } - } - } + if (msgChanged) { + // Push updated message to log + this.pushMessage(message); } - }); + } } catch (error) { debugLogger.error( 'Error updating conversation history from memory.', @@ -835,3 +814,63 @@ export class ChatRecordingService { } } } + +async function parseLegacyRecordFallback( + filePath: string, + options?: LoadConversationOptions, +): Promise< + | (ConversationRecord & { + messageCount?: number; + firstUserMessage?: string; + hasUserOrAssistantMessage?: boolean; + }) + | null +> { + try { + const fileContent = await fs.promises.readFile(filePath, 'utf8'); + const parsed = JSON.parse(fileContent) as unknown; + + const isLegacyRecord = (val: unknown): val is ConversationRecord => + typeof val === 'object' && val !== null && 'sessionId' in val; + + if (isLegacyRecord(parsed)) { + const legacyRecord = parsed; + if (options?.metadataOnly) { + let fallbackFirstUserMessageStr: string | undefined; + const firstUserMessage = legacyRecord.messages?.find( + (m) => m.type === 'user', + ); + if (firstUserMessage) { + const rawContent = firstUserMessage.content; + if (Array.isArray(rawContent)) { + fallbackFirstUserMessageStr = rawContent + .map((p: unknown) => (isTextPart(p) ? p['text'] : '')) + .join(''); + } else if (typeof rawContent === 'string') { + fallbackFirstUserMessageStr = rawContent; + } + } + return { + ...legacyRecord, + messages: [], + messageCount: legacyRecord.messages?.length || 0, + firstUserMessage: fallbackFirstUserMessageStr, + hasUserOrAssistantMessage: + legacyRecord.messages?.some( + (m) => m.type === 'user' || m.type === 'gemini', + ) || false, + }; + } + return { + ...legacyRecord, + hasUserOrAssistantMessage: + legacyRecord.messages?.some( + (m) => m.type === 'user' || m.type === 'gemini', + ) || false, + }; + } + } catch { + // ignore legacy fallback parse error + } + return null; +} diff --git a/packages/core/src/services/chatRecordingTypes.ts b/packages/core/src/services/chatRecordingTypes.ts new file mode 100644 index 0000000000..42cf9f3f0a --- /dev/null +++ b/packages/core/src/services/chatRecordingTypes.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { PartListUnion } from '@google/genai'; +import type { Status } from '../scheduler/types.js'; +import type { ToolResultDisplay } from '../tools/tools.js'; +import { type ThoughtSummary } from '../utils/thoughtUtils.js'; + +export const SESSION_FILE_PREFIX = 'session-'; +export const MAX_HISTORY_MESSAGES = 50; +export const MAX_TOOL_OUTPUT_SIZE = 50 * 1024; // 50KB + +/** + * Token usage summary for a message or conversation. + */ +export interface TokensSummary { + input: number; // promptTokenCount + output: number; // candidatesTokenCount + cached: number; // cachedContentTokenCount + thoughts?: number; // thoughtsTokenCount + tool?: number; // toolUsePromptTokenCount + total: number; // totalTokenCount +} + +/** + * Base fields common to all messages. + */ +export interface BaseMessageRecord { + id: string; + timestamp: string; + content: PartListUnion; + displayContent?: PartListUnion; +} + +/** + * Record of a tool call execution within a conversation. + */ +export interface ToolCallRecord { + id: string; + name: string; + args: Record; + result?: PartListUnion | null; + status: Status; + timestamp: string; + // UI-specific fields for display purposes + displayName?: string; + description?: string; + resultDisplay?: ToolResultDisplay; + renderOutputAsMarkdown?: boolean; + /** Experimental: original name of the tool requested by the model before unwrapping */ + originalRequestName?: string; + /** Experimental: original arguments of the tool requested by the model before unwrapping */ + originalRequestArgs?: Record; +} + +/** + * Message type and message type-specific fields. + */ +export type ConversationRecordExtra = + | { + type: 'user' | 'info' | 'error' | 'warning'; + } + | { + type: 'gemini'; + toolCalls?: ToolCallRecord[]; + thoughts?: Array; + tokens?: TokensSummary | null; + model?: string; + }; + +/** + * A single message record in a conversation. + */ +export type MessageRecord = BaseMessageRecord & ConversationRecordExtra; + +/** + * Complete conversation record stored in session files. + */ +export interface ConversationRecord { + sessionId: string; + projectHash: string; + startTime: string; + lastUpdated: string; + messages: MessageRecord[]; + summary?: string; + /** Workspace directories added during the session via /dir add */ + directories?: string[]; + /** The kind of conversation (main agent or subagent) */ + kind?: 'main' | 'subagent'; + /** Experimental: Whether dynamic tools documentation-injection was enabled */ + experimentalDynamicTools?: boolean; +} + +/** + * Data structure for resuming an existing session. + */ +export interface ResumedSessionData { + conversation: ConversationRecord; + filePath: string; +} + +/** + * Loads a ConversationRecord from a JSONL session file. + * Returns null if the file is invalid or cannot be read. + */ +export interface LoadConversationOptions { + maxMessages?: number; + metadataOnly?: boolean; +} + +export interface RewindRecord { + $rewindTo: string; +} + +export interface MetadataUpdateRecord { + $set: Partial; +} + +export interface PartialMetadataRecord { + sessionId: string; + projectHash: string; + startTime?: string; + lastUpdated?: string; + summary?: string; + directories?: string[]; + kind?: 'main' | 'subagent'; + /** Experimental: Whether dynamic tools documentation-injection was enabled */ + experimentalDynamicTools?: boolean; +} diff --git a/packages/core/src/services/sandboxManager.integration.test.ts b/packages/core/src/services/sandboxManager.integration.test.ts index 1461b6d606..65adeaacbb 100644 --- a/packages/core/src/services/sandboxManager.integration.test.ts +++ b/packages/core/src/services/sandboxManager.integration.test.ts @@ -507,6 +507,102 @@ describe('SandboxManager Integration', () => { }); }); + describe('Git Worktree Support', () => { + it('allows access to git common directory in a worktree', async () => { + const mainRepo = createTempDir('main-repo-'); + const worktreeDir = createTempDir('worktree-'); + + const mainGitDir = path.join(mainRepo, '.git'); + fs.mkdirSync(mainGitDir, { recursive: true }); + fs.writeFileSync( + path.join(mainGitDir, 'config'), + '[core]\n\trepositoryformatversion = 0\n', + ); + + const worktreeGitDir = path.join( + mainGitDir, + 'worktrees', + 'test-worktree', + ); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + + // Create the .git file in the worktree directory pointing to the worktree git dir + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${worktreeGitDir}\n`, + ); + + // Create the backlink from worktree git dir to the worktree's .git file + const backlinkPath = path.join(worktreeGitDir, 'gitdir'); + fs.writeFileSync(backlinkPath, path.join(worktreeDir, '.git')); + + // Create a file in the worktree git dir that we want to access + const secretFile = path.join(worktreeGitDir, 'secret.txt'); + fs.writeFileSync(secretFile, 'git-secret'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: worktreeDir }, + ); + + const { command, args } = Platform.cat(secretFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: worktreeDir, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(result.stdout.trim()).toBe('git-secret'); + }); + + it('blocks write access to git common directory in a worktree', async () => { + const mainRepo = createTempDir('main-repo-'); + const worktreeDir = createTempDir('worktree-'); + + const mainGitDir = path.join(mainRepo, '.git'); + fs.mkdirSync(mainGitDir, { recursive: true }); + + const worktreeGitDir = path.join( + mainGitDir, + 'worktrees', + 'test-worktree', + ); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${worktreeGitDir}\n`, + ); + fs.writeFileSync( + path.join(worktreeGitDir, 'gitdir'), + path.join(worktreeDir, '.git'), + ); + + const targetFile = path.join(worktreeGitDir, 'secret.txt'); + + const osManager = createSandboxManager( + { enabled: true }, + // Use YOLO mode to ensure the workspace is fully writable, but git worktrees should still be read-only + { workspace: worktreeDir, modeConfig: { yolo: true } }, + ); + + const { command, args } = Platform.touch(targetFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: worktreeDir, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); + expect(fs.existsSync(targetFile)).toBe(false); + }); + }); + describe('Network Access', () => { let server: http.Server; let url: string; diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index 134ef167bd..7ff8525f77 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -13,7 +13,6 @@ import { sanitizePaths, findSecretFiles, isSecretFile, - tryRealpath, resolveSandboxPaths, getPathIdentity, type SandboxRequest, @@ -36,10 +35,25 @@ vi.mock('node:fs/promises', async () => { readdir: vi.fn(), realpath: vi.fn(), stat: vi.fn(), + lstat: vi.fn(), + readFile: vi.fn(), }, readdir: vi.fn(), realpath: vi.fn(), stat: vi.fn(), + lstat: vi.fn(), + readFile: vi.fn(), + }; +}); + +vi.mock('../utils/paths.js', async () => { + const actual = + await vi.importActual( + '../utils/paths.js', + ); + return { + ...actual, + resolveToRealPath: vi.fn((p) => p), }; }); @@ -279,104 +293,6 @@ describe('SandboxManager', () => { }); }); - describe('tryRealpath', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should return the realpath if the file exists', async () => { - const realPath = path.resolve('/real/path/to/file.txt'); - const symlinkPath = path.resolve('/some/symlink/to/file.txt'); - vi.mocked(fsPromises.realpath).mockResolvedValue(realPath as never); - const result = await tryRealpath(symlinkPath); - expect(result).toBe(realPath); - expect(fsPromises.realpath).toHaveBeenCalledWith(symlinkPath); - }); - - it('should fallback to parent directory if file does not exist (ENOENT)', async () => { - const nonexistent = path.resolve('/workspace/nonexistent.txt'); - const workspace = path.resolve('/workspace'); - const realWorkspace = path.resolve('/real/workspace'); - - vi.mocked(fsPromises.realpath).mockImplementation(((p: string) => { - if (p === nonexistent) { - return Promise.reject( - Object.assign(new Error('ENOENT: no such file or directory'), { - code: 'ENOENT', - }), - ); - } - if (p === workspace) { - return Promise.resolve(realWorkspace); - } - return Promise.reject(new Error(`Unexpected path: ${p}`)); - }) as never); - - const result = await tryRealpath(nonexistent); - - // It should combine the real path of the parent with the original basename - expect(result).toBe(path.join(realWorkspace, 'nonexistent.txt')); - }); - - it('should recursively fallback up the directory tree on multiple ENOENT errors', async () => { - const missingFile = path.resolve( - '/workspace/missing_dir/missing_file.txt', - ); - const missingDir = path.resolve('/workspace/missing_dir'); - const workspace = path.resolve('/workspace'); - const realWorkspace = path.resolve('/real/workspace'); - - vi.mocked(fsPromises.realpath).mockImplementation(((p: string) => { - if (p === missingFile) { - return Promise.reject( - Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), - ); - } - if (p === missingDir) { - return Promise.reject( - Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), - ); - } - if (p === workspace) { - return Promise.resolve(realWorkspace); - } - return Promise.reject(new Error(`Unexpected path: ${p}`)); - }) as never); - - const result = await tryRealpath(missingFile); - - // It should resolve '/workspace' to '/real/workspace' and append the missing parts - expect(result).toBe( - path.join(realWorkspace, 'missing_dir', 'missing_file.txt'), - ); - }); - - it('should return the path unchanged if it reaches the root directory and it still does not exist', async () => { - const rootPath = path.resolve('/'); - vi.mocked(fsPromises.realpath).mockImplementation(() => - Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })), - ); - - const result = await tryRealpath(rootPath); - expect(result).toBe(rootPath); - }); - - it('should throw an error if realpath fails with a non-ENOENT error (e.g. EACCES)', async () => { - const secretFile = path.resolve('/secret/file.txt'); - vi.mocked(fsPromises.realpath).mockImplementation(() => - Promise.reject( - Object.assign(new Error('EACCES: permission denied'), { - code: 'EACCES', - }), - ), - ); - - await expect(tryRealpath(secretFile)).rejects.toThrow( - 'EACCES: permission denied', - ); - }); - }); - describe('NoopSandboxManager', () => { const sandboxManager = new NoopSandboxManager(); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index f7f2944fe7..0191207b16 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -15,7 +15,6 @@ import { isKnownSafeCommand as isWindowsSafeCommand, isDangerousCommand as isWindowsDangerousCommand, } from '../sandbox/windows/commandSafety.js'; -import { isNodeError } from '../utils/errors.js'; import { sanitizeEnvironment, getSecureSanitizationConfig, @@ -24,6 +23,7 @@ import { import type { ShellExecutionResult } from './shellExecutionService.js'; import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js'; import { resolveToRealPath } from '../utils/paths.js'; +import { resolveGitWorktreePaths } from '../sandbox/utils/fsUtils.js'; /** * A structured result of fully resolved sandbox paths. @@ -48,6 +48,13 @@ export interface ResolvedSandboxPaths { policyRead: string[]; /** Paths granted temporary write access by the current command's dynamic permissions. */ policyWrite: string[]; + /** Auto-detected paths for git worktrees/submodules. */ + gitWorktree?: { + /** The actual .git directory for this worktree. */ + worktreeGitDir: string; + /** The main repository's .git directory (if applicable). */ + mainGitDir?: string; + }; } export interface SandboxPermissions { @@ -392,6 +399,12 @@ export async function resolveSandboxPaths( ); const forbiddenIdentities = new Set(forbidden.map(getPathIdentity)); + const { worktreeGitDir, mainGitDir } = + await resolveGitWorktreePaths(resolvedWorkspace); + const gitWorktree = worktreeGitDir + ? { gitWorktree: { worktreeGitDir, mainGitDir } } + : undefined; + /** * Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved). */ @@ -413,8 +426,10 @@ export async function resolveSandboxPaths( policyAllowed: filter(policyAllowed), policyRead: filter(policyRead), policyWrite: filter(policyWrite), + ...gitWorktree, }; } + /** * Sanitizes an array of paths by deduplicating them and ensuring they are absolute. * Always returns an array (empty if input is null/undefined). @@ -451,24 +466,4 @@ export function getPathIdentity(p: string): string { return isCaseInsensitive ? norm.toLowerCase() : norm; } -/** - * Resolves symlinks for a given path to prevent sandbox escapes. - * If a file does not exist (ENOENT), it recursively resolves the parent directory. - * Other errors (e.g. EACCES) are re-thrown. - */ -export async function tryRealpath(p: string): Promise { - try { - return await fs.realpath(p); - } catch (e) { - if (isNodeError(e) && e.code === 'ENOENT') { - const parentDir = path.dirname(p); - if (parentDir === p) { - return p; - } - return path.join(await tryRealpath(parentDir), path.basename(p)); - } - throw e; - } -} - export { createSandboxManager } from './sandboxManagerFactory.js'; diff --git a/packages/core/src/telemetry/activity-monitor.ts b/packages/core/src/telemetry/activity-monitor.ts index 15b96cb1e3..255fb39e5f 100644 --- a/packages/core/src/telemetry/activity-monitor.ts +++ b/packages/core/src/telemetry/activity-monitor.ts @@ -50,6 +50,7 @@ export const DEFAULT_ACTIVITY_CONFIG: ActivityMonitorConfig = { ActivityType.USER_INPUT_START, ActivityType.MESSAGE_ADDED, ActivityType.TOOL_CALL_SCHEDULED, + ActivityType.TOOL_CALL_COMPLETED, ActivityType.STREAM_START, ], }; diff --git a/packages/core/src/telemetry/event-loop-monitor.ts b/packages/core/src/telemetry/event-loop-monitor.ts new file mode 100644 index 0000000000..d56179d0da --- /dev/null +++ b/packages/core/src/telemetry/event-loop-monitor.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import process from 'node:process'; +import { monitorEventLoopDelay, type IntervalHistogram } from 'node:perf_hooks'; +import type { Config } from '../config/config.js'; +import { + recordEventLoopDelay, + isPerformanceMonitoringActive, +} from './metrics.js'; + +export class EventLoopMonitor { + private eventLoopHistogram: IntervalHistogram | null = null; + private intervalId: NodeJS.Timeout | null = null; + private isRunning = false; + + start(config: Config, intervalMs: number = 10000): void { + const isEnabled = + process.env['GEMINI_EVENT_LOOP_MONITOR_ENABLED'] === 'true'; + if (!isEnabled || !isPerformanceMonitoringActive() || this.isRunning) { + return; + } + + this.isRunning = true; + this.eventLoopHistogram = monitorEventLoopDelay({ resolution: 10 }); + this.eventLoopHistogram.enable(); + + this.intervalId = setInterval(() => { + this.takeSnapshot(config); + }, intervalMs).unref(); + } + + stop(): void { + if (!this.isRunning) { + return; + } + + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + if (this.eventLoopHistogram) { + this.eventLoopHistogram.disable(); + this.eventLoopHistogram = null; + } + + this.isRunning = false; + } + + private takeSnapshot(config: Config): void { + if (!this.eventLoopHistogram) { + return; + } + + const p50 = this.eventLoopHistogram.percentile(50) / 1e6; + const p95 = this.eventLoopHistogram.percentile(95) / 1e6; + const max = this.eventLoopHistogram.max / 1e6; + + recordEventLoopDelay(config, p50, { + percentile: 'p50', + component: 'event_loop_monitor', + }); + recordEventLoopDelay(config, p95, { + percentile: 'p95', + component: 'event_loop_monitor', + }); + recordEventLoopDelay(config, max, { + percentile: 'max', + component: 'event_loop_monitor', + }); + } +} + +let globalEventLoopMonitor: EventLoopMonitor | null = null; + +export function startGlobalEventLoopMonitoring( + config: Config, + intervalMs?: number, +): void { + if (!globalEventLoopMonitor) { + globalEventLoopMonitor = new EventLoopMonitor(); + } + globalEventLoopMonitor.start(config, intervalMs); +} + +export function stopGlobalEventLoopMonitoring(): void { + if (globalEventLoopMonitor) { + globalEventLoopMonitor.stop(); + globalEventLoopMonitor = null; + } +} + +export function getEventLoopMonitor(): EventLoopMonitor | null { + return globalEventLoopMonitor; +} diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index ea65941e06..d3cc033341 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -93,6 +93,12 @@ export { stopGlobalMemoryMonitoring, } from './memory-monitor.js'; export type { MemorySnapshot, ProcessMetrics } from './memory-monitor.js'; +export { + EventLoopMonitor, + startGlobalEventLoopMonitoring, + stopGlobalEventLoopMonitoring, + getEventLoopMonitor, +} from './event-loop-monitor.js'; export { HighWaterMarkTracker } from './high-water-mark-tracker.js'; export { RateLimiter } from './rate-limiter.js'; export { ActivityType } from './activity-types.js'; @@ -133,6 +139,7 @@ export { recordStartupPerformance, recordMemoryUsage, recordCpuUsage, + recordEventLoopDelay, recordToolQueueDepth, recordToolExecutionBreakdown, recordTokenEfficiency, diff --git a/packages/core/src/telemetry/metrics.ts b/packages/core/src/telemetry/metrics.ts index 422f0222a5..377479c1e4 100644 --- a/packages/core/src/telemetry/metrics.ts +++ b/packages/core/src/telemetry/metrics.ts @@ -88,6 +88,7 @@ const GEN_AI_CLIENT_OPERATION_DURATION = 'gen_ai.client.operation.duration'; const STARTUP_TIME = 'gemini_cli.startup.duration'; const MEMORY_USAGE = 'gemini_cli.memory.usage'; const CPU_USAGE = 'gemini_cli.cpu.usage'; +const EVENT_LOOP_DELAY = 'gemini_cli.event_loop.delay'; const TOOL_QUEUE_DEPTH = 'gemini_cli.tool.queue.depth'; const TOOL_EXECUTION_BREAKDOWN = 'gemini_cli.tool.execution.breakdown'; const TOKEN_EFFICIENCY = 'gemini_cli.token.efficiency'; @@ -608,6 +609,17 @@ const PERFORMANCE_HISTOGRAM_DEFINITIONS = { component?: string; }, }, + [EVENT_LOOP_DELAY]: { + description: 'Event loop delay in milliseconds.', + unit: 'ms', + valueType: ValueType.DOUBLE, + assign: (h: Histogram) => (eventLoopDelayHistogram = h), + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + attributes: {} as { + percentile: string; + component?: string; + }, + }, [TOOL_QUEUE_DEPTH]: { description: 'Number of tools in execution queue.', unit: 'count', @@ -806,6 +818,7 @@ let genAiClientOperationDurationHistogram: Histogram | undefined; let startupTimeHistogram: Histogram | undefined; let memoryUsageGauge: Histogram | undefined; // Using Histogram until ObservableGauge is available let cpuUsageGauge: Histogram | undefined; +let eventLoopDelayHistogram: Histogram | undefined; let toolQueueDepthGauge: Histogram | undefined; let toolExecutionBreakdownHistogram: Histogram | undefined; let tokenEfficiencyHistogram: Histogram | undefined; @@ -1339,6 +1352,21 @@ export function recordCpuUsage( cpuUsageGauge.record(percentage, metricAttributes); } +export function recordEventLoopDelay( + config: Config, + delayMs: number, + attributes: MetricDefinitions[typeof EVENT_LOOP_DELAY]['attributes'], +): void { + if (!eventLoopDelayHistogram || !isPerformanceMonitoringEnabled) return; + + const metricAttributes: Attributes = { + ...baseMetricDefinition.getCommonAttributes(config), + ...attributes, + }; + + eventLoopDelayHistogram.record(delayMs, metricAttributes); +} + export function recordToolQueueDepth(config: Config, queueDepth: number): void { if (!toolQueueDepthGauge || !isPerformanceMonitoringEnabled) return; diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index bafa540790..ac90bf86ad 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -52,6 +52,11 @@ import { } from './gcp-exporters.js'; import { TelemetryTarget } from './index.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { + startGlobalMemoryMonitoring, + getMemoryMonitor, +} from './memory-monitor.js'; +import { startGlobalEventLoopMonitoring } from './event-loop-monitor.js'; import { authEvents } from '../code_assist/oauth2.js'; import { coreEvents, CoreEvent } from '../utils/events.js'; import { @@ -91,6 +96,7 @@ diag.setLogger(new DiagLoggerAdapter(), DiagLogLevel.INFO); let sdk: NodeSDK | undefined; let spanProcessor: BatchSpanProcessor | undefined; let logRecordProcessor: BatchLogRecordProcessor | undefined; +let metricReader: PeriodicExportingMetricReader | undefined; let telemetryInitialized = false; let callbackRegistered = false; let authListener: ((newCredentials: JWTInput) => Promise) | undefined = @@ -258,7 +264,6 @@ export async function initializeTelemetry( | GcpLogExporter | FileLogExporter | ConsoleLogRecordExporter; - let metricReader: PeriodicExportingMetricReader; if (useDirectGcpExport) { debugLogger.log( @@ -346,6 +351,26 @@ export async function initializeTelemetry( } activeTelemetryEmail = credentials?.client_email; initializeMetrics(config); + + // Start memory monitoring if interval is specified via environment variable + const monitorInterval = process.env['GEMINI_MEMORY_MONITOR_INTERVAL']; + debugLogger.log( + `[TELEMETRY] GEMINI_MEMORY_MONITOR_INTERVAL: ${monitorInterval}`, + ); + if (monitorInterval) { + const intervalMs = parseInt(monitorInterval, 10); + if (!isNaN(intervalMs) && intervalMs > 0) { + startGlobalMemoryMonitoring(config, intervalMs); + startGlobalEventLoopMonitoring(config, intervalMs); + // Disable enhanced monitoring (rate limiting/high water mark) in tests + // to ensure we get regular snapshots regardless of growth. + const monitor = getMemoryMonitor(); + if (monitor) { + monitor.setEnhancedMonitoring(false); + } + } + } + telemetryInitialized = true; void flushTelemetryBuffer(); } catch (error) { @@ -378,6 +403,7 @@ export async function flushTelemetry(config: Config): Promise { await Promise.all([ spanProcessor.forceFlush(), logRecordProcessor.forceFlush(), + metricReader ? metricReader.forceFlush() : Promise.resolve(), ]); if (config.getDebugMode()) { debugLogger.log('OpenTelemetry SDK flushed successfully.'); diff --git a/packages/core/src/utils/getFolderStructure.ts b/packages/core/src/utils/getFolderStructure.ts index 5a2f99d729..5e7adc9d5b 100644 --- a/packages/core/src/utils/getFolderStructure.ts +++ b/packages/core/src/utils/getFolderStructure.ts @@ -113,7 +113,9 @@ async function readFullStructure( } catch (error: unknown) { if ( isNodeError(error) && - (error.code === 'EACCES' || error.code === 'ENOENT') + (error.code === 'EACCES' || + error.code === 'ENOENT' || + error.code === 'EPERM') ) { debugLogger.warn( `Warning: Could not read directory ${currentPath}: ${error.message}`, @@ -121,7 +123,7 @@ async function readFullStructure( if (currentPath === rootPath && error.code === 'ENOENT') { return null; // Root directory itself not found } - // For other EACCES/ENOENT on subdirectories, just skip them. + // For other EACCES/ENOENT/EPERM on subdirectories, just skip them. continue; } throw error; diff --git a/packages/core/src/utils/googleQuotaErrors.test.ts b/packages/core/src/utils/googleQuotaErrors.test.ts index 90769def35..72cc47ff1e 100644 --- a/packages/core/src/utils/googleQuotaErrors.test.ts +++ b/packages/core/src/utils/googleQuotaErrors.test.ts @@ -81,6 +81,32 @@ describe('classifyGoogleError', () => { } }); + it('should return RetryableQuotaError with delay for 503 Service Unavailable with RetryInfo', () => { + const apiError: GoogleApiError = { + code: 503, + message: + 'No capacity available for model gemini-3.1-pro-preview on the server', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'MODEL_CAPACITY_EXHAUSTED', + domain: 'cloudcode-pa.googleapis.com', + metadata: { + model: 'gemini-3.1-pro-preview', + }, + }, + { + '@type': 'type.googleapis.com/google.rpc.RetryInfo', + retryDelay: '9s', + }, + ], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const result = classifyGoogleError(new Error()); + expect(result).toBeInstanceOf(RetryableQuotaError); + expect((result as RetryableQuotaError).retryDelayMs).toBe(9000); + }); + it('should return original error if code is not 429, 499 or 503', () => { const apiError: GoogleApiError = { code: 500, diff --git a/packages/core/src/utils/googleQuotaErrors.ts b/packages/core/src/utils/googleQuotaErrors.ts index 5a0bf48092..ce7a88b302 100644 --- a/packages/core/src/utils/googleQuotaErrors.ts +++ b/packages/core/src/utils/googleQuotaErrors.ts @@ -14,6 +14,14 @@ import { } from './googleErrors.js'; import { getErrorStatus, ModelNotFoundError } from './httpErrors.js'; +// Enum for Google API type strings +enum GoogleApiType { + ERROR_INFO = 'type.googleapis.com/google.rpc.ErrorInfo', + HELP = 'type.googleapis.com/google.rpc.Help', + QUOTA_FAILURE = 'type.googleapis.com/google.rpc.QuotaFailure', + RETRY_INFO = 'type.googleapis.com/google.rpc.RetryInfo', +} + /** * A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit). */ @@ -136,8 +144,7 @@ function classifyValidationRequiredError( googleApiError: GoogleApiError, ): ValidationRequiredError | null { const errorInfo = googleApiError.details.find( - (d): d is ErrorInfo => - d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo', + (d): d is ErrorInfo => d['@type'] === GoogleApiType.ERROR_INFO, ); if (!errorInfo) { @@ -154,7 +161,7 @@ function classifyValidationRequiredError( // Try to extract validation info from Help detail first const helpDetail = googleApiError.details.find( - (d): d is Help => d['@type'] === 'type.googleapis.com/google.rpc.Help', + (d): d is Help => d['@type'] === GoogleApiType.HELP, ); let validationLink: string | undefined; @@ -198,12 +205,13 @@ function classifyValidationRequiredError( * - 404 errors are classified as `ModelNotFoundError`. * - 403 errors with `VALIDATION_REQUIRED` from cloudcode-pa domains are classified * as `ValidationRequiredError`. - * - 429 errors are classified as either `TerminalQuotaError` or `RetryableQuotaError`: + * - 429 or 499 errors are classified as either `TerminalQuotaError` or `RetryableQuotaError`: * - CloudCode API: `RATE_LIMIT_EXCEEDED` → `RetryableQuotaError`, `QUOTA_EXHAUSTED` → `TerminalQuotaError`. * - If the error indicates a daily limit (in QuotaFailure), it's a `TerminalQuotaError`. * - If the error has a retry delay, it's a `RetryableQuotaError`. * - If the error indicates a per-minute limit, it's a `RetryableQuotaError`. * - If the error message contains the phrase "Please retry in X[s|ms]", it's a `RetryableQuotaError`. + * - 503 errors are classified as `RetryableQuotaError`. * * @param error The error to classify. * @returns A classified error or the original `unknown` error. @@ -227,24 +235,11 @@ export function classifyGoogleError(error: unknown): unknown { } } - // Check for 503 Service Unavailable errors - if (status === 503) { - const errorMessage = - googleApiError?.message || - (error instanceof Error ? error.message : String(error)); - return new RetryableQuotaError( - errorMessage, - googleApiError ?? { - code: 503, - message: errorMessage, - details: [], - }, - ); - } - if ( !googleApiError || - (googleApiError.code !== 429 && googleApiError.code !== 499) || + (googleApiError.code !== 429 && + googleApiError.code !== 499 && + googleApiError.code !== 503) || googleApiError.details.length === 0 ) { // Fallback: try to parse the error message for a retry delay @@ -265,9 +260,9 @@ export function classifyGoogleError(error: unknown): unknown { } return new RetryableQuotaError(errorMessage, cause, retryDelaySeconds); } - } else if (status === 429 || status === 499) { - // Fallback: If it is a 429 or 499 but doesn't have a specific "retry in" message, - // assume it is a temporary rate limit and retry after 5 sec (same as DEFAULT_RETRY_OPTIONS). + } else if (status === 429 || status === 499 || status === 503) { + // Fallback: If it is a 429, 499, or 503 but doesn't have a specific "retry in" message, + // assume it is a temporary rate limit and retry. return new RetryableQuotaError( errorMessage, googleApiError ?? { @@ -282,18 +277,15 @@ export function classifyGoogleError(error: unknown): unknown { } const quotaFailure = googleApiError.details.find( - (d): d is QuotaFailure => - d['@type'] === 'type.googleapis.com/google.rpc.QuotaFailure', + (d): d is QuotaFailure => d['@type'] === GoogleApiType.QUOTA_FAILURE, ); const errorInfo = googleApiError.details.find( - (d): d is ErrorInfo => - d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo', + (d): d is ErrorInfo => d['@type'] === GoogleApiType.ERROR_INFO, ); const retryInfo = googleApiError.details.find( - (d): d is RetryInfo => - d['@type'] === 'type.googleapis.com/google.rpc.RetryInfo', + (d): d is RetryInfo => d['@type'] === GoogleApiType.RETRY_INFO, ); // 1. Check for long-term limits in QuotaFailure or ErrorInfo @@ -321,7 +313,7 @@ export function classifyGoogleError(error: unknown): unknown { // INSUFFICIENT_G1_CREDITS_BALANCE is always terminal, regardless of domain if (errorInfo.reason === 'INSUFFICIENT_G1_CREDITS_BALANCE') { return new TerminalQuotaError( - `${googleApiError.message}`, + googleApiError.message, googleApiError, delaySeconds, errorInfo.reason, @@ -335,21 +327,21 @@ export function classifyGoogleError(error: unknown): unknown { const effectiveDelay = delaySeconds ?? 10; if (effectiveDelay > MAX_RETRYABLE_DELAY_SECONDS) { return new TerminalQuotaError( - `${googleApiError.message}`, + googleApiError.message, googleApiError, effectiveDelay, errorInfo.reason, ); } return new RetryableQuotaError( - `${googleApiError.message}`, + googleApiError.message, googleApiError, effectiveDelay, ); } if (errorInfo.reason === 'QUOTA_EXHAUSTED') { return new TerminalQuotaError( - `${googleApiError.message}`, + googleApiError.message, googleApiError, delaySeconds, errorInfo.reason, @@ -400,19 +392,10 @@ export function classifyGoogleError(error: unknown): unknown { } } - // If we reached this point and the status is still 429 or 499, we return retryable. - if (status === 429 || status === 499) { - const errorMessage = - googleApiError?.message || - (error instanceof Error ? error.message : String(error)); - return new RetryableQuotaError( - errorMessage, - googleApiError ?? { - code: status, - message: errorMessage, - details: [], - }, - ); - } - return error; // Fallback to original error if no specific classification fits. + // If we reached this point, the status is 429, 499, or 503 and we have details, + // but no specific violation was matched. We return a generic retryable error. + const errorMessage = + googleApiError.message || + (error instanceof Error ? error.message : String(error)); + return new RetryableQuotaError(errorMessage, googleApiError); } diff --git a/packages/core/src/utils/oauth-flow.test.ts b/packages/core/src/utils/oauth-flow.test.ts index dee919c249..b4f28890e4 100644 --- a/packages/core/src/utils/oauth-flow.test.ts +++ b/packages/core/src/utils/oauth-flow.test.ts @@ -305,6 +305,28 @@ describe('oauth-flow', () => { 'Invalid value for OAUTH_CALLBACK_PORT', ); }); + + it('should settle on timeout without keeping the process alive', async () => { + vi.useFakeTimers(); + try { + const server = startCallbackServer('timeout-state'); + await server.port; + + const responsePromise = server.response.catch((e: Error) => { + if (e.message !== 'OAuth callback timeout') throw e; + return e; + }); + + // Advance timers by 5 minutes to trigger the timeout + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + + const error = await responsePromise; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('OAuth callback timeout'); + } finally { + vi.useRealTimers(); + } + }); }); describe('exchangeCodeForToken', () => { diff --git a/packages/core/src/utils/oauth-flow.ts b/packages/core/src/utils/oauth-flow.ts index e13fd37837..67062c9ec5 100644 --- a/packages/core/src/utils/oauth-flow.ts +++ b/packages/core/src/utils/oauth-flow.ts @@ -116,6 +116,8 @@ export function startCallbackServer( portReject = reject; }); + let timeoutId: NodeJS.Timeout | undefined; + const responsePromise = new Promise( (resolve, reject) => { let serverPort: number; @@ -221,18 +223,31 @@ export function startCallbackServer( portResolve(serverPort); // Resolve port promise immediately }); - // Timeout after 5 minutes - setTimeout( + const abortController = new AbortController(); + timeoutId = setTimeout( () => { - server.close(); - reject(new Error('OAuth callback timeout')); + abortController.abort(new Error('OAuth callback timeout')); }, 5 * 60 * 1000, ); + timeoutId.unref(); + + const onAbort = () => { + server.close(); + reject(abortController.signal.reason); + }; + abortController.signal.addEventListener('abort', onAbort, { once: true }); + + server.on('close', () => { + abortController.signal.removeEventListener('abort', onAbort); + }); }, ); - return { port: portPromise, response: responsePromise }; + return { + port: portPromise, + response: responsePromise, + }; } /** diff --git a/packages/core/src/utils/sessionOperations.ts b/packages/core/src/utils/sessionOperations.ts index 24ff43aa00..8a6da85d8e 100644 --- a/packages/core/src/utils/sessionOperations.ts +++ b/packages/core/src/utils/sessionOperations.ts @@ -98,8 +98,11 @@ export async function deleteSubagentSessionDirAndArtifactsAsync( }); for (const file of files) { - if (file.isFile() && file.name.endsWith('.json')) { - const agentId = path.basename(file.name, '.json'); + if ( + file.isFile() && + (file.name.endsWith('.json') || file.name.endsWith('.jsonl')) + ) { + const agentId = path.basename(file.name, path.extname(file.name)); await deleteSessionArtifactsAsync(agentId, tempDir); } } diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index 6e713c0fe1..dba25ca444 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -10,6 +10,7 @@ import { createSessionId, type ResumedSessionData, type ConversationRecord, + loadConversationRecord, } from '@google/gemini-cli-core'; import { GeminiCliSession } from './session.js'; @@ -55,9 +56,11 @@ export class GeminiCliAgent { const filesToCheck = candidates.length > 0 ? candidates : sessions; for (const sessionFile of filesToCheck) { - const loaded = await storage.loadProjectTempFile( + const absolutePath = path.join( + storage.getProjectTempDir(), sessionFile.filePath, ); + const loaded = await loadConversationRecord(absolutePath); if (loaded && loaded.sessionId === sessionId) { conversation = loaded; filePath = path.join(storage.getProjectTempDir(), sessionFile.filePath); diff --git a/packages/test-utils/src/perf-test-harness.ts b/packages/test-utils/src/perf-test-harness.ts index c4625077be..2f376f58b6 100644 --- a/packages/test-utils/src/perf-test-harness.ts +++ b/packages/test-utils/src/perf-test-harness.ts @@ -23,7 +23,6 @@ type PlotFn = (series: number[], config?: PlotConfig) => string; export interface PerfBaseline { wallClockMs: number; cpuTotalUs: number; - eventLoopDelayP99Ms: number; timestamp: string; } @@ -48,8 +47,10 @@ export interface PerfSnapshot { cpuTotalUs: number; eventLoopDelayP50Ms: number; eventLoopDelayP95Ms: number; - eventLoopDelayP99Ms: number; eventLoopDelayMaxMs: number; + childEventLoopDelayP50Ms?: number; + childEventLoopDelayP95Ms?: number; + childEventLoopDelayMaxMs?: number; } /** @@ -159,7 +160,6 @@ export class PerfTestHarness { cpuTotalUs: cpuDelta.user + cpuDelta.system, eventLoopDelayP50Ms: 0, eventLoopDelayP95Ms: 0, - eventLoopDelayP99Ms: 0, eventLoopDelayMaxMs: 0, }; } @@ -196,7 +196,6 @@ export class PerfTestHarness { // Convert from nanoseconds to milliseconds snapshot.eventLoopDelayP50Ms = histogram.percentile(50) / 1e6; snapshot.eventLoopDelayP95Ms = histogram.percentile(95) / 1e6; - snapshot.eventLoopDelayP99Ms = histogram.percentile(99) / 1e6; snapshot.eventLoopDelayMaxMs = histogram.max / 1e6; return snapshot; @@ -305,7 +304,6 @@ export class PerfTestHarness { ` Baseline: ${result.baseline.wallClockMs.toFixed(1)} ms wall-clock\n` + ` Delta: ${deltaPercent.toFixed(1)}% (tolerance: ${tolerance}%)\n` + ` CPU total: ${formatUs(result.median.cpuTotalUs)}\n` + - ` EL p99: ${result.median.eventLoopDelayP99Ms.toFixed(1)} ms\n` + ` Samples: ${result.samples.length} (${result.filteredSamples.length} after IQR filter)`, ); } @@ -316,8 +314,7 @@ export class PerfTestHarness { ` Measured: ${formatUs(result.median.cpuTotalUs)}\n` + ` Baseline: ${formatUs(result.baseline.cpuTotalUs)}\n` + ` Delta: ${result.cpuDeltaPercent.toFixed(1)}% (tolerance: ${cpuTolerance}%)\n` + - ` Wall-clock: ${result.median.wallClockMs.toFixed(1)} ms\n` + - ` EL p99: ${result.median.eventLoopDelayP99Ms.toFixed(1)} ms`, + ` Wall-clock: ${result.median.wallClockMs.toFixed(1)} ms`, ); } } @@ -329,7 +326,6 @@ export class PerfTestHarness { updatePerfBaseline(this.baselinesPath, result.scenarioName, { wallClockMs: result.median.wallClockMs, cpuTotalUs: result.median.cpuTotalUs, - eventLoopDelayP99Ms: result.median.eventLoopDelayP99Ms, }); // Reload baselines after update this.baselines = loadPerfBaselines(this.baselinesPath); @@ -375,9 +371,18 @@ export class PerfTestHarness { ` CPU: ${cpuMs} (user: ${formatUs(result.median.cpuUserUs)}, system: ${formatUs(result.median.cpuSystemUs)})`, ); - if (result.median.eventLoopDelayP99Ms > 0) { + if (result.median.eventLoopDelayMaxMs > 0) { lines.push( - ` Event loop: p50=${result.median.eventLoopDelayP50Ms.toFixed(1)}ms p95=${result.median.eventLoopDelayP95Ms.toFixed(1)}ms p99=${result.median.eventLoopDelayP99Ms.toFixed(1)}ms max=${result.median.eventLoopDelayMaxMs.toFixed(1)}ms`, + ` Event loop (runner): p50=${result.median.eventLoopDelayP50Ms.toFixed(1)}ms p95=${result.median.eventLoopDelayP95Ms.toFixed(1)}ms max=${result.median.eventLoopDelayMaxMs.toFixed(1)}ms`, + ); + } + + if ( + result.median.childEventLoopDelayMaxMs !== undefined && + result.median.childEventLoopDelayMaxMs > 0 + ) { + lines.push( + ` Event loop (CLI): p50=${result.median.childEventLoopDelayP50Ms!.toFixed(1)}ms p95=${result.median.childEventLoopDelayP95Ms!.toFixed(1)}ms max=${result.median.childEventLoopDelayMaxMs!.toFixed(1)}ms`, ); } @@ -517,14 +522,12 @@ export function updatePerfBaseline( measured: { wallClockMs: number; cpuTotalUs: number; - eventLoopDelayP99Ms: number; }, ): void { const baselines = loadPerfBaselines(path); baselines.scenarios[scenarioName] = { wallClockMs: measured.wallClockMs, cpuTotalUs: measured.cpuTotalUs, - eventLoopDelayP99Ms: measured.eventLoopDelayP99Ms, timestamp: new Date().toISOString(), }; savePerfBaselines(path, baselines); diff --git a/perf-tests/baselines.json b/perf-tests/baselines.json index a6bad73574..1dd52a5213 100644 --- a/perf-tests/baselines.json +++ b/perf-tests/baselines.json @@ -1,24 +1,26 @@ { "version": 1, - "updatedAt": "2026-04-08T18:51:29.839Z", + "updatedAt": "2026-04-09T02:30:22.000Z", "scenarios": { "cold-startup-time": { - "wallClockMs": 1333.4230420000004, - "cpuTotalUs": 1711, - "eventLoopDelayP99Ms": 0, - "timestamp": "2026-04-08T18:50:58.124Z" + "wallClockMs": 927.553249999999, + "cpuTotalUs": 1470, + "timestamp": "2026-04-08T22:27:54.871Z" }, "idle-cpu-usage": { - "wallClockMs": 5001.926125, - "cpuTotalUs": 128518, - "eventLoopDelayP99Ms": 12.705791, - "timestamp": "2026-04-08T18:51:23.938Z" + "wallClockMs": 5000.460750000002, + "cpuTotalUs": 12157, + "timestamp": "2026-04-08T22:28:19.098Z" }, "skill-loading-time": { - "wallClockMs": 1372.4463749999995, - "cpuTotalUs": 1550, - "eventLoopDelayP99Ms": 0, - "timestamp": "2026-04-08T18:51:29.839Z" + "wallClockMs": 930.0920409999962, + "cpuTotalUs": 1323, + "timestamp": "2026-04-08T22:28:23.290Z" + }, + "high-volume-shell-output": { + "wallClockMs": 1119.9, + "cpuTotalUs": 2100, + "timestamp": "2026-04-09T02:30:22.000Z" } } } diff --git a/perf-tests/perf-usage.test.ts b/perf-tests/perf-usage.test.ts index 3f92cd9f91..1a361eda5d 100644 --- a/perf-tests/perf-usage.test.ts +++ b/perf-tests/perf-usage.test.ts @@ -8,6 +8,7 @@ import { describe, it, beforeAll, afterAll } from 'vitest'; import { TestRig, PerfTestHarness } from '@google/gemini-cli-test-utils'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { existsSync, readFileSync } from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const BASELINES_PATH = join(__dirname, 'baselines.json'); @@ -33,7 +34,7 @@ describe('CPU Performance Tests', () => { afterAll(async () => { // Generate the summary report after all tests await harness.generateReport(); - }); + }, 30000); it('cold-startup-time: startup completes within baseline', async () => { const result = await harness.runScenario('cold-startup-time', async () => { @@ -150,4 +151,119 @@ describe('CPU Performance Tests', () => { harness.assertWithinBaseline(result); } }); + + it('high-volume-shell-output: handles large output efficiently', async () => { + const result = await harness.runScenario( + 'high-volume-shell-output', + async () => { + const rig = new TestRig(); + try { + rig.setup('perf-high-volume-output', { + fakeResponsesPath: join(__dirname, 'perf.high-volume.responses'), + }); + + const snapshot = await harness.measureWithEventLoop( + 'high-volume-output', + async () => { + const runResult = await rig.run({ + args: ['Generate 1M lines of output'], + timeout: 120000, + env: { + GEMINI_API_KEY: 'fake-perf-test-key', + GEMINI_TELEMETRY_ENABLED: 'true', + GEMINI_MEMORY_MONITOR_INTERVAL: '500', + GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true', + DEBUG: 'true', + }, + }); + console.log(` Child Process Output:`, runResult); + }, + ); + + // Query CLI's own performance metrics from telemetry logs + await rig.waitForTelemetryReady(); + + // Debug: Read and log the telemetry file content + try { + const logFilePath = join(rig.homeDir!, 'telemetry.log'); + if (existsSync(logFilePath)) { + const content = readFileSync(logFilePath, 'utf-8'); + console.log(` Telemetry Log Content:\n`, content); + } else { + console.log(` Telemetry log file not found at: ${logFilePath}`); + } + } catch (e) { + console.error(` Failed to read telemetry log:`, e); + } + + const memoryMetric = rig.readMetric('memory.usage'); + const cpuMetric = rig.readMetric('cpu.usage'); + const toolLatencyMetric = rig.readMetric('tool.call.latency'); + const eventLoopMetric = rig.readMetric('event_loop.delay'); + + if (memoryMetric) { + console.log( + ` CLI Memory Metric found:`, + JSON.stringify(memoryMetric), + ); + } + if (cpuMetric) { + console.log(` CLI CPU Metric found:`, JSON.stringify(cpuMetric)); + } + if (toolLatencyMetric) { + console.log( + ` CLI Tool Latency Metric found:`, + JSON.stringify(toolLatencyMetric), + ); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const logs = (rig as any)._readAndParseTelemetryLog(); + console.log(` Total telemetry log entries: ${logs.length}`); + for (const logData of logs) { + if (logData.scopeMetrics) { + for (const scopeMetric of logData.scopeMetrics) { + for (const metric of scopeMetric.metrics) { + if (metric.descriptor.name.includes('event_loop')) { + console.log( + ` Found event_loop metric in log:`, + metric.descriptor.name, + ); + } + } + } + } + } + + if (eventLoopMetric) { + console.log( + ` CLI Event Loop Metric found:`, + JSON.stringify(eventLoopMetric), + ); + + const findValue = (percentile: string) => { + const dp = eventLoopMetric.dataPoints.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (p: any) => p.attributes.percentile === percentile, + ); + return dp ? dp.value.min : undefined; + }; + + snapshot.childEventLoopDelayP50Ms = findValue('p50'); + snapshot.childEventLoopDelayP95Ms = findValue('p95'); + snapshot.childEventLoopDelayMaxMs = findValue('max'); + } + + return snapshot; + } finally { + await rig.cleanup(); + } + }, + ); + + if (UPDATE_BASELINES) { + harness.updateScenarioBaseline(result); + } else { + harness.assertWithinBaseline(result); + } + }); }); diff --git a/perf-tests/perf.high-volume.responses b/perf-tests/perf.high-volume.responses new file mode 100644 index 0000000000..74f5972db9 --- /dev/null +++ b/perf-tests/perf.high-volume.responses @@ -0,0 +1,3 @@ +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"command":"yes | head -n 1000000"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have generated 1M lines of output."}],"role":"model"},"finishReason":"STOP","index":0}]}]} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 1281d0f429..98bc786410 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -230,6 +230,13 @@ "default": {}, "type": "object", "properties": { + "debugRainbow": { + "title": "Debug Rainbow", + "description": "Enable debug rainbow rendering. Only useful for debugging rendering bugs and performance issues.", + "markdownDescription": "Enable debug rainbow rendering. Only useful for debugging rendering bugs and performance issues.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "theme": { "title": "Theme", "description": "The color theme for the UI. See the CLI themes guide for available options.",