diff --git a/.gemini/skills/pr-address-comments/SKILL.md b/.gemini/skills/pr-address-comments/SKILL.md new file mode 100644 index 0000000000..bd190feffa --- /dev/null +++ b/.gemini/skills/pr-address-comments/SKILL.md @@ -0,0 +1,13 @@ +--- +name: pr-address-comments +description: Use this skill if the user asks you to help them address GitHub PR comments for their current branch of the Gemini CLI. Requires `gh` CLI tool. +--- +You are helping the user address comments on their Pull Request. These comments may have come from an automated review agent or a team member. + +OBJECTIVE: Help the user review and address comments on their PR. + +# Comment Review Procedure + +1. Run the `scripts/fetch-pr-info.js` script to get PR info and state. MAKE SURE you read the entire output of the command, even if it gets truncated. +2. Summarize the review status by analyzing the diff, commit log, and comments to see which still need to be addressed. Pay attention to the current user's comments. For resolved threads, summarize as a single line with a ✅. For open threads, provide a reference number e.g. [1] and the comment content. +3. Present your summary of the feedback and current state and allow the user to guide you as to what to fix/address/skip. DO NOT begin fixing issues automatically. diff --git a/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js new file mode 100755 index 0000000000..de99def0ce --- /dev/null +++ b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js @@ -0,0 +1,161 @@ +#!/usr/bin/env node +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-env node */ +/* global console, process */ + +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execAsync = promisify(exec); + +async function run(cmd) { + try { + const { stdout } = await execAsync(cmd, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + }); + return stdout.trim(); + } catch (_e) { + // eslint-disable-line @typescript-eslint/no-unused-vars + return null; + } +} + +const IGNORE_MESSAGES = [ + 'thank you so much for your contribution to Gemini CLI!', + "I'm currently reviewing this pull request and will post my feedback shortly.", + 'This pull request is being closed because it is not currently linked to an issue.', +]; + +const shouldIgnore = (body) => { + if (!body) return false; + return IGNORE_MESSAGES.some((msg) => body.includes(msg)); +}; + +async function main() { + const branch = await run('git branch --show-current'); + if (!branch) { + console.error('❌ Could not determine current git branch.'); + process.exit(1); + } + + const gqlQuery = `query($branch:String!){repository(name:"gemini-cli",owner:"google-gemini"){pullRequests(headRefName:$branch,first:100){nodes{id,number,state,comments(first:100){nodes{createdAt,isMinimized,minimizedReason,author{login},body,url,authorAssociation}},reviews(first:100){nodes{id,author{login},createdAt,isMinimized,minimizedReason,body,state,comments(first:30){nodes{id,replyTo{id},author{login},createdAt,body,isMinimized,minimizedReason,path,line,startLine,originalLine,originalStartLine}}}}}}}}`; + + const [authInfo, diff, commits, rawJson] = await Promise.all([ + run('gh auth status -a'), + run('gh pr diff'), + run( + 'git fetch && git log origin/main..origin/$(git branch --show-current)', + ), + run(`gh api graphql -F branch="${branch}" -f query='${gqlQuery}'`), + ]); + + if (!diff) { + console.error(`⚠️ No active PR found for branch: ${branch}`); + process.exit(1); + } + + console.log(`\n# Current GitHub user info:\n\n${authInfo}\n`); + console.log(`\n# PR diff for current branch: ${branch}\n\n\`\`\``); + console.log(diff); + console.log('```'); + console.log( + `\n# Commit history (origin/main..origin/${branch})\n\n${commits}`, + ); + + const data = JSON.parse(rawJson || '{}'); + const prs = data?.data?.repository?.pullRequests?.nodes || []; + + // Sort PRs by number descending so we check the newest one first + prs.sort((a, b) => b.number - a.number); + + const pr = prs.find((p) => p.state === 'OPEN') || prs[0]; + + if (!pr) { + console.error('❌ No PR data found.'); + process.exit(1); + } + + console.log('\n# PR Feedback\n'); + + // 1. General PR Comments + const general = pr.comments.nodes.filter((c) => !shouldIgnore(c.body)); + if (general.length > 0) { + console.log('\n💬 GENERAL COMMENTS:'); + general.forEach((c) => { + const minimized = c.isMinimized + ? ` (Minimized: ${c.minimizedReason})` + : ''; + console.log( + `[${c.createdAt}] [${c.author.login}]${minimized}: ${c.body}\n`, + ); + }); + } + + // 2. Process ALL Review Comments into a single Thread Map + const allInlineComments = pr.reviews.nodes.flatMap((r) => r.comments.nodes); + const filteredInlines = allInlineComments.filter( + (c) => !shouldIgnore(c.body), + ); + + console.log('🔍 CODE REVIEWS & INLINE THREADS:'); + + // Print Review Summaries First + pr.reviews.nodes.forEach((review) => { + if (review.body && !shouldIgnore(review.body)) { + const icon = review.state === 'APPROVED' ? '✅' : '💬'; + const minimized = review.isMinimized + ? ` (Minimized: ${review.minimizedReason})` + : ''; + console.log( + `\n${icon} ${review.state} by ${review.author.login} at ${review.createdAt}${minimized}: "${review.body}"`, + ); + } + }); + + // Build and Print Threads + const topLevelThreads = filteredInlines.filter((c) => !c.replyTo); + + const printThread = (parentId, depth = 1) => { + const indent = ' '.repeat(depth); + filteredInlines + .filter((c) => c.replyTo?.id === parentId) + .forEach((reply) => { + const minimized = reply.isMinimized + ? ` (Minimized: ${reply.minimizedReason})` + : ''; + console.log( + `${indent}↳ [${reply.createdAt}] ${reply.author.login}${minimized}: ${reply.body}`, + ); + printThread(reply.id, depth + 1); + }); + }; + + topLevelThreads.forEach((c) => { + const start = c.startLine || c.originalStartLine; + const end = c.line || c.originalLine; + const range = start && end && start !== end ? `${start}-${end}` : end || ''; + const fileInfo = c.path + ? `(${c.path}${range ? `:${range}` : ''}) ` + : range + ? `(Line ${range}) ` + : ''; + const minimized = c.isMinimized ? ` (Minimized: ${c.minimizedReason})` : ''; + console.log( + `\n💬 ${minimized}${c.author.login} | ${c.createdAt} ${fileInfo}\n${c.body}`, + ); + printThread(c.id); + }); + + console.log('\n'); +} + +main().catch((err) => { + console.error('❌ Unexpected error:', err); + process.exit(1); +}); diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index 08b97db0a2..64609b5c3b 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -284,8 +284,21 @@ jobs: return; } } else { - core.setFailed(`Output is not valid JSON and does not contain a JSON markdown block.\nRaw output: ${rawOutput}`); - return; + // If no markdown block, try to find a raw JSON object in the output. + // The CLI may include debug/log lines (e.g. telemetry init, YOLO mode) + // before the actual JSON response. + const jsonObjectMatch = rawOutput.match(/(\{[\s\S]*"labels_to_set"[\s\S]*\})/); + if (jsonObjectMatch) { + try { + parsedLabels = JSON.parse(jsonObjectMatch[0]); + } catch (extractError) { + core.setFailed(`Found JSON-like content but failed to parse: ${extractError.message}\nRaw output: ${rawOutput}`); + return; + } + } else { + core.setFailed(`Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${rawOutput}`); + return; + } } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d8252f86c..7dfe898f14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -546,7 +546,7 @@ Before submitting your documentation pull request, please: If you have questions about contributing documentation: -- Check our [FAQ](/docs/faq.md). +- Check our [FAQ](/docs/resources/faq.md). - Review existing documentation for examples. - Open [an issue](https://github.com/google-gemini/gemini-cli/issues) to discuss your proposed changes. diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 990d116769..3cff4c123b 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -295,7 +295,8 @@ on GitHub. - **Experimental permission improvements:** We are now experimenting with a new policy engine in Gemini CLI. This allows users and administrators to create fine-grained policy for tool calls. Currently behind a flag. See - [policy engine documentation](../core/policy-engine.md) for more information. + [policy engine documentation](../reference/policy-engine.md) for more + information. - Blog: [https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/](https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/) - **Gemini 3 support for paid:** Gemini 3 support has been rolled out to all API diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index e8ac4a2dc9..4cb6a3824b 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,4 +1,4 @@ -# Preview release: v0.30.0-preview.1 +# Preview release: v0.30.0-preview.3 Released: February 19, 2026 @@ -311,4 +311,4 @@ npm install -g @google/gemini-cli@preview [#19008](https://github.com/google-gemini/gemini-cli/pull/19008) **Full changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.1 +https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.3 diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index b44733916f..f8ff24bed6 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -26,29 +26,29 @@ and parameters. ## CLI Options -| Option | Alias | Type | Default | Description | -| -------------------------------- | ----- | ------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging | -| `--version` | `-v` | - | - | Show CLI version number and exit | -| `--help` | `-h` | - | - | Show help information | -| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | -| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. | -| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | -| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | -| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | -| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | -| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** | -| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** | -| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) | -| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../core/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`) | -| `--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) | -| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility | -| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` | +| Option | Alias | Type | Default | Description | +| -------------------------------- | ----- | ------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging | +| `--version` | `-v` | - | - | Show CLI version number and exit | +| `--help` | `-h` | - | - | Show help information | +| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | +| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. | +| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | +| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | +| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | +| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | +| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** | +| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** | +| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) | +| `--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`) | +| `--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) | +| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility | +| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` | ## Model selection diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md index 31f7f89c83..b6d469755b 100644 --- a/docs/cli/enterprise.md +++ b/docs/cli/enterprise.md @@ -20,7 +20,7 @@ The most powerful tools for enterprise administration are the system-wide settings files. These files allow you to define a baseline configuration (`system-defaults.json`) and a set of overrides (`settings.json`) that apply to all users on a machine. For a complete overview of configuration options, see -the [Configuration documentation](../get-started/configuration.md). +the [Configuration documentation](../reference/configuration.md). Settings are merged from four files. The precedence order for single-value settings (like `theme`) is: @@ -224,8 +224,8 @@ gemini You can significantly enhance security by controlling which tools the Gemini model can use. This is achieved through the `tools.core` setting and the -[Policy Engine](../core/policy-engine.md). For a list of available tools, see -the [Tools documentation](../tools/index.md). +[Policy Engine](../reference/policy-engine.md). For a list of available tools, +see the [Tools documentation](../tools/index.md). ### Allowlisting with `coreTools` @@ -245,8 +245,8 @@ on the approved list. ### Blocklisting with `excludeTools` (Deprecated) -> **Deprecated:** Use the [Policy Engine](../core/policy-engine.md) for more -> robust control. +> **Deprecated:** Use the [Policy Engine](../reference/policy-engine.md) for +> more robust control. Alternatively, you can add specific tools that are considered dangerous in your environment to a blocklist. @@ -289,8 +289,8 @@ unintended tool execution. ## Managing custom tools (MCP servers) If your organization uses custom tools via -[Model-Context Protocol (MCP) servers](../core/tools-api.md), it is crucial to -understand how server configurations are managed to apply security policies +[Model-Context Protocol (MCP) servers](../reference/tools-api.md), it is crucial +to understand how server configurations are managed to apply security policies effectively. ### How MCP server configurations are merged diff --git a/docs/cli/gemini-md.md b/docs/cli/gemini-md.md index 01c99972d9..95f46ae095 100644 --- a/docs/cli/gemini-md.md +++ b/docs/cli/gemini-md.md @@ -88,7 +88,7 @@ More content here. @../shared/style-guide.md ``` -For more details, see the [Memory Import Processor](../core/memport.md) +For more details, see the [Memory Import Processor](../reference/memport.md) documentation. ## Customize the context file name diff --git a/docs/cli/model.md b/docs/cli/model.md index fd0e950bbb..62bfcf5b0b 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -39,7 +39,7 @@ To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable You can also use the `--model` flag to specify a particular Gemini model on startup. For more details, refer to the -[configuration documentation](../get-started/configuration.md). +[configuration documentation](../reference/configuration.md). Changes to these settings will be applied to all subsequent interactions with Gemini CLI. diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 1f283a63aa..d5e78f6fb5 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -1,50 +1,61 @@ # Plan Mode (experimental) -Plan Mode is a safe, read-only mode for researching and designing complex -changes. It prevents modifications while you research, design and plan an -implementation strategy. +Plan Mode is a read-only environment for architecting robust solutions before +implementation. It allows you to: -> **Note: Plan Mode is currently an experimental feature.** -> -> Experimental features are subject to change. To use Plan Mode, enable it via -> `/settings` (search for `Plan`) or add the following to your `settings.json`: -> -> ```json -> { -> "experimental": { -> "plan": true -> } -> } -> ``` -> -> Your feedback is invaluable as we refine this feature. If you have ideas, +- **Research:** Explore the project in a read-only state to prevent accidental + changes. +- **Design:** Understand problems, evaluate trade-offs, and choose a solution. +- **Plan:** Align on an execution strategy before any code is modified. + +> **Note:** This is a preview feature currently under active development. Your +> feedback is invaluable as we refine this feature. If you have ideas, > suggestions, or encounter issues: > -> - Use the `/bug` command within the CLI to file an issue. > - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on > GitHub. +> - Use the **/bug** command within Gemini CLI to file an issue. -- [Starting in Plan Mode](#starting-in-plan-mode) +- [Enabling Plan Mode](#enabling-plan-mode) - [How to use Plan Mode](#how-to-use-plan-mode) - [Entering Plan Mode](#entering-plan-mode) - - [The Planning Workflow](#the-planning-workflow) + - [Planning Workflow](#planning-workflow) - [Exiting Plan Mode](#exiting-plan-mode) - [Tool Restrictions](#tool-restrictions) - [Customizing Planning with Skills](#customizing-planning-with-skills) - [Customizing Policies](#customizing-policies) + - [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode) + - [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode) + - [Custom Plan Directory and Policies](#custom-plan-directory-and-policies) -## Starting in Plan Mode +## Enabling Plan Mode -You can configure Gemini CLI to start directly in Plan Mode by default: +To use Plan Mode, enable it via **/settings** (search for **Plan**) or add the +following to your `settings.json`: -1. Type `/settings` in the CLI. -2. Search for `Default Approval Mode`. -3. Set the value to `Plan`. +```json +{ + "experimental": { + "plan": true + } +} +``` -Other ways to start in Plan Mode: +## How to use Plan Mode -- **CLI Flag:** `gemini --approval-mode=plan` -- **Manual Settings:** Manually update your `settings.json`: +### Entering Plan Mode + +You can configure Gemini CLI to start in Plan Mode by default or enter it +manually during a session. + +- **Configuration:** Configure Gemini CLI to start directly in Plan Mode by + default: + 1. Type `/settings` in the CLI. + 2. Search for **Default Approval Mode**. + 3. Set the value to **Plan**. + + Alternatively, use the `gemini --approval-mode=plan` CLI flag or manually + update: ```json { @@ -54,42 +65,44 @@ Other ways to start in Plan Mode: } ``` -## How to use Plan Mode +- **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes + (`Default` -> `Auto-Edit` -> `Plan`). -### Entering Plan Mode + > **Note:** Plan Mode is automatically removed from the rotation when Gemini + > CLI is actively processing or showing confirmation dialogs. -You can enter Plan Mode in three ways: +- **Command:** Type `/plan` in the input box. -1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes - (`Default` -> `Auto-Edit` -> `Plan`). +- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI then + calls the [`enter_plan_mode`] tool to switch modes. + > **Note:** This tool is not available when Gemini CLI is in [YOLO mode]. - > **Note:** Plan Mode is automatically removed from the rotation when the - > agent is actively processing or showing confirmation dialogs. +### Planning Workflow -2. **Command:** Type `/plan` in the input box. -3. **Natural Language:** Ask the agent to "start a plan for...". The agent will - then call the [`enter_plan_mode`] tool to switch modes. - -### The Planning Workflow - -1. **Requirements:** The agent clarifies goals using [`ask_user`]. -2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map - the codebase and validate assumptions. -3. **Design:** The agent proposes alternative approaches with a recommended - solution for you to choose from. -4. **Planning:** A detailed plan is written to a temporary Markdown file. -5. **Review:** You review the plan. - - **Approve:** Exit Plan Mode and start implementation (switching to - Auto-Edit or Default approval mode). +1. **Explore & Analyze:** Analyze requirements and use read-only tools to map + the codebase and validate assumptions. For complex tasks, identify at least + two viable implementation approaches. +2. **Consult:** Present a summary of the identified approaches via [`ask_user`] + to obtain a selection. For simple or canonical tasks, this step may be + skipped. +3. **Draft:** Once an approach is selected, write a detailed implementation + plan to the plans directory. +4. **Review & Approval:** Use the [`exit_plan_mode`] tool to present the plan + and formally request approval. + - **Approve:** Exit Plan Mode and start implementation. - **Iterate:** Provide feedback to refine the plan. +For more complex or specialized planning tasks, you can +[customize the planning workflow with skills](#customizing-planning-with-skills). + ### Exiting Plan Mode -To exit Plan Mode: +To exit Plan Mode, you can: -1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode. -2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the - finalized plan for your approval. +- **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode. + +- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the + finalized plan for your approval. ## Tool Restrictions @@ -102,30 +115,31 @@ These are the only allowed tools: - **Interaction:** [`ask_user`] - **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`, `postgres_read_schema`) are allowed. -- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md` - files in the `~/.gemini/tmp///plans/` directory. +- **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md` + files in the `~/.gemini/tmp///plans/` directory or your + [custom plans directory](#custom-plan-directory-and-policies). - **Skills:** [`activate_skill`] (allows loading specialized instructions and resources in a read-only manner) ### Customizing Planning with Skills -You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI -approaches planning for specific types of tasks. When a skill is activated -during Plan Mode, its specialized instructions and procedural workflows will -guide the research and design phases. +You can use [Agent Skills](./skills.md) to customize how Gemini CLI approaches +planning for specific types of tasks. When a skill is activated during Plan +Mode, its specialized instructions and procedural workflows will guide the +research, design and planning phases. For example: - A **"Database Migration"** skill could ensure the plan includes data safety checks and rollback strategies. -- A **"Security Audit"** skill could prompt the agent to look for specific +- A **"Security Audit"** skill could prompt Gemini CLI to look for specific vulnerabilities during codebase exploration. -- A **"Frontend Design"** skill could guide the agent to use specific UI +- A **"Frontend Design"** skill could guide Gemini CLI to use specific UI components and accessibility standards in its proposal. -To use a skill in Plan Mode, you can explicitly ask the agent to "use the -[skill-name] skill to plan..." or the agent may autonomously activate it based -on the task description. +To use a skill in Plan Mode, you can explicitly ask Gemini CLI to "use the +`` skill to plan..." or Gemini CLI may autonomously activate it +based on the task description. ### Customizing Policies @@ -137,7 +151,7 @@ Because user policies (Tier 2) have a higher base priority than built-in policies (Tier 1), you can override Plan Mode's default restrictions by creating a rule in your `~/.gemini/policies/` directory. -#### Example: Allow `git status` and `git diff` in Plan Mode +#### Example: Allow git commands in Plan Mode This rule allows you to check the repository status and see changes while in Plan Mode. @@ -153,10 +167,10 @@ priority = 100 modes = ["plan"] ``` -#### Example: Enable research sub-agents in Plan Mode +#### Example: Enable research subagents in Plan Mode -You can enable [experimental research sub-agents] like `codebase_investigator` -to help gather architecture details during the planning phase. +You can enable experimental research [subagents] like `codebase_investigator` to +help gather architecture details during the planning phase. `~/.gemini/policies/research-subagents.toml` @@ -168,11 +182,51 @@ priority = 100 modes = ["plan"] ``` -Tell the agent it can use these tools in your prompt, for example: _"You can +Tell Gemini CLI it can use these tools in your prompt, for example: _"You can check ongoing changes in git."_ -For more information on how the policy engine works, see the [Policy Engine -Guide]. +For more information on how the policy engine works, see the [policy engine] +docs. + +### Custom Plan Directory and Policies + +By default, planning artifacts are stored in a managed temporary directory +outside your project: `~/.gemini/tmp///plans/`. + +You can configure a custom directory for plans in your `settings.json`. For +example, to store plans in a `.gemini/plans` directory within your project: + +```json +{ + "general": { + "plan": { + "directory": ".gemini/plans" + } + } +} +``` + +To maintain the safety of Plan Mode, user-configured paths for the plans +directory are restricted to the project root. This ensures that custom planning +locations defined within a project's workspace cannot be used to escape and +overwrite sensitive files elsewhere. Any user-configured directory must reside +within the project boundary. + +Using a custom directory requires updating your [policy engine] configurations +to allow `write_file` and `replace` in that specific location. For example, to +allow writing to the `.gemini/plans` directory within your project, create a +policy file at `~/.gemini/policies/plan-custom-directory.toml`: + +```toml +[[rule]] +toolName = ["write_file", "replace"] +decision = "allow" +priority = 100 +modes = ["plan"] +# Adjust the pattern to match your custom directory. +# This example matches any .md file in a .gemini/plans directory within the project. +argsPattern = "\"file_path\":\"[^\"]*/\\.gemini/plans/[a-zA-Z0-9_-]+\\.md\"" +``` [`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder [`read_file`]: /docs/tools/file-system.md#2-read_file-readfile @@ -183,8 +237,9 @@ Guide]. [`replace`]: /docs/tools/file-system.md#6-replace-edit [MCP tools]: /docs/tools/mcp-server.md [`activate_skill`]: /docs/cli/skills.md -[experimental research sub-agents]: /docs/core/subagents.md -[Policy Engine Guide]: /docs/core/policy-engine.md +[subagents]: /docs/core/subagents.md +[policy engine]: /docs/reference/policy-engine.md [`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode [`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode [`ask_user`]: /docs/tools/ask-user.md +[YOLO mode]: /docs/reference/configuration.md#command-line-arguments diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 9f632693c7..392c71a176 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -167,6 +167,6 @@ gemini -s -p "run shell command: mount | grep workspace" ## Related documentation -- [Configuration](../get-started/configuration.md): Full configuration options. -- [Commands](./commands.md): Available commands. -- [Troubleshooting](../troubleshooting.md): General troubleshooting. +- [Configuration](../reference/configuration.md): Full configuration options. +- [Commands](../reference/commands.md): Available commands. +- [Troubleshooting](../resources/troubleshooting.md): General troubleshooting. diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 318fdbea75..a7689fbcea 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -28,6 +28,7 @@ they appear in the UI. | Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | | Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | | Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | +| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` | | Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | | Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | | Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index ca44bccaf0..0cda8b4528 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -93,7 +93,7 @@ Environment variables can be used to override the settings in the file. `true` or `1` will enable the feature. Any other value will disable it. For detailed information about all configuration options, see the -[Configuration guide](../get-started/configuration.md). +[Configuration guide](../reference/configuration.md). ## Google Cloud telemetry diff --git a/docs/cli/themes.md b/docs/cli/themes.md index dc3423080f..08564a249a 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -41,8 +41,8 @@ can change the theme using the `/theme` command. ### Theme persistence Selected themes are saved in Gemini CLI's -[configuration](../get-started/configuration.md) so your preference is -remembered across sessions. +[configuration](../reference/configuration.md) so your preference is remembered +across sessions. --- @@ -194,7 +194,7 @@ untrusted sources. - Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui` object in your `settings.json`. - Custom themes can be set at the user, project, or system level, and follow the - same [configuration precedence](../get-started/configuration.md) as other + same [configuration precedence](../reference/configuration.md) as other settings. ### Themes from extensions diff --git a/docs/cli/trusted-folders.md b/docs/cli/trusted-folders.md index 7f6e668c24..c271a0dba2 100644 --- a/docs/cli/trusted-folders.md +++ b/docs/cli/trusted-folders.md @@ -38,6 +38,37 @@ folder, a dialog will automatically appear, prompting you to make a choice: Your choice is saved in a central file (`~/.gemini/trustedFolders.json`), so you 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. + +The discovery UI lists the following categories of items found in the project: + +- **Commands**: Custom `.toml` command definitions that add new functionality. +- **MCP Servers**: Configured Model Context Protocol servers that the CLI will + attempt to connect to. +- **Hooks**: System or custom hooks that can intercept and modify CLI behavior. +- **Skills**: Local agent skills that provide specialized capabilities. +- **Setting overrides**: Any project-specific configurations that override your + global user settings. + +### Security warnings and errors + +The trust dialog also highlights critical information that requires your +attention: + +- **Security Warnings**: The CLI will explicitly flag potentially dangerous + 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. + +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" diff --git a/docs/cli/tutorials/memory-management.md b/docs/cli/tutorials/memory-management.md index f679719238..829fbecbd4 100644 --- a/docs/cli/tutorials/memory-management.md +++ b/docs/cli/tutorials/memory-management.md @@ -121,6 +121,6 @@ immediately. Force a reload with: - Learn about [Session management](session-management.md) to see how short-term history works. -- Explore the [Command reference](../../cli/commands.md) for more `/memory` - options. +- Explore the [Command reference](../../reference/commands.md) for more + `/memory` options. - Read the technical spec for [Project context](../../cli/gemini-md.md). diff --git a/docs/cli/tutorials/session-management.md b/docs/cli/tutorials/session-management.md index 775a1e9b5f..7815aa94d6 100644 --- a/docs/cli/tutorials/session-management.md +++ b/docs/cli/tutorials/session-management.md @@ -101,5 +101,5 @@ This creates a new branch of history without losing your original work. - Learn about [Checkpointing](../../cli/checkpointing.md) to understand the underlying safety mechanism. - Explore [Task planning](task-planning.md) to keep complex sessions organized. -- See the [Command reference](../../cli/commands.md) for all `/chat` and +- See the [Command reference](../../reference/commands.md) for all `/chat` and `/resume` options. diff --git a/docs/core/index.md b/docs/core/index.md index 0cd49ad43e..53aa647dc2 100644 --- a/docs/core/index.md +++ b/docs/core/index.md @@ -9,11 +9,11 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the - **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use specialized sub-agents for complex tasks. -- **[Core tools API](./tools-api.md):** Information on how tools are defined, - registered, and used by the core. -- **[Memory Import Processor](./memport.md):** Documentation for the modular - GEMINI.md import feature using @file.md syntax. -- **[Policy Engine](./policy-engine.md):** Use the Policy Engine for +- **[Core tools API](../reference/tools-api.md):** Information on how tools are + defined, registered, and used by the core. +- **[Memory Import Processor](../reference/memport.md):** Documentation for the + modular GEMINI.md import feature using @file.md syntax. +- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for fine-grained control over tool execution. ## Role of the core @@ -92,8 +92,8 @@ 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. -You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and -`refresh` the content of loaded `GEMINI.md` files. +You can use the [`/memory` command](../reference/commands.md) to `show`, `add`, +and `refresh` the content of loaded `GEMINI.md` files. ## Citations diff --git a/docs/core/subagents.md b/docs/core/subagents.md index 7e17da94ae..3619609e95 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -17,7 +17,7 @@ the main agent's context or toolset. > ``` > > **Warning:** Subagents currently operate in -> ["YOLO mode"](../get-started/configuration.md#command-line-arguments), meaning +> ["YOLO mode"](../reference/configuration.md#command-line-arguments), meaning > they may execute tools without individual user confirmation for each step. > Proceed with caution when defining agents with powerful tools like > `run_shell_command` or `write_file`. diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index eec5b82025..b4a0df7336 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -130,7 +130,7 @@ The manifest file defines the extension's behavior and configuration. - `description`: A short summary shown in the extension gallery. - `mcpServers`: A map of Model Context Protocol (MCP) servers. Extension servers follow the same format as standard - [CLI configuration](../get-started/configuration.md). + [CLI configuration](../reference/configuration.md). - `contextFileName`: The name of the context file (defaults to `GEMINI.md`). Can also be an array of strings to load multiple context files. - `excludeTools`: An array of tools to block from the model. You can restrict diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md index 6e2ce5ca05..e8696137cf 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -22,8 +22,8 @@ Select the authentication method that matches your situation in the table below: ### What is my Google account type? - **Individual Google accounts:** Includes all - [free tier accounts](../quota-and-pricing/#free-usage) such as Gemini Code - Assist for individuals, as well as paid subscriptions for + [free tier accounts](../resources/quota-and-pricing.md#free-usage) such as + Gemini Code Assist for individuals, as well as paid subscriptions for [Google AI Pro and Ultra](https://gemini.google/subscriptions/). - **Organization accounts:** Accounts using paid licenses through an @@ -317,5 +317,5 @@ configure authentication using environment variables: Your authentication method affects your quotas, pricing, Terms of Service, and privacy notices. Review the following pages to learn more: -- [Gemini CLI: Quotas and Pricing](../quota-and-pricing.md). -- [Gemini CLI: Terms of Service and Privacy Notice](../tos-privacy.md). +- [Gemini CLI: Quotas and Pricing](../resources/quota-and-pricing.md). +- [Gemini CLI: Terms of Service and Privacy Notice](../resources/tos-privacy.md). diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index 333dbdb68d..a5eed9ab1d 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -2,6 +2,21 @@ Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users! +> **Note:** Gemini 3.1 Pro Preview is rolling out. To determine whether you have +> access to Gemini 3.1, use the `/model` command and select **Manual**. If you +> have access, you will see `gemini-3.1-pro-preview`. +> +> If you have access to Gemini 3.1, it will be included in model routing when +> you select **Auto (Gemini 3)**. You can also launch the Gemini 3.1 model +> directly using the `-m` flag: +> +> ``` +> gemini -m gemini-3.1-pro-preview +> ``` +> +> Learn more about [models](../cli/model.md) and +> [model routing](../cli/model-routing.md). + ## How to get started with Gemini 3 on Gemini CLI Get started by upgrading Gemini CLI to the latest version: @@ -12,9 +27,8 @@ npm install -g @google/gemini-cli@latest After you’ve confirmed your version is 0.21.1 or later: -1. Use the `/settings` command in Gemini CLI. -2. Toggle **Preview Features** to `true`. -3. Run `/model` and select **Auto (Gemini 3)**. +1. Run `/model`. +2. Select **Auto (Gemini 3)**. For more information, see [Gemini CLI model selection](../cli/model.md). diff --git a/docs/get-started/index.md b/docs/get-started/index.md index d5cf891381..4d0158b71f 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -54,7 +54,7 @@ Gemini CLI offers several ways to configure its behavior, including environment variables, command-line arguments, and settings files. To explore your configuration options, see -[Gemini CLI Configuration](./configuration.md). +[Gemini CLI Configuration](../reference/configuration.md). ## Use diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md index 316aacbc29..fd80fc0b40 100644 --- a/docs/hooks/best-practices.md +++ b/docs/hooks/best-practices.md @@ -420,7 +420,7 @@ When you open a project with hooks defined in `.gemini/settings.json`: Hooks inherit the environment of the Gemini CLI process, which may include sensitive API keys. Gemini CLI provides a -[redaction system](/docs/get-started/configuration#environment-variable-redaction) +[redaction system](/docs/reference/configuration.md#environment-variable-redaction) that automatically filters variables matching sensitive patterns (e.g., `KEY`, `TOKEN`). diff --git a/docs/index.md b/docs/index.md index 813cd4047e..81e760fadd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,41 +48,8 @@ User-focused guides and tutorials for daily development workflows. ## Features -Technical reference documentation for each capability of Gemini CLI. +Technical documentation for each capability of Gemini CLI. -- **[/about](./cli/commands.md#about):** About Gemini CLI. -- **[/auth](./get-started/authentication.md):** Authentication. -- **[/bug](./cli/commands.md#bug):** Report a bug. -- **[/chat](./cli/commands.md#chat):** Chat history. -- **[/clear](./cli/commands.md#clear):** Clear screen. -- **[/compress](./cli/commands.md#compress):** Compress context. -- **[/copy](./cli/commands.md#copy):** Copy output. -- **[/directory](./cli/commands.md#directory-or-dir):** Manage workspace. -- **[/docs](./cli/commands.md#docs):** Open documentation. -- **[/editor](./cli/commands.md#editor):** Select editor. -- **[/extensions](./extensions/index.md):** Manage extensions. -- **[/help](./cli/commands.md#help-or):** Show help. -- **[/hooks](./hooks/index.md):** Hooks. -- **[/ide](./ide-integration/index.md):** IDE integration. -- **[/init](./cli/commands.md#init):** Initialize context. -- **[/mcp](./tools/mcp-server.md):** MCP servers. -- **[/memory](./cli/commands.md#memory):** Manage memory. -- **[/model](./cli/model.md):** Model selection. -- **[/policies](./cli/commands.md#policies):** Manage policies. -- **[/privacy](./cli/commands.md#privacy):** Privacy notice. -- **[/quit](./cli/commands.md#quit-or-exit):** Exit CLI. -- **[/restore](./cli/checkpointing.md):** Restore files. -- **[/resume](./cli/commands.md#resume):** Resume session. -- **[/rewind](./cli/rewind.md):** Rewind. -- **[/settings](./cli/settings.md):** Settings. -- **[/setup-github](./cli/commands.md#setup-github):** GitHub setup. -- **[/shells](./cli/commands.md#shells-or-bashes):** Manage processes. -- **[/skills](./cli/skills.md):** Agent skills. -- **[/stats](./cli/commands.md#stats):** Session statistics. -- **[/terminal-setup](./cli/commands.md#terminal-setup):** Terminal keybindings. -- **[/theme](./cli/themes.md):** Themes. -- **[/tools](./cli/commands.md#tools):** List tools. -- **[/vim](./cli/commands.md#vim):** Vim mode. - **[Activate skill (tool)](./tools/activate-skill.md):** Internal mechanism for loading expert procedures. - **[Ask user (tool)](./tools/ask-user.md):** Internal dialog system for @@ -97,12 +64,12 @@ Technical reference documentation for each capability of Gemini CLI. - **[Model routing](./cli/model-routing.md):** Automatic fallback resilience. - **[Plan mode 🧪](./cli/plan-mode.md):** Use a safe, read-only mode for planning complex changes. +- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific + tasks. - **[Remote subagents 🧪](./core/remote-agents.md):** Connecting to and using remote agents. - **[Sandboxing](./cli/sandbox.md):** Isolate tool execution. - **[Shell (tool)](./tools/shell.md):** Detailed system execution parameters. -- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific - tasks. - **[Telemetry](./cli/telemetry.md):** Usage and performance metric details. - **[Todo (tool)](./tools/todos.md):** Progress tracking specification. - **[Token caching](./cli/token-caching.md):** Performance optimization. @@ -134,23 +101,29 @@ Settings and customization options for Gemini CLI. Deep technical documentation and API specifications. -- **[Command reference](./cli/commands.md):** Detailed slash command guide. -- **[Configuration reference](./get-started/configuration.md):** Settings and +- **[Command reference](./reference/commands.md):** Detailed slash command + guide. +- **[Configuration reference](./reference/configuration.md):** Settings and environment variables. -- **[Keyboard shortcuts](./cli/keyboard-shortcuts.md):** Productivity tips. -- **[Memory import processor](./core/memport.md):** How Gemini CLI processes - memory from various sources. -- **[Policy engine](./core/policy-engine.md):** Fine-grained execution control. -- **[Tools API](./core/tools-api.md):** The API for defining and using tools. +- **[Keyboard shortcuts](./reference/keyboard-shortcuts.md):** Productivity + tips. +- **[Memory import processor](./reference/memport.md):** How Gemini CLI + processes memory from various sources. +- **[Policy engine](./reference/policy-engine.md):** Fine-grained execution + control. +- **[Tools API](./reference/tools-api.md):** The API for defining and using + tools. ## Resources Support, release history, and legal information. -- **[FAQ](./faq.md):** Answers to frequently asked questions. +- **[FAQ](./resources/faq.md):** Answers to frequently asked questions. - **[Changelogs](./changelogs/index.md):** Highlights and notable changes. -- **[Quota and pricing](./quota-and-pricing.md):** Limits and billing details. -- **[Terms and privacy](./tos-privacy.md):** Official notices and terms. +- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing + details. +- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and + terms. ## Development diff --git a/docs/redirects.json b/docs/redirects.json new file mode 100644 index 0000000000..5183d0d476 --- /dev/null +++ b/docs/redirects.json @@ -0,0 +1,19 @@ +{ + "/docs/architecture": "/docs/cli/index", + "/docs/cli/commands": "/docs/reference/commands", + "/docs/cli": "/docs", + "/docs/cli/index": "/docs", + "/docs/cli/keyboard-shortcuts": "/docs/reference/keyboard-shortcuts", + "/docs/cli/uninstall": "/docs/resources/uninstall", + "/docs/core/concepts": "/docs", + "/docs/core/memport": "/docs/reference/memport", + "/docs/core/policy-engine": "/docs/reference/policy-engine", + "/docs/core/tools-api": "/docs/reference/tools-api", + "/docs/faq": "/docs/resources/faq", + "/docs/get-started/configuration": "/docs/reference/configuration", + "/docs/get-started/configuration-v1": "/docs/reference/configuration", + "/docs/index": "/docs", + "/docs/quota-and-pricing": "/docs/resources/quota-and-pricing", + "/docs/tos-privacy": "/docs/resources/tos-privacy", + "/docs/troubleshooting": "/docs/resources/troubleshooting" +} diff --git a/docs/cli/commands.md b/docs/reference/commands.md similarity index 98% rename from docs/cli/commands.md rename to docs/reference/commands.md index 6d44659404..ee7ac6d581 100644 --- a/docs/cli/commands.md +++ b/docs/reference/commands.md @@ -217,7 +217,7 @@ Slash commands provide meta-level control over the CLI itself. model. - **Note:** For more details on how `GEMINI.md` files contribute to hierarchical memory, see the - [CLI Configuration documentation](../get-started/configuration.md). + [CLI Configuration documentation](./configuration.md). ### `/model` @@ -254,7 +254,7 @@ Slash commands provide meta-level control over the CLI itself. checkpoints to restore from. - **Usage:** `/restore [tool_call_id]` - **Note:** Only available if checkpointing is configured via - [settings](../get-started/configuration.md). See + [settings](./configuration.md). See [Checkpointing documentation](../cli/checkpointing.md) for more details. ### `/rewind` @@ -293,7 +293,8 @@ Slash commands provide meta-level control over the CLI itself. settings that control the behavior and appearance of Gemini CLI. It is equivalent to manually editing the `.gemini/settings.json` file, but with validation and guidance to prevent errors. See the - [settings documentation](./settings.md) for a full list of available settings. + [settings documentation](../cli/settings.md) for a full list of available + settings. - **Usage:** Simply run `/settings` and the editor will open. You can then browse or search for specific settings, view their current values, and modify them as desired. Changes to some settings are applied immediately, while @@ -380,7 +381,8 @@ Slash commands provide meta-level control over the CLI itself. 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](./custom-commands.md). +please see the dedicated +[Custom Commands documentation](../cli/custom-commands.md). ## Input prompt shortcuts diff --git a/docs/get-started/configuration.md b/docs/reference/configuration.md similarity index 98% rename from docs/get-started/configuration.md rename to docs/reference/configuration.md index 8e2164080f..de639f95cf 100644 --- a/docs/get-started/configuration.md +++ b/docs/reference/configuration.md @@ -131,6 +131,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`general.plan.directory`** (string): + - **Description:** The directory where planning artifacts are stored. If not + specified, defaults to the system temporary directory. + - **Default:** `undefined` + - **Requires restart:** Yes + - **`general.enablePromptCompletion`** (boolean): - **Description:** Enable AI-powered prompt completion suggestions while typing. @@ -1228,8 +1234,8 @@ within your user's home folder. Environment variables are a common way to configure applications, especially for sensitive information like API keys or for settings that might change between environments. For authentication setup, see the -[Authentication documentation](./authentication.md) which covers all available -authentication methods. +[Authentication documentation](../get-started/authentication.md) which covers +all available authentication methods. The CLI automatically loads environment variables from an `.env` file. The loading order is: @@ -1248,7 +1254,8 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - **`GEMINI_API_KEY`**: - Your API key for the Gemini API. - - One of several available [authentication methods](./authentication.md). + - One of several available + [authentication methods](../get-started/authentication.md). - Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file. - **`GEMINI_MODEL`**: @@ -1594,15 +1601,15 @@ conventions and context. about the active instructional context. - **Importing content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, - see the [Memory Import Processor documentation](../core/memport.md). + see the [Memory Import Processor documentation](./memport.md). - **Commands for memory management:** - Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context. - Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI. - - See the [Commands documentation](../cli/commands.md#memory) for full details - on the `/memory` command and its sub-commands (`show` and `refresh`). + - See the [Commands documentation](./commands.md#memory) for full details on + the `/memory` command and its sub-commands (`show` and `refresh`). By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor diff --git a/docs/cli/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md similarity index 100% rename from docs/cli/keyboard-shortcuts.md rename to docs/reference/keyboard-shortcuts.md diff --git a/docs/core/memport.md b/docs/reference/memport.md similarity index 100% rename from docs/core/memport.md rename to docs/reference/memport.md diff --git a/docs/core/policy-engine.md b/docs/reference/policy-engine.md similarity index 88% rename from docs/core/policy-engine.md rename to docs/reference/policy-engine.md index 23e672e4b9..2106b751c9 100644 --- a/docs/core/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -92,11 +92,12 @@ 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. | -| User | 2 | Custom policies defined by the user. | -| Admin | 3 | Policies managed by an administrator (e.g., in an enterprise environment). | +| Tier | Base | Description | +| :-------- | :--- | :------------------------------------------------------------------------- | +| Default | 1 | Built-in policies that ship with the Gemini CLI. | +| Workspace | 2 | Policies defined in the current workspace's configuration directory. | +| User | 3 | Custom policies defined by the user. | +| Admin | 4 | Policies managed by an administrator (e.g., 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: @@ -105,15 +106,17 @@ engine transforms this into a final priority using the following formula: This system guarantees that: -- Admin policies always override User and Default policies. -- User policies always override Default policies. +- Admin policies always override User, Workspace, and Default policies. +- User policies override Workspace and Default policies. +- Workspace policies override Default policies. - You can still order rules within a single tier with fine-grained control. For example: - A `priority: 50` rule in a Default policy file becomes `1.050`. -- A `priority: 100` rule in a User policy file becomes `2.100`. -- A `priority: 20` rule in an Admin policy file becomes `3.020`. +- A `priority: 10` rule in a Workspace policy policy file becomes `2.010`. +- A `priority: 100` rule in a User policy file becomes `3.100`. +- A `priority: 20` rule in an Admin policy file becomes `4.020`. ### Approval modes @@ -156,10 +159,11 @@ User, and (if configured) Admin directories. ### Policy locations -| Tier | Type | Location | -| :-------- | :----- | :-------------------------- | -| **User** | Custom | `~/.gemini/policies/*.toml` | -| **Admin** | System | _See below (OS specific)_ | +| Tier | Type | Location | +| :------------ | :----- | :---------------------------------------- | +| **User** | Custom | `~/.gemini/policies/*.toml` | +| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` | +| **Admin** | System | _See below (OS specific)_ | #### System-wide policies (Admin) diff --git a/docs/core/tools-api.md b/docs/reference/tools-api.md similarity index 100% rename from docs/core/tools-api.md rename to docs/reference/tools-api.md diff --git a/docs/faq.md b/docs/resources/faq.md similarity index 98% rename from docs/faq.md rename to docs/resources/faq.md index 2e78f3aa34..eeb0396495 100644 --- a/docs/faq.md +++ b/docs/resources/faq.md @@ -104,7 +104,7 @@ The 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`. -Refer to [Gemini CLI Configuration](./get-started/configuration.md) for more +Refer to [Gemini CLI Configuration](../reference/configuration.md) for more details. ## Google AI Pro/Ultra and subscription FAQs diff --git a/docs/quota-and-pricing.md b/docs/resources/quota-and-pricing.md similarity index 100% rename from docs/quota-and-pricing.md rename to docs/resources/quota-and-pricing.md diff --git a/docs/tos-privacy.md b/docs/resources/tos-privacy.md similarity index 96% rename from docs/tos-privacy.md rename to docs/resources/tos-privacy.md index 0c7073e0fb..e653e59d1d 100644 --- a/docs/tos-privacy.md +++ b/docs/resources/tos-privacy.md @@ -10,8 +10,8 @@ and Privacy Notices applicable to those services apply to such access and use. Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy Policy. -**Note:** See [quotas and pricing](/docs/quota-and-pricing.md) for the quota and -pricing details that apply to your usage of the Gemini CLI. +**Note:** See [quotas and pricing](/docs/resources/quota-and-pricing.md) for the +quota and pricing details that apply to your usage of the Gemini CLI. ## Supported authentication methods @@ -93,4 +93,4 @@ backend, these Terms of Service and Privacy Notice documents apply: You may opt-out from sending Gemini CLI Usage Statistics to Google by following the instructions available here: -[Usage Statistics Configuration](https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md#usage-statistics). +[Usage Statistics Configuration](https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md#usage-statistics). diff --git a/docs/troubleshooting.md b/docs/resources/troubleshooting.md similarity index 99% rename from docs/troubleshooting.md rename to docs/resources/troubleshooting.md index f700d0b74f..9e567652d9 100644 --- a/docs/troubleshooting.md +++ b/docs/resources/troubleshooting.md @@ -93,7 +93,7 @@ topics on: - **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory. - - **Solution:** Refer to the [Configuration: Sandboxing](./cli/sandbox.md) + - **Solution:** Refer to the [Configuration: Sandboxing](../cli/sandbox.md) documentation for more information, including how to customize your sandbox configuration. diff --git a/docs/cli/uninstall.md b/docs/resources/uninstall.md similarity index 100% rename from docs/cli/uninstall.md rename to docs/resources/uninstall.md diff --git a/docs/sidebar.json b/docs/sidebar.json index 4e95111a13..1a47f8adc9 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -1,193 +1,247 @@ [ { - "label": "Get started", - "items": [ - { "label": "Overview", "slug": "docs" }, - { "label": "Quickstart", "slug": "docs/get-started" }, - { "label": "Installation", "slug": "docs/get-started/installation" }, - { "label": "Authentication", "slug": "docs/get-started/authentication" }, - { "label": "Examples", "slug": "docs/get-started/examples" }, - { "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" }, - { "label": "Gemini 3 on Gemini CLI", "slug": "docs/get-started/gemini-3" } - ] - }, - { - "label": "Use Gemini CLI", + "label": "docs_tab", "items": [ { - "label": "File management", - "slug": "docs/cli/tutorials/file-management" + "label": "Get started", + "items": [ + { "label": "Overview", "slug": "docs" }, + { "label": "Quickstart", "slug": "docs/get-started" }, + { "label": "Installation", "slug": "docs/get-started/installation" }, + { + "label": "Authentication", + "slug": "docs/get-started/authentication" + }, + { "label": "Examples", "slug": "docs/get-started/examples" }, + { "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" }, + { + "label": "Gemini 3 on Gemini CLI", + "slug": "docs/get-started/gemini-3" + } + ] }, { - "label": "Get started with Agent skills", - "slug": "docs/cli/tutorials/skills-getting-started" + "label": "Use Gemini CLI", + "items": [ + { + "label": "File management", + "slug": "docs/cli/tutorials/file-management" + }, + { + "label": "Get started with Agent skills", + "slug": "docs/cli/tutorials/skills-getting-started" + }, + { + "label": "Manage context and memory", + "slug": "docs/cli/tutorials/memory-management" + }, + { + "label": "Execute shell commands", + "slug": "docs/cli/tutorials/shell-commands" + }, + { + "label": "Manage sessions and history", + "slug": "docs/cli/tutorials/session-management" + }, + { + "label": "Plan tasks with todos", + "slug": "docs/cli/tutorials/task-planning" + }, + { + "label": "Web search and fetch", + "slug": "docs/cli/tutorials/web-tools" + }, + { + "label": "Set up an MCP server", + "slug": "docs/cli/tutorials/mcp-setup" + }, + { "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" } + ] }, { - "label": "Manage context and memory", - "slug": "docs/cli/tutorials/memory-management" + "label": "Features", + "items": [ + { "label": "Agent Skills", "slug": "docs/cli/skills" }, + { + "label": "Authentication", + "slug": "docs/get-started/authentication" + }, + { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, + { + "label": "Extensions", + "slug": "docs/extensions/index" + }, + { "label": "Headless mode", "slug": "docs/cli/headless" }, + { "label": "Help", "link": "/docs/reference/commands/#help-or" }, + { "label": "Hooks", "slug": "docs/hooks" }, + { "label": "IDE integration", "slug": "docs/ide-integration" }, + { "label": "MCP servers", "slug": "docs/tools/mcp-server" }, + { + "label": "Memory management", + "link": "/docs/reference/commands/#memory" + }, + { "label": "Model routing", "slug": "docs/cli/model-routing" }, + { "label": "Model selection", "slug": "docs/cli/model" }, + { "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" }, + { + "label": "Subagents", + "badge": "🧪", + "slug": "docs/core/subagents" + }, + { + "label": "Remote subagents", + "badge": "🧪", + "slug": "docs/core/remote-agents" + }, + { "label": "Rewind", "slug": "docs/cli/rewind" }, + { "label": "Sandboxing", "slug": "docs/cli/sandbox" }, + { "label": "Settings", "slug": "docs/cli/settings" }, + { + "label": "Shell", + "link": "/docs/reference/commands/#shells-or-bashes" + }, + { + "label": "Stats", + "link": "/docs/reference/commands/#stats" + }, + { "label": "Telemetry", "slug": "docs/cli/telemetry" }, + { "label": "Token caching", "slug": "docs/cli/token-caching" }, + { "label": "Tools", "link": "/docs/reference/commands/#tools" } + ] }, { - "label": "Execute shell commands", - "slug": "docs/cli/tutorials/shell-commands" + "label": "Configuration", + "items": [ + { "label": "Custom commands", "slug": "docs/cli/custom-commands" }, + { + "label": "Enterprise configuration", + "slug": "docs/cli/enterprise" + }, + { + "label": "Ignore files (.geminiignore)", + "slug": "docs/cli/gemini-ignore" + }, + { + "label": "Model configuration", + "slug": "docs/cli/generation-settings" + }, + { + "label": "Project context (GEMINI.md)", + "slug": "docs/cli/gemini-md" + }, + { "label": "Settings", "slug": "docs/cli/settings" }, + { + "label": "System prompt override", + "slug": "docs/cli/system-prompt" + }, + { "label": "Themes", "slug": "docs/cli/themes" }, + { "label": "Trusted folders", "slug": "docs/cli/trusted-folders" } + ] }, - { - "label": "Manage sessions and history", - "slug": "docs/cli/tutorials/session-management" - }, - { - "label": "Plan tasks with todos", - "slug": "docs/cli/tutorials/task-planning" - }, - { - "label": "Web search and fetch", - "slug": "docs/cli/tutorials/web-tools" - }, - { - "label": "Set up an MCP server", - "slug": "docs/cli/tutorials/mcp-setup" - }, - { "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" } - ] - }, - { - "label": "Features", - "items": [ - { "label": "Agent Skills", "slug": "docs/cli/skills" }, - { - "label": "Authentication", - "slug": "docs/get-started/authentication" - }, - { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, { "label": "Extensions", - "slug": "docs/extensions/index" - }, - { "label": "Headless mode", "slug": "docs/cli/headless" }, - { "label": "Help", "link": "/docs/cli/commands/#help-or" }, - { "label": "Hooks", "slug": "docs/hooks" }, - { "label": "IDE integration", "slug": "docs/ide-integration" }, - { "label": "MCP servers", "slug": "docs/tools/mcp-server" }, - { - "label": "Memory management", - "link": "/docs/cli/commands/#memory" - }, - { "label": "Model routing", "slug": "docs/cli/model-routing" }, - { "label": "Model selection", "slug": "docs/cli/model" }, - { "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" }, - { "label": "Subagents", "badge": "🧪", "slug": "docs/core/subagents" }, - { - "label": "Remote subagents", - "badge": "🧪", - "slug": "docs/core/remote-agents" - }, - { "label": "Rewind", "slug": "docs/cli/rewind" }, - { "label": "Sandboxing", "slug": "docs/cli/sandbox" }, - { "label": "Settings", "slug": "docs/cli/settings" }, - { - "label": "Shell", - "link": "/docs/cli/commands/#shells-or-bashes" + "items": [ + { + "label": "Overview", + "slug": "docs/extensions" + }, + { + "label": "User guide: Install and manage", + "link": "/docs/extensions/#manage-extensions" + }, + { + "label": "Developer guide: Build extensions", + "slug": "docs/extensions/writing-extensions" + }, + { + "label": "Developer guide: Best practices", + "slug": "docs/extensions/best-practices" + }, + { + "label": "Developer guide: Releasing", + "slug": "docs/extensions/releasing" + }, + { + "label": "Developer guide: Reference", + "slug": "docs/extensions/reference" + } + ] }, { - "label": "Stats", - "link": "/docs/cli/commands/#stats" - }, - { "label": "Telemetry", "slug": "docs/cli/telemetry" }, - { "label": "Token caching", "slug": "docs/cli/token-caching" }, - { "label": "Tools", "link": "/docs/cli/commands/#tools" } - ] - }, - { - "label": "Configuration", - "items": [ - { "label": "Custom commands", "slug": "docs/cli/custom-commands" }, - { "label": "Enterprise configuration", "slug": "docs/cli/enterprise" }, - { - "label": "Ignore files (.geminiignore)", - "slug": "docs/cli/gemini-ignore" - }, - { - "label": "Model configuration", - "slug": "docs/cli/generation-settings" - }, - { "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" }, - { "label": "Settings", "slug": "docs/cli/settings" }, - { "label": "System prompt override", "slug": "docs/cli/system-prompt" }, - { "label": "Themes", "slug": "docs/cli/themes" }, - { "label": "Trusted folders", "slug": "docs/cli/trusted-folders" } - ] - }, - { - "label": "Extensions", - "items": [ - { - "label": "Overview", - "slug": "docs/extensions" - }, - { - "label": "User guide: Install and manage", - "link": "/docs/extensions/#manage-extensions" - }, - { - "label": "Developer guide: Build extensions", - "slug": "docs/extensions/writing-extensions" - }, - { - "label": "Developer guide: Best practices", - "slug": "docs/extensions/best-practices" - }, - { - "label": "Developer guide: Releasing", - "slug": "docs/extensions/releasing" - }, - { - "label": "Developer guide: Reference", - "slug": "docs/extensions/reference" + "label": "Development", + "items": [ + { "label": "Contribution guide", "slug": "docs/contributing" }, + { "label": "Integration testing", "slug": "docs/integration-tests" }, + { + "label": "Issue and PR automation", + "slug": "docs/issue-and-pr-automation" + }, + { "label": "Local development", "slug": "docs/local-development" }, + { "label": "NPM package structure", "slug": "docs/npm" } + ] } ] }, { - "label": "Reference", + "label": "reference_tab", "items": [ - { "label": "Command reference", "slug": "docs/cli/commands" }, { - "label": "Configuration reference", - "slug": "docs/get-started/configuration" - }, - { "label": "Keyboard shortcuts", "slug": "docs/cli/keyboard-shortcuts" }, - { "label": "Memory import processor", "slug": "docs/core/memport" }, - { "label": "Policy engine", "slug": "docs/core/policy-engine" }, - { "label": "Tools API", "slug": "docs/core/tools-api" } + "label": "Reference", + "items": [ + { "label": "Command reference", "slug": "docs/reference/commands" }, + { + "label": "Configuration reference", + "slug": "docs/reference/configuration" + }, + { + "label": "Keyboard shortcuts", + "slug": "docs/reference/keyboard-shortcuts" + }, + { + "label": "Memory import processor", + "slug": "docs/reference/memport" + }, + { "label": "Policy engine", "slug": "docs/reference/policy-engine" }, + { "label": "Tools API", "slug": "docs/reference/tools-api" } + ] + } ] }, { - "label": "Resources", + "label": "resources_tab", "items": [ - { "label": "FAQ", "slug": "docs/faq" }, - { "label": "Quota and pricing", "slug": "docs/quota-and-pricing" }, - { "label": "Terms and privacy", "slug": "docs/tos-privacy" }, - { "label": "Troubleshooting", "slug": "docs/troubleshooting" }, - { "label": "Uninstall", "slug": "docs/cli/uninstall" } - ] - }, - { - "label": "Development", - "items": [ - { "label": "Contribution guide", "slug": "docs/contributing" }, - { "label": "Integration testing", "slug": "docs/integration-tests" }, { - "label": "Issue and PR automation", - "slug": "docs/issue-and-pr-automation" - }, - { "label": "Local development", "slug": "docs/local-development" }, - { "label": "NPM package structure", "slug": "docs/npm" } + "label": "Resources", + "items": [ + { "label": "FAQ", "slug": "docs/resources/faq" }, + { + "label": "Quota and pricing", + "slug": "docs/resources/quota-and-pricing" + }, + { + "label": "Terms and privacy", + "slug": "docs/resources/tos-privacy" + }, + { + "label": "Troubleshooting", + "slug": "docs/resources/troubleshooting" + }, + { "label": "Uninstall", "slug": "docs/resources/uninstall" } + ] + } ] }, { - "label": "Releases", + "label": "releases_tab", "items": [ - { "label": "Release notes", "slug": "docs/changelogs/" }, - { "label": "Stable release", "slug": "docs/changelogs/latest" }, - { "label": "Preview release", "slug": "docs/changelogs/preview" } + { + "label": "Releases", + "items": [ + { "label": "Release notes", "slug": "docs/changelogs/" }, + { "label": "Stable release", "slug": "docs/changelogs/latest" }, + { "label": "Preview release", "slug": "docs/changelogs/preview" } + ] + } ] } ] diff --git a/docs/tools/index.md b/docs/tools/index.md index 42304ef008..f496ad591a 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -98,5 +98,5 @@ Always review confirmation prompts carefully before allowing a tool to execute. ## Next steps - Learn how to [Provide context](../cli/gemini-md.md) to guide tool use. -- Explore the [Command reference](../cli/commands.md) for tool-related slash - commands. +- Explore the [Command reference](../reference/commands.md) for tool-related + slash commands. diff --git a/docs/tools/internal-docs.md b/docs/tools/internal-docs.md index f87099b2f3..0d30655fcd 100644 --- a/docs/tools/internal-docs.md +++ b/docs/tools/internal-docs.md @@ -14,8 +14,8 @@ provides direct access to the Markdown files in the `docs/` directory. `get_internal_docs` takes one optional argument: - `path` (string, optional): The relative path to a specific documentation file - (for example, `cli/commands.md`). If omitted, the tool returns a list of all - available documentation paths. + (for example, `reference/commands.md`). If omitted, the tool returns a list of + all available documentation paths. ## Usage @@ -40,7 +40,7 @@ Gemini CLI uses this tool to ensure technical accuracy: ## Next steps -- Explore the [Command reference](../cli/commands.md) for a detailed guide to - slash commands. -- See the [Configuration guide](../get-started/configuration.md) for settings +- Explore the [Command reference](../reference/commands.md) for a detailed guide + to slash commands. +- See the [Configuration guide](../reference/configuration.md) for settings reference. diff --git a/docs/tools/planning.md b/docs/tools/planning.md index 686b27f058..458b172510 100644 --- a/docs/tools/planning.md +++ b/docs/tools/planning.md @@ -11,6 +11,8 @@ by the agent when you ask it to "start a plan" using natural language. In this mode, the agent is restricted to read-only tools to allow for safe exploration and planning. +> **Note:** This tool is not available when the CLI is in YOLO mode. + - **Tool name:** `enter_plan_mode` - **Display name:** Enter Plan Mode - **File:** `enter-plan-mode.ts` diff --git a/docs/tools/shell.md b/docs/tools/shell.md index 7d8e407434..34fd7c8490 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -131,9 +131,9 @@ configuration file. commands. Including the generic `run_shell_command` acts as a wildcard, allowing any command not explicitly blocked. - `tools.exclude` [DEPRECATED]: To block specific commands, use the - [Policy Engine](../core/policy-engine.md). Historically, this setting allowed - adding entries to the `exclude` list under the `tools` category in the format - `run_shell_command()`. For example, + [Policy Engine](../reference/policy-engine.md). Historically, this setting + allowed adding entries to the `exclude` list under the `tools` category in the + format `run_shell_command()`. For example, `"tools": {"exclude": ["run_shell_command(rm)"]}` will block `rm` commands. The validation logic is designed to be secure and flexible: diff --git a/eslint.config.js b/eslint.config.js index 48af3775f2..12dc29a238 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -38,6 +38,7 @@ export default tseslint.config( 'dist/**', 'evals/**', 'packages/test-utils/**', + '.gemini/skills/**', ], }, eslint.configs.recommended, @@ -55,7 +56,7 @@ export default tseslint.config( }, { // Import specific config - files: ['packages/cli/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package + files: ['packages/*/src/**/*.{ts,tsx}'], // Target all TS/TSX in the packages plugins: { import: importPlugin, }, @@ -199,6 +200,8 @@ export default tseslint.config( ignores: ['**/*.test.ts', '**/*.test.tsx'], rules: { '@typescript-eslint/no-unsafe-type-assertion': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-return': 'error', }, }, { diff --git a/evals/grep_search_functionality.eval.ts b/evals/grep_search_functionality.eval.ts new file mode 100644 index 0000000000..77df3b950f --- /dev/null +++ b/evals/grep_search_functionality.eval.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 202 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest, TestRig } from './test-helper.js'; +import { + assertModelHasOutput, + checkModelOutputContent, +} from './test-helper.js'; + +describe('grep_search_functionality', () => { + const TEST_PREFIX = 'Grep Search Functionality: '; + + evalTest('USUALLY_PASSES', { + name: 'should find a simple string in a file', + files: { + 'test.txt': `hello + world + hello world`, + }, + prompt: 'Find "world" in test.txt', + assert: async (rig: TestRig, result: string) => { + await rig.waitForToolCall('grep_search'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/L2: world/, /L3: hello world/], + testName: `${TEST_PREFIX}simple search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should perform a case-sensitive search', + files: { + 'test.txt': `Hello + hello`, + }, + prompt: 'Find "Hello" in test.txt, case-sensitively.', + assert: async (rig: TestRig, result: string) => { + const wasToolCalled = await rig.waitForToolCall( + 'grep_search', + undefined, + (args) => { + const params = JSON.parse(args); + return params.case_sensitive === true; + }, + ); + expect( + wasToolCalled, + 'Expected grep_search to be called with case_sensitive: true', + ).toBe(true); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/L1: Hello/], + forbiddenContent: [/L2: hello/], + testName: `${TEST_PREFIX}case-sensitive search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should return only file names when names_only is used', + files: { + 'file1.txt': 'match me', + 'file2.txt': 'match me', + }, + prompt: 'Find the files containing "match me".', + assert: async (rig: TestRig, result: string) => { + const wasToolCalled = await rig.waitForToolCall( + 'grep_search', + undefined, + (args) => { + const params = JSON.parse(args); + return params.names_only === true; + }, + ); + expect( + wasToolCalled, + 'Expected grep_search to be called with names_only: true', + ).toBe(true); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/file1.txt/, /file2.txt/], + forbiddenContent: [/L1:/], + testName: `${TEST_PREFIX}names_only search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should search only within the specified include glob', + files: { + 'file.js': 'my_function();', + 'file.ts': 'my_function();', + }, + prompt: 'Find "my_function" in .js files.', + assert: async (rig: TestRig, result: string) => { + const wasToolCalled = await rig.waitForToolCall( + 'grep_search', + undefined, + (args) => { + const params = JSON.parse(args); + return params.include === '*.js'; + }, + ); + expect( + wasToolCalled, + 'Expected grep_search to be called with include: "*.js"', + ).toBe(true); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/file.js/], + forbiddenContent: [/file.ts/], + testName: `${TEST_PREFIX}include glob search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should search within a specific subdirectory', + files: { + 'src/main.js': 'unique_string_1', + 'lib/main.js': 'unique_string_2', + }, + prompt: 'Find "unique_string" in the src directory.', + assert: async (rig: TestRig, result: string) => { + const wasToolCalled = await rig.waitForToolCall( + 'grep_search', + undefined, + (args) => { + const params = JSON.parse(args); + return params.dir_path === 'src'; + }, + ); + expect( + wasToolCalled, + 'Expected grep_search to be called with dir_path: "src"', + ).toBe(true); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/unique_string_1/], + forbiddenContent: [/unique_string_2/], + testName: `${TEST_PREFIX}subdirectory search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should report no matches correctly', + files: { + 'file.txt': 'nothing to see here', + }, + prompt: 'Find "nonexistent" in file.txt', + assert: async (rig: TestRig, result: string) => { + await rig.waitForToolCall('grep_search'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/No matches found/], + testName: `${TEST_PREFIX}no matches`, + }); + }, + }); +}); diff --git a/integration-tests/acp-env-auth.test.ts b/integration-tests/acp-env-auth.test.ts index 78eec9cd56..c83dbafce5 100644 --- a/integration-tests/acp-env-auth.test.ts +++ b/integration-tests/acp-env-auth.test.ts @@ -26,7 +26,7 @@ class MockClient implements acp.Client { }; } -describe('ACP Environment and Auth', () => { +describe.skip('ACP Environment and Auth', () => { let rig: TestRig; let child: ChildProcess | undefined; diff --git a/integration-tests/concurrency-limit.responses b/integration-tests/concurrency-limit.responses new file mode 100644 index 0000000000..e2bd5efe2a --- /dev/null +++ b/integration-tests/concurrency-limit.responses @@ -0,0 +1,12 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/1"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/2"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/3"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/4"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/5"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/6"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/7"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/8"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/9"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/10"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/11"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":500,"totalTokenCount":600}}]} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 1 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 2 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 3 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 4 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 5 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 6 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 7 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 8 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 9 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 10 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Some requests were rate limited: Rate limit exceeded for host. Please wait 60 seconds before trying again."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1000,"candidatesTokenCount":50,"totalTokenCount":1050}}]} diff --git a/integration-tests/concurrency-limit.test.ts b/integration-tests/concurrency-limit.test.ts new file mode 100644 index 0000000000..ba165b3393 --- /dev/null +++ b/integration-tests/concurrency-limit.test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { join } from 'node:path'; + +describe('web-fetch rate limiting', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + it('should rate limit multiple requests to the same host', async () => { + rig.setup('web-fetch rate limit', { + settings: { tools: { core: ['web_fetch'] } }, + fakeResponsesPath: join( + import.meta.dirname, + 'concurrency-limit.responses', + ), + }); + + const result = await rig.run({ + args: `Fetch 11 pages from example.com`, + }); + + // We expect to find at least one tool call that failed with a rate limit error. + const toolLogs = rig.readToolLogs(); + const rateLimitedCalls = toolLogs.filter( + (log) => + log.toolRequest.name === 'web_fetch' && + log.toolRequest.error?.includes('Rate limit exceeded'), + ); + + expect(rateLimitedCalls.length).toBeGreaterThan(0); + expect(result).toContain('Rate limit exceeded'); + }); +}); diff --git a/integration-tests/parallel-tools.responses b/integration-tests/parallel-tools.responses new file mode 100644 index 0000000000..d7beedc8b2 --- /dev/null +++ b/integration-tests/parallel-tools.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file2.txt"}}},{"functionCall":{"name":"write_file","args":{"file_path":"output.txt","content":"wave2"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file3.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file4.txt"}}}, {"text":"All waves completed successfully."}]},"finishReason":"STOP","index":0}]}]} diff --git a/integration-tests/parallel-tools.test.ts b/integration-tests/parallel-tools.test.ts new file mode 100644 index 0000000000..760f98cd7a --- /dev/null +++ b/integration-tests/parallel-tools.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { join } from 'node:path'; +import fs from 'node:fs'; + +describe('Parallel Tool Execution Integration', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should execute [read, read, write, read, read] in correct waves with user approval', async () => { + rig.setup('parallel-wave-execution', { + fakeResponsesPath: join(import.meta.dirname, 'parallel-tools.responses'), + settings: { + tools: { + core: ['read_file', 'write_file'], + approval: 'ASK', // Disable YOLO mode to show permission prompts + confirmationRequired: ['write_file'], + }, + }, + }); + + rig.createFile('file1.txt', 'c1'); + rig.createFile('file2.txt', 'c2'); + rig.createFile('file3.txt', 'c3'); + rig.createFile('file4.txt', 'c4'); + rig.sync(); + + const run = await rig.runInteractive({ approvalMode: 'default' }); + + // 1. Trigger the wave + await run.type('ok'); + await run.type('\r'); + + // 3. Wait for the write_file prompt. + await run.expectText('Allow', 5000); + + // 4. Press Enter to approve the write_file. + await run.type('y'); + await run.type('\r'); + + // 5. Wait for the final model response + await run.expectText('All waves completed successfully.', 5000); + + // Verify all tool calls were made and succeeded in the logs + await rig.expectToolCallSuccess(['write_file']); + const toolLogs = rig.readToolLogs(); + + const readFiles = toolLogs.filter( + (l) => l.toolRequest.name === 'read_file', + ); + const writeFiles = toolLogs.filter( + (l) => l.toolRequest.name === 'write_file', + ); + + expect(readFiles.length).toBe(4); + expect(writeFiles.length).toBe(1); + expect(toolLogs.every((l) => l.toolRequest.success)).toBe(true); + + // Check that output.txt was actually written + expect(fs.readFileSync(join(rig.testDir!, 'output.txt'), 'utf8')).toBe( + 'wave2', + ); + }); +}); diff --git a/package-lock.json b/package-lock.json index e8417cc3e5..0bfce7daa0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1402,19 +1402,22 @@ "link": true }, "node_modules/@google/genai": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz", - "integrity": "sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w==", + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.42.0.tgz", + "integrity": "sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.20.1" + "@modelcontextprotocol/sdk": "^1.25.2" }, "peerDependenciesMeta": { "@modelcontextprotocol/sdk": { @@ -1422,10 +1425,18 @@ } } }, + "node_modules/@google/genai/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, "node_modules/@google/genai/node_modules/gaxios": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -1441,6 +1452,7 @@ "version": "8.1.2", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "gaxios": "^7.0.0", @@ -1455,6 +1467,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -1473,6 +1486,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=14" @@ -1482,6 +1496,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "dev": true, "license": "MIT", "dependencies": { "gaxios": "^7.0.0", @@ -1495,6 +1510,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -1509,6 +1525,20 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/@google/genai/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", @@ -2979,16 +3009,6 @@ "@noble/hashes": "^1.1.5" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -5133,46 +5153,6 @@ "win32" ] }, - "node_modules/@vscode/vsce/node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@vscode/vsce/node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -5212,33 +5192,6 @@ "node": ">=4" } }, - "node_modules/@vscode/vsce/node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@vscode/vsce/node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", - "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@vscode/vsce/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -5816,6 +5769,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -10478,6 +10432,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-network-error": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", + "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-node-process": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", @@ -12908,6 +12874,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", @@ -13447,9 +13428,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", - "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -14164,81 +14145,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rollup": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", @@ -15706,43 +15612,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/test-exclude/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/test-exclude/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -15759,23 +15628,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/test-exclude/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -17310,7 +17162,7 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", "@google/gemini-cli-core": "file:../core", - "@google/genai": "1.30.0", + "@google/genai": "1.41.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.23.0", "ansi-escapes": "^7.3.0", @@ -17370,6 +17222,29 @@ "node": ">=20" } }, + "packages/cli/node_modules/@google/genai": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.41.0.tgz", + "integrity": "sha512-S4WGil+PG0NBQRAx+0yrQuM/TWOLn2gGEy5wn4IsoOI6ouHad0P61p3OWdhJ3aqr9kfj8o904i/jevfaGoGuIQ==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^7.1.1", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "packages/cli/node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -17385,6 +17260,93 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/cli/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "packages/cli/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "packages/cli/node_modules/string-width": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", @@ -17410,7 +17372,7 @@ "@google-cloud/logging": "^11.2.1", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", - "@google/genai": "1.30.0", + "@google/genai": "1.41.0", "@iarna/toml": "^2.2.5", "@joshua.litt/get-ripgrep": "^0.0.3", "@modelcontextprotocol/sdk": "^1.23.0", @@ -17442,7 +17404,7 @@ "fdir": "^6.4.6", "fzf": "^0.5.2", "glob": "^12.0.0", - "google-auth-library": "^9.11.0", + "google-auth-library": "^10.5.0", "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", @@ -17488,6 +17450,29 @@ "node-pty": "^1.0.0" } }, + "packages/core/node_modules/@google/genai": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.41.0.tgz", + "integrity": "sha512-S4WGil+PG0NBQRAx+0yrQuM/TWOLn2gGEy5wn4IsoOI6ouHad0P61p3OWdhJ3aqr9kfj8o904i/jevfaGoGuIQ==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^7.1.1", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "packages/core/node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -17518,6 +17503,75 @@ } } }, + "packages/core/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "packages/core/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/core/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/core/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "packages/core/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "packages/core/node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -17542,6 +17596,24 @@ "node": ">=16" } }, + "packages/core/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "packages/core/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", diff --git a/package.json b/package.json index ef1791523d..7f5bf66348 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,9 @@ "wrap-ansi": "9.0.2", "cliui": { "wrap-ansi": "7.0.0" - } + }, + "glob": "^12.0.0", + "node-domexception": "^1.0.0" }, "bin": { "gemini": "bundle/gemini.js" diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts index 9b5bca8c5c..39cfe5eb74 100644 --- a/packages/a2a-server/src/agent/task.test.ts +++ b/packages/a2a-server/src/agent/task.test.ts @@ -14,19 +14,21 @@ import { type Mock, } from 'vitest'; import { Task } from './task.js'; +import type { + ToolCall, + Config, + ToolCallRequestInfo, + GitService, + CompletedToolCall, +} from '@google/gemini-cli-core'; import { GeminiEventType, - type Config, - type ToolCallRequestInfo, - type GitService, - type CompletedToolCall, ApprovalMode, ToolConfirmationOutcome, } from '@google/gemini-cli-core'; import { createMockConfig } from '../utils/testing_utils.js'; import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server'; import { CoderAgentEvent } from '../types.js'; -import type { ToolCall } from '@google/gemini-cli-core'; const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn()); diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index 890bc85b11..b74381714d 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -30,8 +30,7 @@ import { EDIT_TOOL_NAMES, processRestorableToolCalls, } from '@google/gemini-cli-core'; -import type { RequestContext } from '@a2a-js/sdk/server'; -import { type ExecutionEventBus } from '@a2a-js/sdk/server'; +import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server'; import type { TaskStatusUpdateEvent, TaskArtifactUpdateEvent, diff --git a/packages/a2a-server/src/commands/restore.ts b/packages/a2a-server/src/commands/restore.ts index 5b4839a2e4..c7567a3b24 100644 --- a/packages/a2a-server/src/commands/restore.ts +++ b/packages/a2a-server/src/commands/restore.ts @@ -69,6 +69,7 @@ export class RestoreCommand implements Command { throw error; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const toolCallData = JSON.parse(data); const ToolCallDataSchema = getToolCallDataSchema(); const parseResult = ToolCallDataSchema.safeParse(toolCallData); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 48daffbe42..eb92e55f36 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -8,17 +8,19 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as dotenv from 'dotenv'; -import type { TelemetryTarget } from '@google/gemini-cli-core'; +import type { + TelemetryTarget, + ConfigParameters, + ExtensionLoader, +} from '@google/gemini-cli-core'; import { AuthType, Config, - type ConfigParameters, FileDiscoveryService, ApprovalMode, loadServerHierarchicalMemory, GEMINI_DIR, DEFAULT_GEMINI_EMBEDDING_MODEL, - type ExtensionLoader, startupProfiler, PREVIEW_GEMINI_MODEL, homedir, diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index 8d15247128..a2b11d0886 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -7,8 +7,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { MCPServerConfig } from '@google/gemini-cli-core'; import { + type MCPServerConfig, debugLogger, GEMINI_DIR, getErrorMessage, @@ -122,6 +122,7 @@ export function loadSettings(workspaceDir: string): Settings { function resolveEnvVarsInString(value: string): string { const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} return value.replace(envVarRegex, (match, varName1, varName2) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const varName = varName1 || varName2; if (process && process.env && typeof process.env[varName] === 'string') { return process.env[varName]; @@ -146,7 +147,7 @@ function resolveEnvVarsInObject(obj: T): T { } if (Array.isArray(obj)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T; } diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index 4eb6b522b2..c863fb1472 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -4,12 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config } from '@google/gemini-cli-core'; -import { - GeminiEventType, - ApprovalMode, - type ToolCallConfirmationDetails, +import type { + Config, + ToolCallConfirmationDetails, } from '@google/gemini-cli-core'; +import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core'; import type { TaskStatusUpdateEvent, SendStreamingMessageSuccessResponse, diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index c061d4e3b3..161139279b 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -7,8 +7,8 @@ import express from 'express'; import type { AgentCard, Message } from '@a2a-js/sdk'; -import type { TaskStore } from '@a2a-js/sdk/server'; import { + type TaskStore, DefaultRequestHandler, InMemoryTaskStore, DefaultExecutionEventBus, @@ -25,9 +25,12 @@ import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js'; import { loadSettings } from '../config/settings.js'; import { loadExtensions } from '../config/extension.js'; import { commandRegistry } from '../commands/command-registry.js'; -import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core'; +import { + debugLogger, + SimpleExtensionLoader, + GitService, +} from '@google/gemini-cli-core'; import type { Command, CommandArgument } from '../commands/types.js'; -import { GitService } from '@google/gemini-cli-core'; type CommandResponse = { name: string; @@ -88,6 +91,7 @@ async function handleExecuteCommand( }, ) { logger.info('[CoreAgent] Received /executeCommand request: ', req.body); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { command, args } = req.body; try { if (typeof command !== 'string') { @@ -211,6 +215,7 @@ export async function createApp() { const agentSettings = req.body.agentSettings as | AgentSettings | undefined; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const contextId = req.body.contextId || uuidv4(); const wrapper = await agentExecutor.createTask( taskId, diff --git a/packages/a2a-server/src/persistence/gcs.ts b/packages/a2a-server/src/persistence/gcs.ts index ec6b86e56a..215b45837f 100644 --- a/packages/a2a-server/src/persistence/gcs.ts +++ b/packages/a2a-server/src/persistence/gcs.ts @@ -246,6 +246,7 @@ export class GCSTaskStore implements TaskStore { } const [compressedMetadata] = await metadataFile.download(); const jsonData = gunzipSync(compressedMetadata).toString(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const loadedMetadata = JSON.parse(jsonData); logger.info(`Task ${taskId} metadata loaded from GCS.`); @@ -282,12 +283,14 @@ export class GCSTaskStore implements TaskStore { return { id: taskId, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment contextId: loadedMetadata._contextId || uuidv4(), kind: 'task', status: { state: persistedState._taskState, timestamp: new Date().toISOString(), }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment metadata: loadedMetadata, history: [], artifacts: [], diff --git a/packages/cli/package.json b/packages/cli/package.json index 4d38b87a92..5ad1e37d9d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,7 +31,7 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", "@google/gemini-cli-core": "file:../core", - "@google/genai": "1.30.0", + "@google/genai": "1.41.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.23.0", "ansi-escapes": "^7.3.0", diff --git a/packages/cli/src/commands/extensions/utils.ts b/packages/cli/src/commands/extensions/utils.ts index 26e47b912b..78bad54502 100644 --- a/packages/cli/src/commands/extensions/utils.ts +++ b/packages/cli/src/commands/extensions/utils.ts @@ -47,6 +47,7 @@ const defaultRequestConfirmation: RequestConfirmationCallback = async ( message, initial: false, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.confirm; }; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0c52c9bc4b..809b31cd82 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -21,7 +21,11 @@ import { type MCPServerConfig, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; -import { type Settings, createTestMergedSettings } from './settings.js'; +import { + type Settings, + type MergedSettings, + createTestMergedSettings, +} from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; @@ -2599,6 +2603,21 @@ describe('loadCliConfig approval mode', () => { expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); + it('should pass planSettings.directory from settings to config', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + plan: { + directory: '.custom-plans', + }, + }, + } as unknown as MergedSettings); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + const plansDir = config.storage.getPlansDir(); + expect(plansDir).toContain('.custom-plans'); + }); + // --- Untrusted Folder Scenarios --- describe('when folder is NOT trusted', () => { beforeEach(() => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f893f9e22d..55c8cad82d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -56,7 +56,10 @@ import { resolvePath } from '../utils/resolvePath.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; -import { createPolicyEngineConfig } from './policy.js'; +import { + createPolicyEngineConfig, + resolveWorkspacePolicyState, +} from './policy.js'; import { ExtensionManager } from './extension-manager.js'; import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js'; import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js'; @@ -692,9 +695,17 @@ export async function loadCliConfig( policyPaths: argv.policy, }; + const { workspacePoliciesDir, policyUpdateConfirmationRequest } = + await resolveWorkspacePolicyState({ + cwd, + trustedFolder, + interactive, + }); + const policyEngineConfig = await createPolicyEngineConfig( effectiveSettings, approvalMode, + workspacePoliciesDir, ); policyEngineConfig.nonInteractive = !interactive; @@ -758,6 +769,7 @@ export async function loadCliConfig( coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, policyEngineConfig, + policyUpdateConfirmationRequest, excludeTools, toolDiscoveryCommand: settings.tools?.discoveryCommand, toolCallCommand: settings.tools?.callCommand, @@ -814,6 +826,7 @@ export async function loadCliConfig( enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, + planSettings: settings.general.plan, enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 4756b95b97..179959d83e 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -866,6 +866,7 @@ Would you like to attempt to install via "git clone" instead?`, try { const hooksContent = await fs.promises.readFile(hooksFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const rawHooks = JSON.parse(hooksContent); if ( diff --git a/packages/cli/src/config/extensionRegistryClient.test.ts b/packages/cli/src/config/extensionRegistryClient.test.ts index 187390ceb0..4b9699d5e3 100644 --- a/packages/cli/src/config/extensionRegistryClient.test.ts +++ b/packages/cli/src/config/extensionRegistryClient.test.ts @@ -224,4 +224,59 @@ describe('ExtensionRegistryClient', () => { 'Failed to fetch extensions: Not Found', ); }); + + it('should not return irrelevant results', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => [ + ...mockExtensions, + { + id: 'dataplex', + extensionName: 'dataplex', + extensionDescription: 'Connect to Dataplex Universal Catalog...', + fullName: 'google-cloud/dataplex', + rank: 6, + stars: 6, + url: '', + repoDescription: '', + lastUpdated: '', + extensionVersion: '1.0.0', + avatarUrl: '', + hasMCP: false, + hasContext: false, + isGoogleOwned: true, + licenseKey: '', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + { + id: 'conductor', + extensionName: 'conductor', + extensionDescription: 'A conductor extension that actually matches.', + fullName: 'someone/conductor', + rank: 100, + stars: 100, + url: '', + repoDescription: '', + lastUpdated: '', + extensionVersion: '1.0.0', + avatarUrl: '', + hasMCP: false, + hasContext: false, + isGoogleOwned: false, + licenseKey: '', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + ], + }); + + const results = await client.searchExtensions('conductor'); + const ids = results.map((r) => r.id); + + expect(ids).not.toContain('dataplex'); + expect(ids).toContain('conductor'); + }); }); diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts index aeda50dc48..bf09aabe77 100644 --- a/packages/cli/src/config/extensionRegistryClient.ts +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -79,9 +79,11 @@ export class ExtensionRegistryClient { const fzf = new AsyncFzf(allExtensions, { selector: (ext: RegistryExtension) => `${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`, - fuzzy: 'v2', + fuzzy: true, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const results = await fzf.find(query); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return results.map((r: { item: RegistryExtension }) => r.item); } @@ -108,7 +110,6 @@ export class ExtensionRegistryClient { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return (await response.json()) as RegistryExtension[]; } catch (error) { - // Clear the promise on failure so that subsequent calls can try again ExtensionRegistryClient.fetchPromise = null; throw error; } diff --git a/packages/cli/src/config/extensions/extensionEnablement.ts b/packages/cli/src/config/extensions/extensionEnablement.ts index a619587342..7ae2431ee9 100644 --- a/packages/cli/src/config/extensions/extensionEnablement.ts +++ b/packages/cli/src/config/extensions/extensionEnablement.ts @@ -179,6 +179,7 @@ export class ExtensionEnablementManager { readConfig(): AllExtensionsEnablementConfig { try { const content = fs.readFileSync(this.configFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(content); } catch (error) { if ( diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index 23df066db1..06e4f49db4 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -156,6 +156,7 @@ export async function promptForSetting( name: 'value', message: `${setting.name}\n${setting.description}`, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.value; } diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts index 5a2e0ca457..3a79fc705f 100644 --- a/packages/cli/src/config/extensions/variables.ts +++ b/packages/cli/src/config/extensions/variables.ts @@ -58,6 +58,7 @@ export function recursivelyHydrateStrings( if (Array.isArray(obj)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return obj.map((item) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return recursivelyHydrateStrings(item, values), ) as unknown as T; } diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 2c7ce599da..dbc7f6a415 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -148,13 +148,13 @@ describe('Policy Engine Integration Tests', () => { ); const engine = new PolicyEngine(config); - // MCP server allowed (priority 2.1) provides general allow for server - // MCP server allowed (priority 2.1) provides general allow for server + // MCP server allowed (priority 3.1) provides general allow for server + // MCP server allowed (priority 3.1) provides general allow for server expect( (await engine.check({ name: 'my-server__safe-tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); - // But specific tool exclude (priority 2.4) wins over server allow + // But specific tool exclude (priority 3.4) wins over server allow expect( (await engine.check({ name: 'my-server__dangerous-tool' }, undefined)) .decision, @@ -412,25 +412,25 @@ describe('Policy Engine Integration Tests', () => { // Find rules and verify their priorities const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool'); - expect(blockedToolRule?.priority).toBe(2.4); // Command line exclude + expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude const blockedServerRule = rules.find( (r) => r.toolName === 'blocked-server__*', ); - expect(blockedServerRule?.priority).toBe(2.9); // MCP server exclude + expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude const specificToolRule = rules.find( (r) => r.toolName === 'specific-tool', ); - expect(specificToolRule?.priority).toBe(2.3); // Command line allow + expect(specificToolRule?.priority).toBe(3.3); // Command line allow const trustedServerRule = rules.find( (r) => r.toolName === 'trusted-server__*', ); - expect(trustedServerRule?.priority).toBe(2.2); // MCP trusted server + expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*'); - expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server + expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) @@ -577,16 +577,16 @@ describe('Policy Engine Integration Tests', () => { // Verify each rule has the expected priority const tool3Rule = rules.find((r) => r.toolName === 'tool3'); - expect(tool3Rule?.priority).toBe(2.4); // Excluded tools (user tier) + expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier) const server2Rule = rules.find((r) => r.toolName === 'server2__*'); - expect(server2Rule?.priority).toBe(2.9); // Excluded servers (user tier) + expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier) const tool1Rule = rules.find((r) => r.toolName === 'tool1'); - expect(tool1Rule?.priority).toBe(2.3); // Allowed tools (user tier) + expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier) const server1Rule = rules.find((r) => r.toolName === 'server1__*'); - expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier) + expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier) const globRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts new file mode 100644 index 0000000000..a0e687388d --- /dev/null +++ b/packages/cli/src/config/policy.test.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { resolveWorkspacePolicyState } from './policy.js'; +import { writeToStderr } from '@google/gemini-cli-core'; + +// Mock debugLogger to avoid noise in test output +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger: { + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + writeToStderr: vi.fn(), + }; +}); + +describe('resolveWorkspacePolicyState', () => { + let tempDir: string; + let workspaceDir: string; + let policiesDir: string; + + beforeEach(() => { + // Create a temporary directory for the test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-')); + // Redirect GEMINI_CLI_HOME to the temp directory to isolate integrity storage + vi.stubEnv('GEMINI_CLI_HOME', tempDir); + + workspaceDir = path.join(tempDir, 'workspace'); + fs.mkdirSync(workspaceDir); + policiesDir = path.join(workspaceDir, '.gemini', 'policies'); + + vi.clearAllMocks(); + }); + + afterEach(() => { + // Clean up temporary directory + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it('should return empty state if folder is not trusted', async () => { + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: false, + interactive: true, + }); + + expect(result).toEqual({ + workspacePoliciesDir: undefined, + policyUpdateConfirmationRequest: undefined, + }); + }); + + it('should return policy directory if integrity matches', async () => { + // Set up policies directory with a file + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + // First call to establish integrity (interactive accept) + const firstResult = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + expect(firstResult.policyUpdateConfirmationRequest).toBeDefined(); + + // Establish integrity manually as if accepted + const { PolicyIntegrityManager } = await import('@google/gemini-cli-core'); + const integrityManager = new PolicyIntegrityManager(); + await integrityManager.acceptIntegrity( + 'workspace', + workspaceDir, + firstResult.policyUpdateConfirmationRequest!.newHash, + ); + + // Second call should match + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + }); + + it('should return undefined if integrity is NEW but fileCount is 0', async () => { + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + }); + + it('should return confirmation request if changed in interactive mode', async () => { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toEqual({ + scope: 'workspace', + identifier: workspaceDir, + policyDir: policiesDir, + newHash: expect.any(String), + }); + }); + + it('should warn and auto-accept if changed in non-interactive mode', async () => { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: false, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + expect(writeToStderr).toHaveBeenCalledWith( + expect.stringContaining('Automatically accepting and loading'), + ); + }); +}); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 70536070eb..ef6164efb7 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -12,12 +12,18 @@ import { type PolicySettings, createPolicyEngineConfig as createCorePolicyEngineConfig, createPolicyUpdater as createCorePolicyUpdater, + PolicyIntegrityManager, + IntegrityStatus, + Storage, + type PolicyUpdateConfirmationRequest, + writeToStderr, } from '@google/gemini-cli-core'; import { type Settings } from './settings.js'; export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, + workspacePoliciesDir?: string, ): Promise { // Explicitly construct PolicySettings from Settings to ensure type safety // and avoid accidental leakage of other settings properties. @@ -26,6 +32,7 @@ export async function createPolicyEngineConfig( tools: settings.tools, mcpServers: settings.mcpServers, policyPaths: settings.policyPaths, + workspacePoliciesDir, }; return createCorePolicyEngineConfig(policySettings, approvalMode); @@ -34,6 +41,72 @@ export async function createPolicyEngineConfig( export function createPolicyUpdater( policyEngine: PolicyEngine, messageBus: MessageBus, + storage: Storage, ) { - return createCorePolicyUpdater(policyEngine, messageBus); + return createCorePolicyUpdater(policyEngine, messageBus, storage); +} + +export interface WorkspacePolicyState { + workspacePoliciesDir?: string; + policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest; +} + +/** + * Resolves the workspace policy state by checking folder trust and policy integrity. + */ +export async function resolveWorkspacePolicyState(options: { + cwd: string; + trustedFolder: boolean; + interactive: boolean; +}): Promise { + const { cwd, trustedFolder, interactive } = options; + + let workspacePoliciesDir: string | undefined; + let policyUpdateConfirmationRequest: + | PolicyUpdateConfirmationRequest + | undefined; + + if (trustedFolder) { + const potentialWorkspacePoliciesDir = new Storage( + cwd, + ).getWorkspacePoliciesDir(); + const integrityManager = new PolicyIntegrityManager(); + const integrityResult = await integrityManager.checkIntegrity( + 'workspace', + cwd, + potentialWorkspacePoliciesDir, + ); + + if (integrityResult.status === IntegrityStatus.MATCH) { + workspacePoliciesDir = potentialWorkspacePoliciesDir; + } else if ( + integrityResult.status === IntegrityStatus.NEW && + integrityResult.fileCount === 0 + ) { + // No workspace policies found + workspacePoliciesDir = undefined; + } else if (interactive) { + // Policies changed or are new, and we are in interactive mode + policyUpdateConfirmationRequest = { + scope: 'workspace', + identifier: cwd, + policyDir: potentialWorkspacePoliciesDir, + newHash: integrityResult.hash, + }; + } else { + // Non-interactive mode: warn and automatically accept/load + await integrityManager.acceptIntegrity( + 'workspace', + cwd, + integrityResult.hash, + ); + workspacePoliciesDir = potentialWorkspacePoliciesDir; + // debugLogger.warn here doesn't show up in the terminal. It is showing up only in debug mode on the debug console + writeToStderr( + 'WARNING: Workspace policies changed or are new. Automatically accepting and loading them in non-interactive mode.\n', + ); + } + } + + return { workspacePoliciesDir, policyUpdateConfirmationRequest }; } diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 2638ac0347..ffe1dd2ac5 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -107,6 +107,16 @@ describe('SettingsSchema', () => { ).toBe('boolean'); }); + it('should have plan nested properties', () => { + expect( + getSettingsSchema().general?.properties?.plan?.properties?.directory, + ).toBeDefined(); + expect( + getSettingsSchema().general?.properties?.plan?.properties?.directory + .type, + ).toBe('string'); + }); + it('should have fileFiltering nested properties', () => { expect( getSettingsSchema().context.properties.fileFiltering.properties diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 49652eb7f7..473a286d29 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -266,6 +266,27 @@ const SETTINGS_SCHEMA = { }, }, }, + plan: { + type: 'object', + label: 'Plan', + category: 'General', + requiresRestart: true, + default: {}, + description: 'Planning features configuration.', + showInDialog: false, + properties: { + directory: { + type: 'string', + label: 'Plan Directory', + category: 'General', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.', + showInDialog: true, + }, + }, + }, enablePromptCompletion: { type: 'boolean', label: 'Enable Prompt Completion', @@ -1313,6 +1334,7 @@ const SETTINGS_SCHEMA = { }, }, }, + useWriteTodos: { type: 'boolean', label: 'Use WriteTodos', diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts new file mode 100644 index 0000000000..98cbe05bce --- /dev/null +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -0,0 +1,239 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as path from 'node:path'; +import { loadCliConfig, type CliArgs } from './config.js'; +import { createTestMergedSettings } from './settings.js'; +import * as ServerConfig from '@google/gemini-cli-core'; +import { isWorkspaceTrusted } from './trustedFolders.js'; + +// Mock dependencies +vi.mock('./trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(), +})); + +const mockCheckIntegrity = vi.fn(); +const mockAcceptIntegrity = vi.fn(); + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual( + '@google/gemini-cli-core', + ); + return { + ...actual, + loadServerHierarchicalMemory: vi.fn().mockResolvedValue({ + memoryContent: '', + fileCount: 0, + filePaths: [], + }), + createPolicyEngineConfig: vi.fn().mockResolvedValue({ + rules: [], + checkers: [], + }), + getVersion: vi.fn().mockResolvedValue('test-version'), + PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ + checkIntegrity: mockCheckIntegrity, + acceptIntegrity: mockAcceptIntegrity, + })), + IntegrityStatus: { MATCH: 'match', NEW: 'new', MISMATCH: 'mismatch' }, + debugLogger: { + warn: vi.fn(), + error: vi.fn(), + }, + isHeadlessMode: vi.fn().mockReturnValue(false), // Default to interactive + }; +}); + +describe('Workspace-Level Policy CLI Integration', () => { + const MOCK_CWD = process.cwd(); + + beforeEach(() => { + vi.clearAllMocks(); + // Default to MATCH for existing tests + mockCheckIntegrity.mockResolvedValue({ + status: 'match', + hash: 'test-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); + }); + + it('should have getWorkspacePoliciesDir on Storage class', () => { + const storage = new ServerConfig.Storage(MOCK_CWD); + expect(storage.getWorkspacePoliciesDir).toBeDefined(); + expect(typeof storage.getWorkspacePoliciesDir).toBe('function'); + }); + + it('should pass workspacePoliciesDir to createPolicyEngineConfig when folder is trusted', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), + }), + expect.anything(), + ); + }); + + it('should NOT pass workspacePoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + }); + + it('should NOT pass workspacePoliciesDir if integrity is NEW but fileCount is 0', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'new', + hash: 'hash', + fileCount: 0, + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + }); + + it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(true); // Non-interactive + + const settings = createTestMergedSettings(); + const argv = { prompt: 'do something' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(mockAcceptIntegrity).toHaveBeenCalledWith( + 'workspace', + MOCK_CWD, + 'new-hash', + ); + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), + }), + expect.anything(), + ); + }); + + it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { + query: 'test', + promptInteractive: 'test', + } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ + scope: 'workspace', + identifier: MOCK_CWD, + policyDir: expect.stringContaining(path.join('.gemini', 'policies')), + newHash: 'new-hash', + }); + // In interactive mode without accept flag, it waits for user confirmation (handled by UI), + // so it currently DOES NOT pass the directory to createPolicyEngineConfig yet. + // The UI will handle the confirmation and reload/update. + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + }); + + it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'new', + hash: 'new-hash', + fileCount: 5, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ + scope: 'workspace', + identifier: MOCK_CWD, + policyDir: expect.stringContaining(path.join('.gemini', 'policies')), + newHash: 'new-hash', + }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + }); +}); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 976d832abd..538fb8ee4e 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -38,6 +38,8 @@ import { appEvents, AppEvent } from './utils/events.js'; import { type Config, type ResumedSessionData, + type StartupWarning, + WarningPriority, debugLogger, coreEvents, AuthType, @@ -1193,7 +1195,9 @@ describe('startInteractiveUI', () => { }, }, } as LoadedSettings; - const mockStartupWarnings = ['warning1']; + const mockStartupWarnings: StartupWarning[] = [ + { id: 'w1', message: 'warning1', priority: WarningPriority.High }, + ]; const mockWorkspaceRoot = '/root'; const mockInitializationResult = { authError: null, @@ -1226,7 +1230,7 @@ describe('startInteractiveUI', () => { async function startTestInteractiveUI( config: Config, settings: LoadedSettings, - startupWarnings: string[], + startupWarnings: StartupWarning[], workspaceRoot: string, resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 7b385453bf..aa830c0250 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -11,6 +11,7 @@ import { loadCliConfig, parseArguments } from './config/config.js'; import * as cliConfig from './config/config.js'; import { readStdin } from './utils/readStdin.js'; import { basename } from 'node:path'; +import { createHash } from 'node:crypto'; import v8 from 'node:v8'; import os from 'node:os'; import dns from 'node:dns'; @@ -37,6 +38,8 @@ import { cleanupExpiredSessions, } from './utils/sessionCleanup.js'; import { + type StartupWarning, + WarningPriority, type Config, type ResumedSessionData, type OutputPayload, @@ -99,6 +102,7 @@ import { createPolicyUpdater } from './config/policy.js'; import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { TerminalProvider } from './ui/contexts/TerminalContext.js'; +import { OverflowProvider } from './ui/contexts/OverflowContext.js'; import { setupTerminalAndTheme } from './utils/terminalTheme.js'; import { profiler } from './ui/components/DebugProfiler.js'; @@ -180,7 +184,7 @@ ${reason.stack}` export async function startInteractiveUI( config: Config, settings: LoadedSettings, - startupWarnings: string[], + startupWarnings: StartupWarning[], workspaceRoot: string = process.cwd(), resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, @@ -235,17 +239,19 @@ export async function startInteractiveUI( > - - - - - + + + + + + + @@ -580,7 +586,7 @@ export async function main() { const policyEngine = config.getPolicyEngine(); const messageBus = config.getMessageBus(); - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, config.storage); // Register SessionEnd hook to fire on graceful exit // This runs before telemetry shutdown in runExitCleanup() @@ -668,9 +674,20 @@ export async function main() { } let input = config.getQuestion(); - const startupWarnings = [ - ...(await getStartupWarnings()), - ...(await getUserStartupWarnings(settings.merged)), + const useAlternateBuffer = shouldEnterAlternateScreen( + isAlternateBufferEnabled(settings), + config.getScreenReader(), + ); + const rawStartupWarnings = await getStartupWarnings(); + const startupWarnings: StartupWarning[] = [ + ...rawStartupWarnings.map((message) => ({ + id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`, + message, + priority: WarningPriority.High, + })), + ...(await getUserStartupWarnings(settings.merged, undefined, { + isAlternateBuffer: useAlternateBuffer, + })), ]; // Handle --resume flag diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index a0884ee024..018ce1502b 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -217,13 +217,14 @@ export class AppRig { } private stubRefreshAuth() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment const gcConfig = this.config as any; gcConfig.refreshAuth = async (authMethod: AuthType) => { gcConfig.modelAvailabilityService.reset(); const newContentGeneratorConfig = { authType: authMethod, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment proxy: gcConfig.getProxy(), apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key', }; diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts index 0351c7011c..0259c064a6 100644 --- a/packages/cli/src/test-utils/customMatchers.ts +++ b/packages/cli/src/test-utils/customMatchers.ts @@ -21,7 +21,7 @@ import type { TextBuffer } from '../ui/components/shared/text-buffer.js'; const invalidCharsRegex = /[\b\x1b]/; function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment const { isNot } = this as any; let pass = true; const invalidLines: Array<{ line: number; content: string }> = []; diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index c2f1bbcfd3..8dc5b9930a 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -45,7 +45,7 @@ export const createMockCommandContext = ( forScope: vi.fn().mockReturnValue({ settings: {} }), } as unknown as LoadedSettings, git: undefined as GitService | undefined, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment logger: { log: vi.fn(), logMessage: vi.fn(), @@ -54,7 +54,7 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, // Cast because Logger is a class. }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment ui: { addItem: vi.fn(), clear: vi.fn(), @@ -94,11 +94,14 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any const merge = (target: any, source: any): any => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const output = { ...target }; for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const sourceValue = source[key]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const targetValue = output[key]; if ( @@ -106,9 +109,11 @@ export const createMockCommandContext = ( Object.prototype.toString.call(sourceValue) === '[object Object]' && Object.prototype.toString.call(targetValue) === '[object Object]' ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment output[key] = merge(targetValue, sourceValue); } else { // If not, we do a direct assignment. This preserves Date objects and others. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment output[key] = sourceValue; } } @@ -116,5 +121,6 @@ export const createMockCommandContext = ( return output; }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return merge(defaultMocks, overrides); }; diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 2375a0fba1..a13d6e2558 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render as inkRenderDirect, type Instance as InkInstance } from 'ink'; +import { + render as inkRenderDirect, + type Instance as InkInstance, + type RenderOptions, +} from 'ink'; import { EventEmitter } from 'node:events'; import { Box } from 'ink'; import type React from 'react'; @@ -31,6 +35,13 @@ import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js'; import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js'; import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js'; import { TerminalProvider } from '../ui/contexts/TerminalContext.js'; +import { + OverflowProvider, + useOverflowActions, + useOverflowState, + type OverflowActions, + type OverflowState, +} from '../ui/contexts/OverflowContext.js'; import { makeFakeConfig, type Config } from '@google/gemini-cli-core'; import { FakePersistentState } from './persistentStateFake.js'; @@ -68,6 +79,25 @@ type TerminalState = { rows: number; }; +type RenderMetrics = Parameters>[0]; + +interface InkRenderMetrics extends RenderMetrics { + output: string; + staticOutput?: string; +} + +function isInkRenderMetrics( + metrics: RenderMetrics, +): metrics is InkRenderMetrics { + const m = metrics as Record; + return ( + typeof m === 'object' && + m !== null && + 'output' in m && + typeof m['output'] === 'string' + ); +} + class XtermStdout extends EventEmitter { private state: TerminalState; private pendingWrites = 0; @@ -312,6 +342,8 @@ export type RenderInstance = { lastFrame: (options?: { allowEmpty?: boolean }) => string; terminal: Terminal; waitUntilReady: () => Promise; + capturedOverflowState: OverflowState | undefined; + capturedOverflowActions: OverflowActions | undefined; }; const instances: InkInstance[] = []; @@ -320,7 +352,10 @@ const instances: InkInstance[] = []; export const render = ( tree: React.ReactElement, terminalWidth?: number, -): RenderInstance => { +): Omit< + RenderInstance, + 'capturedOverflowState' | 'capturedOverflowActions' +> => { const cols = terminalWidth ?? 100; // We use 1000 rows to avoid windows with incorrect snapshots if a correct // value was used (e.g. 40 rows). The alternatives to make things worse are @@ -357,8 +392,10 @@ export const render = ( debug: false, exitOnCtrlC: false, patchConsole: false, - onRender: (metrics: { output: string; staticOutput?: string }) => { - stdout.onRender(metrics.staticOutput ?? '', metrics.output); + onRender: (metrics: RenderMetrics) => { + if (isInkRenderMetrics(metrics)) { + stdout.onRender(metrics.staticOutput ?? '', metrics.output); + } }, }); }); @@ -506,6 +543,7 @@ const mockUIActions: UIActions = { vimHandleInput: vi.fn(), handleIdePromptComplete: vi.fn(), handleFolderTrustSelect: vi.fn(), + setIsPolicyUpdateDialogOpen: vi.fn(), setConstrainHeight: vi.fn(), onEscapePromptChange: vi.fn(), refreshStatic: vi.fn(), @@ -536,6 +574,16 @@ const mockUIActions: UIActions = { handleNewAgentsSelect: vi.fn(), }; +let capturedOverflowState: OverflowState | undefined; +let capturedOverflowActions: OverflowActions | undefined; +const ContextCapture: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + capturedOverflowState = useOverflowState(); + capturedOverflowActions = useOverflowActions(); + return <>{children}; +}; + export const renderWithProviders = ( component: React.ReactElement, { @@ -637,6 +685,9 @@ export const renderWithProviders = ( .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group') .flatMap((item) => item.tools); + capturedOverflowState = undefined; + capturedOverflowActions = undefined; + const renderResult = render( @@ -649,35 +700,39 @@ export const renderWithProviders = ( value={finalUiState.streamingState} > - - + - - - - - - {component} - - - - - - - + + + + + + + + {component} + + + + + + + + + @@ -692,6 +747,8 @@ export const renderWithProviders = ( return { ...renderResult, + capturedOverflowState, + capturedOverflowActions, simulateClick: (col: number, row: number, button?: 0 | 1 | 2) => simulateClick(renderResult.stdin, col, row, button), }; diff --git a/packages/cli/src/test-utils/settings.ts b/packages/cli/src/test-utils/settings.ts index 77e8450a9c..dd498b6625 100644 --- a/packages/cli/src/test-utils/settings.ts +++ b/packages/cli/src/test-utils/settings.ts @@ -46,6 +46,7 @@ export const createMockSettings = ( workspace, isTrusted, errors, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment merged: mergedOverride, ...settingsOverrides } = overrides; @@ -75,7 +76,7 @@ export const createMockSettings = ( // Assign any function overrides (e.g., vi.fn() for methods) for (const key in overrides) { if (typeof overrides[key] === 'function') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment (loaded as any)[key] = overrides[key]; } } diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 5554ecb58a..b3610a6d72 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -26,6 +26,8 @@ import { CoreEvent, type UserFeedbackPayload, type ResumedSessionData, + type StartupWarning, + WarningPriority, AuthType, type AgentDefinition, CoreToolCallStatus, @@ -103,6 +105,11 @@ import { type UIActions, } from './contexts/UIActionsContext.js'; import { KeypressProvider } from './contexts/KeypressContext.js'; +import { OverflowProvider } from './contexts/OverflowContext.js'; +import { + useOverflowActions, + type OverflowActions, +} from './contexts/OverflowContext.js'; // Mock useStdout to capture terminal title writes vi.mock('ink', async (importOriginal) => { @@ -118,9 +125,11 @@ vi.mock('ink', async (importOriginal) => { // so we can assert against them in our tests. let capturedUIState: UIState; let capturedUIActions: UIActions; +let capturedOverflowActions: OverflowActions; function TestContextConsumer() { capturedUIState = useContext(UIStateContext)!; capturedUIActions = useContext(UIActionsContext)!; + capturedOverflowActions = useOverflowActions()!; return null; } @@ -227,7 +236,10 @@ import { disableMouseEvents, } from '@google/gemini-cli-core'; import { type ExtensionManager } from '../config/extension-manager.js'; -import { WARNING_PROMPT_DURATION_MS } from './constants.js'; +import { + WARNING_PROMPT_DURATION_MS, + EXPAND_HINT_DURATION_MS, +} from './constants.js'; describe('AppContainer State Management', () => { let mockConfig: Config; @@ -248,18 +260,20 @@ describe('AppContainer State Management', () => { config?: Config; version?: string; initResult?: InitializationResult; - startupWarnings?: string[]; + startupWarnings?: StartupWarning[]; resumedSessionData?: ResumedSessionData; } = {}) => ( - + + + ); @@ -501,7 +515,18 @@ describe('AppContainer State Management', () => { }); it('renders with startup warnings', async () => { - const startupWarnings = ['Warning 1', 'Warning 2']; + const startupWarnings: StartupWarning[] = [ + { + id: 'w1', + message: 'Warning 1', + priority: WarningPriority.High, + }, + { + id: 'w2', + message: 'Warning 2', + priority: WarningPriority.High, + }, + ]; let unmount: () => void; await act(async () => { @@ -2674,12 +2699,14 @@ describe('AppContainer State Management', () => { const getTree = (settings: LoadedSettings) => ( - - + + + + ); @@ -3290,6 +3317,306 @@ describe('AppContainer State Management', () => { }); }); + describe('Submission Handling', () => { + it('resets expansion state on submission when not in alternate buffer', async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue([]); + + let unmount: () => void; + await act(async () => { + unmount = renderAppContainer({ + settings: { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { ...mockSettings.merged.ui, useAlternateBuffer: false }, + }, + } as LoadedSettings, + }).unmount; + }); + + await waitFor(() => expect(capturedUIActions).toBeTruthy()); + + // Expand first + act(() => capturedUIActions.setConstrainHeight(false)); + expect(capturedUIState.constrainHeight).toBe(false); + + // Reset mock stdout to clear any initial writes + mocks.mockStdout.write.mockClear(); + + // Submit + await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); + + // Should be reset + expect(capturedUIState.constrainHeight).toBe(true); + // Should refresh static (which clears terminal in non-alternate buffer) + expect(mocks.mockStdout.write).toHaveBeenCalledWith( + ansiEscapes.clearTerminal, + ); + unmount!(); + }); + + it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue([]); + + let unmount: () => void; + await act(async () => { + unmount = renderAppContainer({ + settings: { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { ...mockSettings.merged.ui, useAlternateBuffer: true }, + }, + } as LoadedSettings, + }).unmount; + }); + + await waitFor(() => expect(capturedUIActions).toBeTruthy()); + + // Expand first + act(() => capturedUIActions.setConstrainHeight(false)); + expect(capturedUIState.constrainHeight).toBe(false); + + // Reset mock stdout + mocks.mockStdout.write.mockClear(); + + // Submit + await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); + + // Should be reset + expect(capturedUIState.constrainHeight).toBe(true); + // Should NOT refresh static's clearTerminal in alternate buffer + expect(mocks.mockStdout.write).not.toHaveBeenCalledWith( + ansiEscapes.clearTerminal, + ); + unmount!(); + }); + }); + + describe('Overflow Hint Handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Trigger overflow + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + await waitFor(() => { + // Should show hint because we are in Standard Mode (default settings) and have overflow + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // Advance just before the timeout + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Advance to hit the timeout mark + act(() => { + vi.advanceTimersByTime(100); + }); + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount!(); + }); + + it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { + let unmount: () => void; + let stdin: ReturnType['stdin']; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + stdin = result.stdin; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Initial state is constrainHeight = true + expect(capturedUIState.constrainHeight).toBe(true); + + // Trigger overflow so the hint starts showing + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // Advance half the duration + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Simulate Ctrl+O + act(() => { + stdin.write('\x0f'); // \x0f is Ctrl+O + }); + + await waitFor(() => { + // constrainHeight should toggle + expect(capturedUIState.constrainHeight).toBe(false); + }); + + // Advance enough that the original timer would have expired if it hadn't reset + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 1000); + }); + + // We expect it to still be true because Ctrl+O should have reset the timer + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Advance remaining time to reach the new timeout + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 1000); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount!(); + }); + + it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => { + let unmount: () => void; + let stdin: ReturnType['stdin']; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + stdin = result.stdin; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Initial state is constrainHeight = true + expect(capturedUIState.constrainHeight).toBe(true); + + // Trigger overflow so the hint starts showing + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // Advance half the duration + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // First toggle 'on' (expanded) + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + await waitFor(() => { + expect(capturedUIState.constrainHeight).toBe(false); + }); + + // Wait 1 second + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Second toggle 'off' (collapsed) + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + await waitFor(() => { + expect(capturedUIState.constrainHeight).toBe(true); + }); + + // Wait 1 second + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Third toggle 'on' (expanded) + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + await waitFor(() => { + expect(capturedUIState.constrainHeight).toBe(false); + }); + + // Now we wait just before the timeout from the LAST toggle. + // It should still be true. + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Wait 0.1s more to hit exactly the timeout since the last toggle. + // It should hide now. + act(() => { + vi.advanceTimersByTime(100); + }); + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount!(); + }); + + it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { + const alternateSettings = mergeSettings({}, {}, {}, {}, true); + const settingsWithAlternateBuffer = { + merged: { + ...alternateSettings, + ui: { + ...alternateSettings.ui, + useAlternateBuffer: true, + }, + }, + } as unknown as LoadedSettings; + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ + settings: settingsWithAlternateBuffer, + }); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Trigger overflow + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + // Should NOT show hint because we are in Alternate Buffer Mode + expect(capturedUIState.showIsExpandableHint).toBe(false); + + unmount!(); + }); + }); + describe('Permission Handling', () => { it('shows permission dialog when checkPermissions returns paths', async () => { const { checkPermissions } = await import( diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 08bae44959..d0ba22d455 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -41,6 +41,7 @@ import { checkPermissions } from './hooks/atCommandProcessor.js'; import { MessageType, StreamingState } from './types.js'; import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; import { + type StartupWarning, type EditorType, type Config, type IdeInfo, @@ -94,6 +95,10 @@ import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; +import { + useOverflowActions, + useOverflowState, +} from './contexts/OverflowContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; @@ -150,6 +155,7 @@ import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js' import { WARNING_PROMPT_DURATION_MS, QUEUE_ERROR_DISPLAY_DURATION_MS, + EXPAND_HINT_DURATION_MS, } from './constants.js'; import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; import { NewAgentsChoice } from './components/NewAgentsNotification.js'; @@ -186,7 +192,7 @@ function isToolAwaitingConfirmation( interface AppContainerProps { config: Config; - startupWarnings?: string[]; + startupWarnings?: StartupWarning[]; version: string; initializationResult: InitializationResult; resumedSessionData?: ResumedSessionData; @@ -213,6 +219,7 @@ const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { const { config, initializationResult, resumedSessionData } = props; const settings = useSettings(); + const { reset } = useOverflowActions()!; const notificationsEnabled = isNotificationsEnabled(settings); const historyManager = useHistory({ @@ -261,6 +268,54 @@ export const AppContainer = (props: AppContainerProps) => { ); const [newAgents, setNewAgents] = useState(null); + const [constrainHeight, setConstrainHeight] = useState(true); + const [showIsExpandableHint, setShowIsExpandableHint] = useState(false); + const expandHintTimerRef = useRef(null); + const overflowState = useOverflowState(); + const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0; + const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight; + + /** + * Manages the visibility and x-second timer for the expansion hint. + * + * This effect triggers the timer countdown whenever an overflow is detected + * or the user manually toggles the expansion state with Ctrl+O. We use a stable + * boolean dependency (hasOverflowState) to ensure the timer only resets on + * genuine state transitions, preventing it from infinitely resetting during + * active text streaming. + */ + useEffect(() => { + if (isAlternateBuffer) { + setShowIsExpandableHint(false); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + return; + } + + if (hasOverflowState) { + setShowIsExpandableHint(true); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + expandHintTimerRef.current = setTimeout(() => { + setShowIsExpandableHint(false); + }, EXPAND_HINT_DURATION_MS); + } + }, [hasOverflowState, isAlternateBuffer, constrainHeight]); + + /** + * Safe cleanup to ensure the expansion hint timer is cancelled when the + * component unmounts, preventing memory leaks. + */ + useEffect( + () => () => { + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + }, + [], + ); const [defaultBannerText, setDefaultBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState(''); @@ -1188,6 +1243,19 @@ Logging in with Google... Restarting Gemini CLI to continue. const handleFinalSubmit = useCallback( async (submittedValue: string) => { + reset(); + // Explicitly hide the expansion hint and clear its x-second timer when a new turn begins. + setShowIsExpandableHint(false); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + if (!constrainHeight) { + setConstrainHeight(true); + if (!isAlternateBuffer) { + refreshStatic(); + } + } + const isSlash = isSlashCommand(submittedValue.trim()); const isIdle = streamingState === StreamingState.Idle; const isAgentRunning = @@ -1246,15 +1314,32 @@ Logging in with Google... Restarting Gemini CLI to continue. pendingSlashCommandHistoryItems, pendingGeminiHistoryItems, config, + constrainHeight, + setConstrainHeight, + isAlternateBuffer, + refreshStatic, + reset, handleHintSubmit, ], ); const handleClearScreen = useCallback(() => { + reset(); + // Explicitly hide the expansion hint and clear its x-second timer when clearing the screen. + setShowIsExpandableHint(false); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } historyManager.clearItems(); clearConsoleMessagesState(); refreshStatic(); - }, [historyManager, clearConsoleMessagesState, refreshStatic]); + }, [ + historyManager, + clearConsoleMessagesState, + refreshStatic, + reset, + setShowIsExpandableHint, + ]); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); @@ -1424,7 +1509,7 @@ Logging in with Google... Restarting Gemini CLI to continue. windowMs: WARNING_PROMPT_DURATION_MS, onRepeat: handleExitRepeat, }); - const [constrainHeight, setConstrainHeight] = useState(true); + const [ideContextState, setIdeContextState] = useState< IdeContext | undefined >(); @@ -1436,8 +1521,18 @@ Logging in with Google... Restarting Gemini CLI to continue. type: TransientMessageType; }>(WARNING_PROMPT_DURATION_MS); - const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = - useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); + const { + isFolderTrustDialogOpen, + discoveryResults: folderDiscoveryResults, + handleFolderTrustSelect, + isRestarting, + } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); + + const policyUpdateConfirmationRequest = + config.getPolicyUpdateConfirmationRequest(); + const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState( + !!policyUpdateConfirmationRequest, + ); const { needsRestart: ideNeedsRestart, restartReason: ideTrustRestartReason, @@ -1644,6 +1739,19 @@ Logging in with Google... Restarting Gemini CLI to continue. if (!constrainHeight) { enteringConstrainHeightMode = true; setConstrainHeight(true); + if (keyMatchers[Command.SHOW_MORE_LINES](key)) { + // If the user manually collapses the view, show the hint and reset the x-second timer. + setShowIsExpandableHint(true); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + expandHintTimerRef.current = setTimeout(() => { + setShowIsExpandableHint(false); + }, EXPAND_HINT_DURATION_MS); + } + if (!isAlternateBuffer) { + refreshStatic(); + } } if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { @@ -1687,6 +1795,17 @@ Logging in with Google... Restarting Gemini CLI to continue. !enteringConstrainHeightMode ) { setConstrainHeight(false); + // If the user manually expands the view, show the hint and reset the x-second timer. + setShowIsExpandableHint(true); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + expandHintTimerRef.current = setTimeout(() => { + setShowIsExpandableHint(false); + }, EXPAND_HINT_DURATION_MS); + if (!isAlternateBuffer) { + refreshStatic(); + } return true; } else if ( (keyMatchers[Command.FOCUS_SHELL_INPUT](key) || @@ -1910,6 +2029,7 @@ Logging in with Google... Restarting Gemini CLI to continue. (shouldShowRetentionWarning && retentionCheckComplete) || shouldShowIdePrompt || isFolderTrustDialogOpen || + isPolicyUpdateDialogOpen || adminSettingsChanged || !!commandConfirmationRequest || !!authConsentRequest || @@ -2066,8 +2186,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const fetchBannerTexts = async () => { const [defaultBanner, warningBanner] = await Promise.all([ - // TODO: temporarily disabling the banner, it will be re-added. - '', + config.getBannerTextNoCapacityIssues(), config.getBannerTextCapacityIssues(), ]); @@ -2137,6 +2256,9 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, + folderDiscoveryResults, + isPolicyUpdateDialogOpen, + policyUpdateConfirmationRequest, isTrustedFolder, constrainHeight, showErrorDetails, @@ -2204,6 +2326,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isBackgroundShellListOpen, adminSettingsChanged, newAgents, + showIsExpandableHint, hintMode: config.isModelSteeringEnabled() && isToolExecuting([ @@ -2259,6 +2382,9 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen, + folderDiscoveryResults, + isPolicyUpdateDialogOpen, + policyUpdateConfirmationRequest, isTrustedFolder, constrainHeight, showErrorDetails, @@ -2327,6 +2453,7 @@ Logging in with Google... Restarting Gemini CLI to continue. backgroundShells, adminSettingsChanged, newAgents, + showIsExpandableHint, ], ); @@ -2356,6 +2483,7 @@ Logging in with Google... Restarting Gemini CLI to continue. vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, + setIsPolicyUpdateDialogOpen, setConstrainHeight, onEscapePromptChange: handleEscapePromptChange, refreshStatic, @@ -2440,6 +2568,7 @@ Logging in with Google... Restarting Gemini CLI to continue. vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, + setIsPolicyUpdateDialogOpen, setConstrainHeight, handleEscapePromptChange, refreshStatic, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index c7359a2a46..0a8a8d74e3 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -20,6 +20,7 @@ import { import { type CommandContext, type SlashCommand, + type SlashCommandActionReturn, CommandKind, } from './types.js'; import open from 'open'; @@ -35,6 +36,7 @@ import { stat } from 'node:fs/promises'; import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js'; import { type ConfigLogger } from '../../commands/extensions/utils.js'; import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js'; +import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js'; import React from 'react'; function showMessageIfNoExtensions( @@ -265,7 +267,28 @@ async function restartAction( } } -async function exploreAction(context: CommandContext) { +async function exploreAction( + context: CommandContext, +): Promise { + const settings = context.services.settings.merged; + const useRegistryUI = settings.experimental?.extensionRegistry; + + if (useRegistryUI) { + const extensionManager = context.services.config?.getExtensionLoader(); + if (extensionManager instanceof ExtensionManager) { + return { + type: 'custom_dialog' as const, + component: React.createElement(ExtensionRegistryView, { + onSelect: (extension) => { + debugLogger.debug(`Selected extension: ${extension.extensionName}`); + }, + onClose: () => context.ui.removeComponent(), + extensionManager, + }), + }; + } + } + const extensionsUrl = 'https://geminicli.com/extensions/'; // Only check for NODE_ENV for explicit test mode, not for unit test framework diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts index af556ae255..2608b44ca9 100644 --- a/packages/cli/src/ui/commands/planCommand.test.ts +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -51,7 +51,7 @@ describe('planCommand', () => { getApprovalMode: vi.fn(), getFileSystemService: vi.fn(), storage: { - getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), }, }, }, diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index c64b0048f4..d9cc6739da 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -43,7 +43,7 @@ export const planCommand: SlashCommand = { try { const content = await processSingleFileContent( approvedPlanPath, - config.storage.getProjectTempPlansDir(), + config.storage.getPlansDir(), config.getFileSystemService(), ); const fileName = path.basename(approvedPlanPath); diff --git a/packages/cli/src/ui/components/AboutBox.tsx b/packages/cli/src/ui/components/AboutBox.tsx index ea5512b48d..7ea744b0fe 100644 --- a/packages/cli/src/ui/components/AboutBox.tsx +++ b/packages/cli/src/ui/components/AboutBox.tsx @@ -9,6 +9,7 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { useSettings } from '../contexts/SettingsContext.js'; +import { getDisplayString } from '@google/gemini-cli-core'; interface AboutBoxProps { cliVersion: string; @@ -79,7 +80,9 @@ export const AboutBox: React.FC = ({ - {modelVersion} + + {getDisplayString(modelVersion)} + diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index fe2adba9ab..2adc370ed5 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -191,7 +191,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { {showUiDetails && } - + + ); + } + if (uiState.isPolicyUpdateDialogOpen) { + return ( + uiActions.setIsPolicyUpdateDialogOpen(false)} /> ); } diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 36c7bb3437..26b61829a0 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -154,7 +154,7 @@ Implement a comprehensive authentication system with multiple providers. getIdeMode: () => false, isTrustedFolder: () => true, storage: { - getProjectTempPlansDir: () => mockPlansDir, + getPlansDir: () => mockPlansDir, }, getFileSystemService: (): FileSystemService => ({ readTextFile: vi.fn(), @@ -429,7 +429,7 @@ Implement a comprehensive authentication system with multiple providers. getIdeMode: () => false, isTrustedFolder: () => true, storage: { - getProjectTempPlansDir: () => mockPlansDir, + getPlansDir: () => mockPlansDir, }, getFileSystemService: (): FileSystemService => ({ readTextFile: vi.fn(), diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 9fc1adfc23..8777136d86 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -65,7 +65,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { try { const pathError = await validatePlanPath( planPath, - config.storage.getProjectTempPlansDir(), + config.storage.getPlansDir(), config.getTargetDir(), ); if (ignore) return; @@ -83,7 +83,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { const result = await processSingleFileContent( planPath, - config.storage.getProjectTempPlansDir(), + config.storage.getPlansDir(), config.getFileSystemService(), ); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 832edd1d8a..07693db151 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -18,6 +18,7 @@ vi.mock('../../utils/processUtils.js', () => ({ const mockedExit = vi.hoisted(() => vi.fn()); const mockedCwd = vi.hoisted(() => vi.fn()); +const mockedRows = vi.hoisted(() => ({ current: 24 })); vi.mock('node:process', async () => { const actual = @@ -29,11 +30,20 @@ vi.mock('node:process', async () => { }; }); +vi.mock('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ columns: 80, terminalHeight: mockedRows.current }), +})); + describe('FolderTrustDialog', () => { beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); mockedCwd.mockReturnValue('/home/user/project'); + mockedRows.current = 24; + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('should render the dialog with title and description', async () => { @@ -42,13 +52,159 @@ describe('FolderTrustDialog', () => { ); await waitUntilReady(); - expect(lastFrame()).toContain('Do you trust this folder?'); + expect(lastFrame()).toContain('Do you trust the files in this folder?'); expect(lastFrame()).toContain( - 'Trusting a folder allows Gemini to execute commands it suggests.', + 'Trusting a folder allows Gemini CLI to load its local configurations', ); unmount(); }); + it('should truncate discovery results when they exceed maxDiscoveryHeight', async () => { + // maxDiscoveryHeight = 24 - 15 = 9. + const discoveryResults = { + commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`), + mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`), + hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`), + skills: Array.from({ length: 10 }, (_, i) => `skill${i}`), + settings: Array.from({ length: 10 }, (_, i) => `setting${i}`), + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: true, terminalHeight: 24 }, + }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('This folder contains:'); + expect(lastFrame()).toContain('hidden'); + unmount(); + }); + + it('should adjust maxHeight based on terminal rows', async () => { + mockedRows.current = 14; // maxHeight = 14 - 10 = 4 + const discoveryResults = { + commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: true, terminalHeight: 14 }, + }, + ); + + await waitUntilReady(); + // With maxHeight=4, the intro text (4 lines) will take most of the space. + // The discovery results will likely be hidden. + expect(lastFrame()).toContain('hidden'); + unmount(); + }); + + it('should use minimum maxHeight of 4', async () => { + mockedRows.current = 8; // 8 - 10 = -2, should use 4 + const discoveryResults = { + commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: true, terminalHeight: 10 }, + }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('hidden'); + unmount(); + }); + + it('should toggle expansion when global Ctrl+O is handled', async () => { + const discoveryResults = { + commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`), + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + + const { lastFrame, unmount } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + // Initially constrained + uiState: { constrainHeight: true, terminalHeight: 24 }, + }, + ); + + // Initial state: truncated + await waitFor(() => { + expect(lastFrame()).toContain('Do you trust the files in this folder?'); + // In standard terminal mode, the expansion hint is handled globally by ToastDisplay + // via AppContainer, so it should not be present in the dialog's local frame. + expect(lastFrame()).not.toContain('Press Ctrl+O'); + expect(lastFrame()).toContain('hidden'); + }); + + // We can't easily simulate global Ctrl+O toggle in this unit test + // because it's handled in AppContainer. + // But we can re-render with constrainHeight: false. + const { lastFrame: lastFrameExpanded, unmount: unmountExpanded } = + renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: false, terminalHeight: 24 }, + }, + ); + + await waitFor(() => { + expect(lastFrameExpanded()).not.toContain('hidden'); + expect(lastFrameExpanded()).toContain('- cmd9'); + expect(lastFrameExpanded()).toContain('- cmd4'); + }); + + unmount(); + unmountExpanded(); + }); + it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => { const onSelect = vi.fn(); const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( @@ -164,5 +320,153 @@ describe('FolderTrustDialog', () => { expect(lastFrame()).toContain('Trust parent folder ()'); unmount(); }); + + it('should display discovery results when provided', async () => { + mockedRows.current = 40; // Increase height to show all results + const discoveryResults = { + commands: ['cmd1', 'cmd2'], + mcps: ['mcp1'], + hooks: ['hook1'], + skills: ['skill1'], + settings: ['general', 'ui'], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('This folder contains:'); + expect(lastFrame()).toContain('• Commands (2):'); + expect(lastFrame()).toContain('- cmd1'); + expect(lastFrame()).toContain('- cmd2'); + expect(lastFrame()).toContain('• MCP Servers (1):'); + expect(lastFrame()).toContain('- mcp1'); + expect(lastFrame()).toContain('• Hooks (1):'); + expect(lastFrame()).toContain('- hook1'); + expect(lastFrame()).toContain('• Skills (1):'); + expect(lastFrame()).toContain('- skill1'); + expect(lastFrame()).toContain('• Setting overrides (2):'); + expect(lastFrame()).toContain('- general'); + expect(lastFrame()).toContain('- ui'); + unmount(); + }); + + it('should display security warnings when provided', async () => { + const discoveryResults = { + commands: [], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: ['Dangerous setting detected!'], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('Security Warnings:'); + expect(lastFrame()).toContain('Dangerous setting detected!'); + unmount(); + }); + + it('should display discovery errors when provided', async () => { + const discoveryResults = { + commands: [], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: ['Failed to load custom commands'], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('Discovery Errors:'); + expect(lastFrame()).toContain('Failed to load custom commands'); + unmount(); + }); + + it('should use scrolling instead of truncation when alternate buffer is enabled and expanded', async () => { + const discoveryResults = { + commands: Array.from({ length: 20 }, (_, i) => `cmd${i}`), + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: true, + uiState: { constrainHeight: false, terminalHeight: 15 }, + }, + ); + + await waitUntilReady(); + // In alternate buffer + expanded, the title should be visible (StickyHeader) + expect(lastFrame()).toContain('Do you trust the files in this folder?'); + // And it should NOT use MaxSizedBox truncation + expect(lastFrame()).not.toContain('hidden'); + unmount(); + }); + + it('should strip ANSI codes from discovery results', async () => { + const ansiRed = '\u001b[31m'; + const ansiReset = '\u001b[39m'; + + const discoveryResults = { + commands: [`${ansiRed}cmd-with-ansi${ansiReset}`], + mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`], + hooks: [`${ansiRed}hook-with-ansi${ansiReset}`], + skills: [`${ansiRed}skill-with-ansi${ansiReset}`], + settings: [`${ansiRed}setting-with-ansi${ansiReset}`], + discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`], + securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`], + }; + + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { width: 100, uiState: { terminalHeight: 40 } }, + ); + + await waitUntilReady(); + const output = lastFrame(); + + expect(output).toContain('cmd-with-ansi'); + expect(output).toContain('mcp-with-ansi'); + expect(output).toContain('hook-with-ansi'); + expect(output).toContain('skill-with-ansi'); + expect(output).toContain('setting-with-ansi'); + expect(output).toContain('error-with-ansi'); + expect(output).toContain('warning-with-ansi'); + + unmount(); + }); }); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 9886e3b5e4..2067a5dc3a 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -8,14 +8,25 @@ import { Box, Text } from 'ink'; import type React from 'react'; import { useEffect, useState, useCallback } from 'react'; import { theme } from '../semantic-colors.js'; +import stripAnsi from 'strip-ansi'; import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { MaxSizedBox } from './shared/MaxSizedBox.js'; +import { Scrollable } from './shared/Scrollable.js'; import { useKeypress } from '../hooks/useKeypress.js'; import * as process from 'node:process'; import * as path from 'node:path'; import { relaunchApp } from '../../utils/processUtils.js'; import { runExitCleanup } from '../../utils/cleanup.js'; -import { ExitCodes } from '@google/gemini-cli-core'; +import { + ExitCodes, + type FolderDiscoveryResults, +} from '@google/gemini-cli-core'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { OverflowProvider } from '../contexts/OverflowContext.js'; +import { ShowMoreLines } from './ShowMoreLines.js'; +import { StickyHeader } from './StickyHeader.js'; export enum FolderTrustChoice { TRUST_FOLDER = 'trust_folder', @@ -26,13 +37,19 @@ export enum FolderTrustChoice { interface FolderTrustDialogProps { onSelect: (choice: FolderTrustChoice) => void; isRestarting?: boolean; + discoveryResults?: FolderDiscoveryResults | null; } export const FolderTrustDialog: React.FC = ({ onSelect, isRestarting, + discoveryResults, }) => { const [exiting, setExiting] = useState(false); + const { terminalHeight, terminalWidth, constrainHeight } = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); + + const isExpanded = !constrainHeight; useEffect(() => { let timer: ReturnType; @@ -87,33 +104,197 @@ export const FolderTrustDialog: React.FC = ({ }, ]; - return ( - + const hasDiscovery = + discoveryResults && + (discoveryResults.commands.length > 0 || + discoveryResults.mcps.length > 0 || + discoveryResults.hooks.length > 0 || + discoveryResults.skills.length > 0 || + discoveryResults.settings.length > 0); + + const hasWarnings = + discoveryResults && discoveryResults.securityWarnings.length > 0; + + const hasErrors = + discoveryResults && + discoveryResults.discoveryErrors && + discoveryResults.discoveryErrors.length > 0; + + const dialogWidth = terminalWidth - 2; + const borderColor = theme.status.warning; + + // Header: 3 lines + // Options: options.length + 2 lines for margins + // Footer: 1 line + // Safety margin: 2 lines + const overhead = 3 + options.length + 2 + 1 + 2; + const scrollableHeight = Math.max(4, terminalHeight - overhead); + + const groups = [ + { label: 'Commands', items: discoveryResults?.commands ?? [] }, + { label: 'MCP Servers', items: discoveryResults?.mcps ?? [] }, + { label: 'Hooks', items: discoveryResults?.hooks ?? [] }, + { label: 'Skills', items: discoveryResults?.skills ?? [] }, + { label: 'Setting overrides', items: discoveryResults?.settings ?? [] }, + ].filter((g) => g.items.length > 0); + + const discoveryContent = ( + + + + Trusting a folder allows Gemini CLI to load its local configurations, + including custom commands, hooks, MCP servers, agent skills, and + settings. These configurations could execute code on your behalf or + change the behavior of the CLI. + + + + {hasErrors && ( + + + ❌ Discovery Errors: + + {discoveryResults.discoveryErrors.map((error, i) => ( + + • {stripAnsi(error)} + + ))} + + )} + + {hasWarnings && ( + + + ⚠️ Security Warnings: + + {discoveryResults.securityWarnings.map((warning, i) => ( + + • {stripAnsi(warning)} + + ))} + + )} + + {hasDiscovery && ( + + + This folder contains: + + {groups.map((group) => ( + + + • {group.label} ({group.items.length}): + + {group.items.map((item, idx) => ( + + - {stripAnsi(item)} + + ))} + + ))} + + )} + + ); + + const title = ( + + Do you trust the files in this folder? + + ); + + const selectOptions = ( + + ); + + const renderContent = () => { + if (isAlternateBuffer) { + return ( + + + {title} + + + + + + {discoveryContent} + + + + + {selectOptions} + + + + + + ); + } + + return ( - - - Do you trust this folder? - - - Trusting a folder allows Gemini to execute commands it suggests. - This is a security feature to prevent accidental execution in - untrusted directories. - - + {title} - + + {discoveryContent} + + + {selectOptions} + ); + }; + + const content = ( + + + {renderContent()} + + + + + + {isRestarting && ( @@ -131,4 +312,10 @@ export const FolderTrustDialog: React.FC = ({ )} ); + + return isAlternateBuffer ? ( + {content} + ) : ( + content + ); }; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f863e2272a..2fceacdf2f 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -46,6 +46,7 @@ interface HistoryItemDisplayProps { isPending: boolean; commands?: readonly SlashCommand[]; availableTerminalHeightGemini?: number; + isExpandable?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -55,6 +56,7 @@ export const HistoryItemDisplay: React.FC = ({ isPending, commands, availableTerminalHeightGemini, + isExpandable, }) => { const settings = useSettings(); const inlineThinkingMode = getInlineThinkingMode(settings); @@ -180,6 +182,7 @@ export const HistoryItemDisplay: React.FC = ({ terminalWidth={terminalWidth} borderTop={itemForDisplay.borderTop} borderBottom={itemForDisplay.borderBottom} + isExpandable={isExpandable} /> )} {itemForDisplay.type === 'compression' && ( diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 154c680809..1576cef2e8 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1065,7 +1065,7 @@ describe('InputPrompt', () => { unmount(); }); - it('should NOT submit on Enter when an @-path is a perfect match', async () => { + it('should submit on Enter when an @-path is a perfect match', async () => { mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, showSuggestions: true, @@ -1085,13 +1085,38 @@ describe('InputPrompt', () => { }); await waitFor(() => { - // Should handle autocomplete but NOT submit - expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); - expect(props.onSubmit).not.toHaveBeenCalled(); + // Should submit directly + expect(props.onSubmit).toHaveBeenCalledWith('@file.txt'); }); unmount(); }); + it('should NOT submit on Shift+Enter even if an @-path is a perfect match', async () => { + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [{ label: 'file.txt', value: 'file.txt' }], + activeSuggestionIndex: 0, + isPerfectMatch: true, + completionMode: CompletionMode.AT, + }); + props.buffer.text = '@file.txt'; + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + await act(async () => { + // Simulate Shift+Enter using CSI u sequence + stdin.write('\x1b[13;2u'); + }); + + // Should NOT submit, should call newline instead + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(props.buffer.newline).toHaveBeenCalled(); + unmount(); + }); + it('should auto-execute commands with autoExecute: true on Enter', async () => { const aboutCommand: SlashCommand = { name: 'about', @@ -2285,6 +2310,36 @@ describe('InputPrompt', () => { unmount(); }); + it('should prevent perfect match auto-submission immediately after an unsafe paste', async () => { + // isTerminalPasteTrusted will be false due to beforeEach setup. + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + isPerfectMatch: true, + completionMode: CompletionMode.AT, + }); + props.buffer.text = '@file.txt'; + + const { stdin, unmount } = renderWithProviders( + , + ); + + // Simulate an unsafe paste of a perfect match + await act(async () => { + stdin.write(`\x1b[200~@file.txt\x1b[201~`); + }); + + // Simulate an Enter key press immediately after paste + await act(async () => { + stdin.write('\r'); + }); + + // Verify that onSubmit was NOT called due to recent paste protection + expect(props.onSubmit).not.toHaveBeenCalled(); + // It should call newline() instead + expect(props.buffer.newline).toHaveBeenCalled(); + unmount(); + }); + it('should allow submission after unsafe paste protection timeout', async () => { // isTerminalPasteTrusted will be false due to beforeEach setup. props.buffer.text = 'pasted text'; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8a4f068df1..689df105ca 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -890,14 +890,20 @@ export const InputPrompt: React.FC = ({ // We prioritize execution unless the user is explicitly selecting a different suggestion. if ( completion.isPerfectMatch && - completion.completionMode !== CompletionMode.AT && - keyMatchers[Command.RETURN](key) && + keyMatchers[Command.SUBMIT](key) && + recentUnsafePasteTime === null && (!completion.showSuggestions || completion.activeSuggestionIndex <= 0) ) { handleSubmit(buffer.text); return true; } + // Newline insertion + if (keyMatchers[Command.NEWLINE](key)) { + buffer.newline(); + return true; + } + if (completion.showSuggestions) { if (completion.suggestions.length > 1) { if (keyMatchers[Command.COMPLETION_UP](key)) { @@ -1078,12 +1084,6 @@ export const InputPrompt: React.FC = ({ return true; } - // Newline insertion - if (keyMatchers[Command.NEWLINE](key)) { - buffer.newline(); - return true; - } - // Ctrl+A (Home) / Ctrl+E (End) if (keyMatchers[Command.HOME](key)) { buffer.move('home'); diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index fce375c306..598d19240f 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -493,7 +493,8 @@ describe('MainContent', () => { isAlternateBuffer: true, embeddedShellFocused: true, constrainHeight: true, - shouldShowLine1: true, + shouldShowLine1: false, + staticAreaMaxItemHeight: 15, }, { name: 'ASB mode - Unfocused shell', @@ -501,6 +502,7 @@ describe('MainContent', () => { embeddedShellFocused: false, constrainHeight: true, shouldShowLine1: false, + staticAreaMaxItemHeight: 15, }, { name: 'Normal mode - Constrained height', @@ -508,13 +510,15 @@ describe('MainContent', () => { embeddedShellFocused: false, constrainHeight: true, shouldShowLine1: false, + staticAreaMaxItemHeight: 15, }, { name: 'Normal mode - Unconstrained height', isAlternateBuffer: false, embeddedShellFocused: false, constrainHeight: false, - shouldShowLine1: false, + shouldShowLine1: true, + staticAreaMaxItemHeight: 15, }, ]; @@ -525,6 +529,7 @@ describe('MainContent', () => { embeddedShellFocused, constrainHeight, shouldShowLine1, + staticAreaMaxItemHeight, }) => { vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer); const ptyId = 123; @@ -554,6 +559,7 @@ describe('MainContent', () => { }, ], availableTerminalHeight: 30, // In ASB mode, focused shell should get ~28 lines + staticAreaMaxItemHeight, terminalHeight: 50, terminalWidth: 100, mainAreaWidth: 100, diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index cba57756e3..fbcc962663 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -47,32 +47,61 @@ export const MainContent = () => { pendingHistoryItems, mainAreaWidth, staticAreaMaxItemHeight, - availableTerminalHeight, cleanUiDetailsVisible, } = uiState; const showHeaderDetails = cleanUiDetailsVisible; + const lastUserPromptIndex = useMemo(() => { + for (let i = uiState.history.length - 1; i >= 0; i--) { + const type = uiState.history[i].type; + if (type === 'user' || type === 'user_shell') { + return i; + } + } + return -1; + }, [uiState.history]); + const historyItems = useMemo( () => - uiState.history.map((h) => ( - - )), + uiState.history.map((h, index) => { + const isExpandable = index > lastUserPromptIndex; + return ( + + ); + }), [ uiState.history, mainAreaWidth, staticAreaMaxItemHeight, uiState.slashCommands, + uiState.constrainHeight, + lastUserPromptIndex, ], ); + const staticHistoryItems = useMemo( + () => historyItems.slice(0, lastUserPromptIndex + 1), + [historyItems, lastUserPromptIndex], + ); + + const lastResponseHistoryItems = useMemo( + () => historyItems.slice(lastUserPromptIndex + 1), + [historyItems, lastUserPromptIndex], + ); + const pendingItems = useMemo( () => ( @@ -80,14 +109,12 @@ export const MainContent = () => { ))} {showConfirmationQueue && confirmingTool && ( @@ -98,8 +125,7 @@ export const MainContent = () => { [ pendingHistoryItems, uiState.constrainHeight, - isAlternateBuffer, - availableTerminalHeight, + staticAreaMaxItemHeight, mainAreaWidth, showConfirmationQueue, confirmingTool, @@ -109,10 +135,14 @@ export const MainContent = () => { const virtualizedData = useMemo( () => [ { type: 'header' as const }, - ...uiState.history.map((item) => ({ type: 'history' as const, item })), + ...uiState.history.map((item, index) => ({ + type: 'history' as const, + item, + isExpandable: index > lastUserPromptIndex, + })), { type: 'pending' as const }, ], - [uiState.history], + [uiState.history, lastUserPromptIndex], ); const renderItem = useCallback( @@ -129,12 +159,17 @@ export const MainContent = () => { return ( ); } else { @@ -147,6 +182,8 @@ export const MainContent = () => { mainAreaWidth, uiState.slashCommands, pendingItems, + uiState.constrainHeight, + staticAreaMaxItemHeight, ], ); @@ -176,7 +213,8 @@ export const MainContent = () => { key={uiState.historyRemountKey} items={[ , - ...historyItems, + ...staticHistoryItems, + ...lastResponseHistoryItems, ]} > {(item) => item} diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index e96694eeaf..d5c89215b8 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -9,11 +9,17 @@ import { act } from 'react'; import { ModelDialog } from './ModelDialog.js'; import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; +import { createMockSettings } from '../../test-utils/settings.js'; import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL, + PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, + PREVIEW_GEMINI_FLASH_MODEL, + AuthType, } from '@google/gemini-cli-core'; import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core'; @@ -42,12 +48,14 @@ describe('', () => { const mockGetModel = vi.fn(); const mockOnClose = vi.fn(); const mockGetHasAccessToPreviewModel = vi.fn(); + const mockGetGemini31LaunchedSync = vi.fn(); interface MockConfig extends Partial { setModel: (model: string, isTemporary?: boolean) => void; getModel: () => string; getHasAccessToPreviewModel: () => boolean; getIdeMode: () => boolean; + getGemini31LaunchedSync: () => boolean; } const mockConfig: MockConfig = { @@ -55,12 +63,14 @@ describe('', () => { getModel: mockGetModel, getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel, getIdeMode: () => false, + getGemini31LaunchedSync: mockGetGemini31LaunchedSync, }; beforeEach(() => { vi.resetAllMocks(); mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO); mockGetHasAccessToPreviewModel.mockReturnValue(false); + mockGetGemini31LaunchedSync.mockReturnValue(false); // Default implementation for getDisplayString mockGetDisplayString.mockImplementation((val: string) => { @@ -70,9 +80,21 @@ describe('', () => { }); }); - const renderComponent = async (configValue = mockConfig as Config) => { + const renderComponent = async ( + configValue = mockConfig as Config, + authType = AuthType.LOGIN_WITH_GOOGLE, + ) => { + const settings = createMockSettings({ + security: { + auth: { + selectedType: authType, + }, + }, + }); + const result = renderWithProviders(, { config: configValue, + settings, }); await result.waitUntilReady(); return result; @@ -87,7 +109,15 @@ describe('', () => { unmount(); }); - it('switches to "manual" view when "Manual" is selected', async () => { + it('switches to "manual" view when "Manual" is selected and uses getDisplayString for models', async () => { + mockGetDisplayString.mockImplementation((val: string) => { + if (val === DEFAULT_GEMINI_MODEL) return 'Formatted Pro Model'; + if (val === DEFAULT_GEMINI_FLASH_MODEL) return 'Formatted Flash Model'; + if (val === DEFAULT_GEMINI_FLASH_LITE_MODEL) + return 'Formatted Lite Model'; + return val; + }); + const { lastFrame, stdin, waitUntilReady, unmount } = await renderComponent(); @@ -107,9 +137,9 @@ describe('', () => { // Should now show manual options await waitFor(() => { const output = lastFrame(); - expect(output).toContain(DEFAULT_GEMINI_MODEL); - expect(output).toContain(DEFAULT_GEMINI_FLASH_MODEL); - expect(output).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL); + expect(output).toContain('Formatted Pro Model'); + expect(output).toContain('Formatted Flash Model'); + expect(output).toContain('Formatted Lite Model'); }); unmount(); }); @@ -241,4 +271,103 @@ describe('', () => { }); unmount(); }); + + it('shows the preferred manual model in the main view option using getDisplayString', async () => { + mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL); + mockGetDisplayString.mockImplementation((val: string) => { + if (val === DEFAULT_GEMINI_MODEL) return 'My Custom Model Display'; + if (val === 'auto-gemini-2.5') return 'Auto (Gemini 2.5)'; + return val; + }); + const { lastFrame, unmount } = await renderComponent(); + + expect(lastFrame()).toContain('Manual (My Custom Model Display)'); + unmount(); + }); + + describe('Preview Models', () => { + beforeEach(() => { + mockGetHasAccessToPreviewModel.mockReturnValue(true); + }); + + it('shows Auto (Preview) in main view when access is granted', async () => { + const { lastFrame, unmount } = await renderComponent(); + expect(lastFrame()).toContain('Auto (Preview)'); + unmount(); + }); + + it('shows Gemini 3 models in manual view when Gemini 3.1 is NOT launched', async () => { + mockGetGemini31LaunchedSync.mockReturnValue(false); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderComponent(); + + // Go to manual view + await act(async () => { + stdin.write('\u001B[B'); // Manual + }); + await waitUntilReady(); + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain(PREVIEW_GEMINI_MODEL); + expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL); + unmount(); + }); + + it('shows Gemini 3.1 models in manual view when Gemini 3.1 IS launched', async () => { + mockGetGemini31LaunchedSync.mockReturnValue(true); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderComponent(mockConfig as Config, AuthType.USE_VERTEX_AI); + + // Go to manual view + await act(async () => { + stdin.write('\u001B[B'); // Manual + }); + await waitUntilReady(); + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain(PREVIEW_GEMINI_3_1_MODEL); + expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL); + unmount(); + }); + + it('uses custom tools model when Gemini 3.1 IS launched and auth is Gemini API Key', async () => { + mockGetGemini31LaunchedSync.mockReturnValue(true); + const { stdin, waitUntilReady, unmount } = await renderComponent( + mockConfig as Config, + AuthType.USE_GEMINI, + ); + + // Go to manual view + await act(async () => { + stdin.write('\u001B[B'); // Manual + }); + await waitUntilReady(); + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + // Select Gemini 3.1 (first item in preview section) + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + await waitFor(() => { + expect(mockSetModel).toHaveBeenCalledWith( + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, + true, + ); + }); + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 88be57b841..7d7fea4d86 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -9,6 +9,7 @@ import { useCallback, useContext, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL, @@ -18,11 +19,14 @@ import { ModelSlashCommandEvent, logModelSlashCommand, getDisplayString, + AuthType, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; interface ModelDialogProps { onClose: () => void; @@ -30,6 +34,7 @@ interface ModelDialogProps { export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const config = useContext(ConfigContext); + const settings = useSettings(); const [view, setView] = useState<'main' | 'manual'>('main'); const [persistMode, setPersistMode] = useState(false); @@ -37,6 +42,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO; const shouldShowPreviewModels = config?.getHasAccessToPreviewModel(); + const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false; + const selectedAuthType = settings.merged.security.auth.selectedType; + const useCustomToolModel = + useGemini31 && selectedAuthType === AuthType.USE_GEMINI; const manualModelSelected = useMemo(() => { const manualModels = [ @@ -44,6 +53,8 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL, PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_FLASH_MODEL, ]; if (manualModels.includes(preferredModel)) { @@ -83,7 +94,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { { value: 'Manual', title: manualModelSelected - ? `Manual (${manualModelSelected})` + ? `Manual (${getDisplayString(manualModelSelected)})` : 'Manual', description: 'Manually select a model', key: 'Manual', @@ -94,49 +105,58 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { list.unshift({ value: PREVIEW_GEMINI_MODEL_AUTO, title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO), - description: - 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash', + description: useGemini31 + ? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash' + : 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash', key: PREVIEW_GEMINI_MODEL_AUTO, }); } return list; - }, [shouldShowPreviewModels, manualModelSelected]); + }, [shouldShowPreviewModels, manualModelSelected, useGemini31]); const manualOptions = useMemo(() => { const list = [ { value: DEFAULT_GEMINI_MODEL, - title: DEFAULT_GEMINI_MODEL, + title: getDisplayString(DEFAULT_GEMINI_MODEL), key: DEFAULT_GEMINI_MODEL, }, { value: DEFAULT_GEMINI_FLASH_MODEL, - title: DEFAULT_GEMINI_FLASH_MODEL, + title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL), key: DEFAULT_GEMINI_FLASH_MODEL, }, { value: DEFAULT_GEMINI_FLASH_LITE_MODEL, - title: DEFAULT_GEMINI_FLASH_LITE_MODEL, + title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL), key: DEFAULT_GEMINI_FLASH_LITE_MODEL, }, ]; if (shouldShowPreviewModels) { + const previewProModel = useGemini31 + ? PREVIEW_GEMINI_3_1_MODEL + : PREVIEW_GEMINI_MODEL; + + const previewProValue = useCustomToolModel + ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL + : previewProModel; + list.unshift( { - value: PREVIEW_GEMINI_MODEL, - title: PREVIEW_GEMINI_MODEL, - key: PREVIEW_GEMINI_MODEL, + value: previewProValue, + title: getDisplayString(previewProModel), + key: previewProModel, }, { value: PREVIEW_GEMINI_FLASH_MODEL, - title: PREVIEW_GEMINI_FLASH_MODEL, + title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL), key: PREVIEW_GEMINI_FLASH_MODEL, }, ); } return list; - }, [shouldShowPreviewModels]); + }, [shouldShowPreviewModels, useGemini31, useCustomToolModel]); const options = view === 'main' ? mainOptions : manualOptions; diff --git a/packages/cli/src/ui/components/Notifications.test.tsx b/packages/cli/src/ui/components/Notifications.test.tsx index 4929d61fd5..f732ff2f23 100644 --- a/packages/cli/src/ui/components/Notifications.test.tsx +++ b/packages/cli/src/ui/components/Notifications.test.tsx @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render, persistentStateMock } from '../../test-utils/render.js'; +import { + persistentStateMock, + renderWithProviders, +} from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import type { LoadedSettings } from '../../config/settings.js'; import { waitFor } from '../../test-utils/async.js'; import { Notifications } from './Notifications.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -13,6 +18,7 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js'; import { useIsScreenReaderEnabled } from 'ink'; import * as fs from 'node:fs/promises'; import { act } from 'react'; +import { WarningPriority } from '@google/gemini-cli-core'; // Mock dependencies vi.mock('../contexts/AppContext.js'); @@ -61,22 +67,18 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...actual, GEMINI_DIR: '.gemini', homedir: () => '/mock/home', + WarningPriority: { + Low: 'low', + High: 'high', + }, Storage: { ...actual.Storage, getGlobalTempDir: () => '/mock/temp', + getGlobalSettingsPath: () => '/mock/home/.gemini/settings.json', }, }; }); -vi.mock('../../config/settings.js', () => ({ - DEFAULT_MODEL_CONFIGS: {}, - LoadedSettings: class { - constructor() { - // this.merged = {}; - } - }, -})); - describe('Notifications', () => { const mockUseAppContext = vi.mocked(useAppContext); const mockUseUIState = vi.mocked(useUIState); @@ -84,9 +86,14 @@ describe('Notifications', () => { const mockFsAccess = vi.mocked(fs.access); const mockFsUnlink = vi.mocked(fs.unlink); + let settings: LoadedSettings; + beforeEach(() => { vi.clearAllMocks(); persistentStateMock.reset(); + settings = createMockSettings({ + ui: { useAlternateBuffer: true }, + }); mockUseAppContext.mockReturnValue({ startupWarnings: [], version: '1.0.0', @@ -100,60 +107,195 @@ describe('Notifications', () => { }); it('renders nothing when no notifications', async () => { - const { lastFrame, waitUntilReady, unmount } = render(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); - it.each([[['Warning 1']], [['Warning 1', 'Warning 2']]])( - 'renders startup warnings: %s', - async (warnings) => { - mockUseAppContext.mockReturnValue({ - startupWarnings: warnings, - version: '1.0.0', - } as AppState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); - const output = lastFrame(); - warnings.forEach((warning) => { - expect(output).toContain(warning); - }); - unmount(); - }, - ); + it.each([ + [[{ id: 'w1', message: 'Warning 1', priority: WarningPriority.High }]], + [ + [ + { id: 'w1', message: 'Warning 1', priority: WarningPriority.High }, + { id: 'w2', message: 'Warning 2', priority: WarningPriority.High }, + ], + ], + ])('renders startup warnings: %s', async (warnings) => { + const appState = { + startupWarnings: warnings, + version: '1.0.0', + } as AppState; + mockUseAppContext.mockReturnValue(appState); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + appState, + settings, + width: 100, + }, + ); + await waitUntilReady(); + const output = lastFrame(); + warnings.forEach((warning) => { + expect(output).toContain(warning.message); + }); + unmount(); + }); + + it('increments show count for low priority warnings', async () => { + const warnings = [ + { id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low }, + ]; + const appState = { + startupWarnings: warnings, + version: '1.0.0', + } as AppState; + mockUseAppContext.mockReturnValue(appState); + + const { waitUntilReady, unmount } = renderWithProviders(, { + appState, + settings, + width: 100, + }); + await waitUntilReady(); + + expect(persistentStateMock.set).toHaveBeenCalledWith( + 'startupWarningCounts', + { 'low-1': 1 }, + ); + unmount(); + }); + + it('filters out low priority warnings that exceeded max show count', async () => { + const warnings = [ + { id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low }, + { + id: 'high-1', + message: 'High priority 1', + priority: WarningPriority.High, + }, + ]; + const appState = { + startupWarnings: warnings, + version: '1.0.0', + } as AppState; + mockUseAppContext.mockReturnValue(appState); + + persistentStateMock.setData({ + startupWarningCounts: { 'low-1': 3 }, + }); + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + appState, + settings, + width: 100, + }, + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).not.toContain('Low priority 1'); + expect(output).toContain('High priority 1'); + unmount(); + }); + + it('dismisses warnings on keypress', async () => { + const warnings = [ + { + id: 'high-1', + message: 'High priority 1', + priority: WarningPriority.High, + }, + ]; + const appState = { + startupWarnings: warnings, + version: '1.0.0', + } as AppState; + mockUseAppContext.mockReturnValue(appState); + + const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( + , + { + appState, + settings, + width: 100, + }, + ); + await waitUntilReady(); + expect(lastFrame()).toContain('High priority 1'); + + await act(async () => { + stdin.write('a'); + }); + await waitUntilReady(); + + expect(lastFrame({ allowEmpty: true })).not.toContain('High priority 1'); + unmount(); + }); it('renders init error', async () => { - mockUseUIState.mockReturnValue({ + const uiState = { initError: 'Something went wrong', streamingState: 'idle', updateInfo: null, - } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); + } as unknown as UIState; + mockUseUIState.mockReturnValue(uiState); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState, + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('does not render init error when streaming', async () => { - mockUseUIState.mockReturnValue({ + const uiState = { initError: 'Something went wrong', streamingState: 'responding', updateInfo: null, - } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); + } as unknown as UIState; + mockUseUIState.mockReturnValue(uiState); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState, + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); it('renders update notification', async () => { - mockUseUIState.mockReturnValue({ + const uiState = { initError: null, streamingState: 'idle', updateInfo: { message: 'Update available' }, - } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); + } as unknown as UIState; + mockUseUIState.mockReturnValue(uiState); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState, + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -164,7 +306,13 @@ describe('Notifications', () => { persistentStateMock.setData({ hasSeenScreenReaderNudge: false }); mockFsAccess.mockRejectedValue(new Error('No legacy file')); - const { lastFrame, waitUntilReady, unmount } = render(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame()).toContain('screen reader-friendly view'); @@ -182,7 +330,10 @@ describe('Notifications', () => { persistentStateMock.setData({ hasSeenScreenReaderNudge: undefined }); mockFsAccess.mockResolvedValue(undefined); - const { waitUntilReady, unmount } = render(); + const { waitUntilReady, unmount } = renderWithProviders(, { + settings, + width: 100, + }); await act(async () => { await waitUntilReady(); @@ -201,7 +352,13 @@ describe('Notifications', () => { mockUseIsScreenReaderEnabled.mockReturnValue(true); persistentStateMock.setData({ hasSeenScreenReaderNudge: true }); - const { lastFrame, waitUntilReady, unmount } = render(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); diff --git a/packages/cli/src/ui/components/Notifications.tsx b/packages/cli/src/ui/components/Notifications.tsx index 1573ef682c..4753f8f6cb 100644 --- a/packages/cli/src/ui/components/Notifications.tsx +++ b/packages/cli/src/ui/components/Notifications.tsx @@ -5,15 +5,22 @@ */ import { Box, Text, useIsScreenReaderEnabled } from 'ink'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { useAppContext } from '../contexts/AppContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { theme } from '../semantic-colors.js'; import { StreamingState } from '../types.js'; import { UpdateNotification } from './UpdateNotification.js'; import { persistentState } from '../../utils/persistentState.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { KeypressPriority } from '../contexts/KeypressContext.js'; -import { GEMINI_DIR, Storage, homedir } from '@google/gemini-cli-core'; +import { + GEMINI_DIR, + Storage, + homedir, + WarningPriority, +} from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import path from 'node:path'; @@ -25,12 +32,13 @@ const screenReaderNudgeFilePath = path.join( 'seen_screen_reader_nudge.json', ); +const MAX_STARTUP_WARNING_SHOW_COUNT = 3; + export const Notifications = () => { const { startupWarnings } = useAppContext(); const { initError, streamingState, updateInfo } = useUIState(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); - const showStartupWarnings = startupWarnings.length > 0; const showInitError = initError && streamingState !== StreamingState.Responding; @@ -38,6 +46,57 @@ export const Notifications = () => { persistentState.get('hasSeenScreenReaderNudge'), ); + const [dismissed, setDismissed] = useState(false); + + // Track if we have already incremented the show count in this session + const hasIncrementedRef = useRef(false); + + // Filter warnings based on persistent state count if low priority + const visibleWarnings = useMemo(() => { + if (dismissed) return []; + + const counts = persistentState.get('startupWarningCounts') || {}; + return startupWarnings.filter((w) => { + if (w.priority === WarningPriority.Low) { + const count = counts[w.id] || 0; + return count < MAX_STARTUP_WARNING_SHOW_COUNT; + } + return true; + }); + }, [startupWarnings, dismissed]); + + const showStartupWarnings = visibleWarnings.length > 0; + + // Increment counts for low priority warnings when shown + useEffect(() => { + if (visibleWarnings.length > 0 && !hasIncrementedRef.current) { + const counts = { ...(persistentState.get('startupWarningCounts') || {}) }; + let changed = false; + visibleWarnings.forEach((w) => { + if (w.priority === WarningPriority.Low) { + counts[w.id] = (counts[w.id] || 0) + 1; + changed = true; + } + }); + if (changed) { + persistentState.set('startupWarningCounts', counts); + } + hasIncrementedRef.current = true; + } + }, [visibleWarnings]); + + const handleKeyPress = useCallback(() => { + if (showStartupWarnings) { + setDismissed(true); + } + return false; + }, [showStartupWarnings]); + + useKeypress(handleKeyPress, { + isActive: showStartupWarnings, + priority: KeypressPriority.Critical, + }); + useEffect(() => { const checkLegacyScreenReaderNudge = async () => { if (hasSeenScreenReaderNudge !== undefined) return; @@ -89,13 +148,13 @@ export const Notifications = () => { {updateInfo && } {showStartupWarnings && ( - {startupWarnings.map((warning, index) => ( + {visibleWarnings.map((warning, index) => ( - {warning} + {warning.message} ))} diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx new file mode 100644 index 0000000000..bab59d83ce --- /dev/null +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { act } from 'react'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { PolicyUpdateDialog } from './PolicyUpdateDialog.js'; +import { + type Config, + type PolicyUpdateConfirmationRequest, + PolicyIntegrityManager, +} from '@google/gemini-cli-core'; + +const { mockAcceptIntegrity } = vi.hoisted(() => ({ + mockAcceptIntegrity: vi.fn(), +})); + +// Mock PolicyIntegrityManager +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ + acceptIntegrity: mockAcceptIntegrity, + checkIntegrity: vi.fn(), + })), + }; +}); + +describe('PolicyUpdateDialog', () => { + let mockConfig: Config; + let mockRequest: PolicyUpdateConfirmationRequest; + let onClose: () => void; + + beforeEach(() => { + mockConfig = { + loadWorkspacePolicies: vi.fn().mockResolvedValue(undefined), + } as unknown as Config; + + mockRequest = { + scope: 'workspace', + identifier: '/test/workspace/.gemini/policies', + policyDir: '/test/workspace/.gemini/policies', + newHash: 'test-hash', + } as PolicyUpdateConfirmationRequest; + + onClose = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly and matches snapshot', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( + , + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toMatchSnapshot(); + expect(output).toContain('New or changed workspace policies detected'); + expect(output).toContain('Location: /test/workspace/.gemini/policies'); + expect(output).toContain('Accept and Load'); + expect(output).toContain('Ignore'); + }); + + it('handles ACCEPT correctly', async () => { + const { stdin } = renderWithProviders( + , + ); + + // Accept is the first option, so pressing enter should select it + await act(async () => { + stdin.write('\r'); + }); + + await waitFor(() => { + expect(PolicyIntegrityManager).toHaveBeenCalled(); + expect(mockConfig.loadWorkspacePolicies).toHaveBeenCalledWith( + mockRequest.policyDir, + ); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('handles IGNORE correctly', async () => { + const { stdin } = renderWithProviders( + , + ); + + // Move down to Ignore option + await act(async () => { + stdin.write('\x1B[B'); // Down arrow + }); + await act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + expect(PolicyIntegrityManager).not.toHaveBeenCalled(); + expect(mockConfig.loadWorkspacePolicies).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('calls onClose when Escape key is pressed', async () => { + const { stdin } = renderWithProviders( + , + ); + + await act(async () => { + stdin.write('\x1B'); // Escape key (matches Command.ESCAPE default) + }); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx new file mode 100644 index 0000000000..e6ed75c4db --- /dev/null +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { useCallback, useRef } from 'react'; +import type React from 'react'; +import { + type Config, + type PolicyUpdateConfirmationRequest, + PolicyIntegrityManager, +} from '@google/gemini-cli-core'; +import { theme } from '../semantic-colors.js'; +import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; + +export enum PolicyUpdateChoice { + ACCEPT = 'accept', + IGNORE = 'ignore', +} + +interface PolicyUpdateDialogProps { + config: Config; + request: PolicyUpdateConfirmationRequest; + onClose: () => void; +} + +export const PolicyUpdateDialog: React.FC = ({ + config, + request, + onClose, +}) => { + const isProcessing = useRef(false); + + const handleSelect = useCallback( + async (choice: PolicyUpdateChoice) => { + if (isProcessing.current) { + return; + } + + isProcessing.current = true; + try { + if (choice === PolicyUpdateChoice.ACCEPT) { + const integrityManager = new PolicyIntegrityManager(); + await integrityManager.acceptIntegrity( + request.scope, + request.identifier, + request.newHash, + ); + await config.loadWorkspacePolicies(request.policyDir); + } + onClose(); + } finally { + isProcessing.current = false; + } + }, + [config, request, onClose], + ); + + useKeypress( + (key) => { + if (keyMatchers[Command.ESCAPE](key)) { + onClose(); + return true; + } + return false; + }, + { isActive: true }, + ); + + const options: Array> = [ + { + label: 'Accept and Load', + value: PolicyUpdateChoice.ACCEPT, + key: 'accept', + }, + { + label: 'Ignore (Use Default Policies)', + value: PolicyUpdateChoice.IGNORE, + key: 'ignore', + }, + ]; + + return ( + + + + + New or changed {request.scope} policies detected + + Location: {request.identifier} + + Do you want to accept and load these policies? + + + + + + + ); +}; diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 2bfbe7a9fa..e426e9bbe3 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -7,6 +7,7 @@ import type React from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react'; import { Text } from 'ink'; +import { AsyncFzf } from 'fzf'; import type { Key } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import type { @@ -31,17 +32,27 @@ import { getEffectiveValue, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; +import { getCachedStringWidth } from '../utils/textUtils.js'; import { type SettingsValue, TOGGLE_TYPES, } from '../../config/settingsSchema.js'; import { coreEvents, debugLogger } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; + +import { useSearchBuffer } from '../hooks/useSearchBuffer.js'; import { - type SettingsDialogItem, BaseSettingsDialog, + type SettingsDialogItem, } from './shared/BaseSettingsDialog.js'; -import { useFuzzyList } from '../hooks/useFuzzyList.js'; + +interface FzfResult { + item: string; + start: number; + end: number; + score: number; + positions?: number[]; +} interface SettingsDialogProps { settings: LoadedSettings; @@ -70,6 +81,61 @@ export function SettingsDialog({ const [showRestartPrompt, setShowRestartPrompt] = useState(false); + // Search state + const [searchQuery, setSearchQuery] = useState(''); + const [filteredKeys, setFilteredKeys] = useState(() => + getDialogSettingKeys(), + ); + const { fzfInstance, searchMap } = useMemo(() => { + const keys = getDialogSettingKeys(); + const map = new Map(); + const searchItems: string[] = []; + + keys.forEach((key) => { + const def = getSettingDefinition(key); + if (def?.label) { + searchItems.push(def.label); + map.set(def.label.toLowerCase(), key); + } + }); + + const fzf = new AsyncFzf(searchItems, { + fuzzy: 'v2', + casing: 'case-insensitive', + }); + return { fzfInstance: fzf, searchMap: map }; + }, []); + + // Perform search + useEffect(() => { + let active = true; + if (!searchQuery.trim() || !fzfInstance) { + setFilteredKeys(getDialogSettingKeys()); + return; + } + + const doSearch = async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const results = await fzfInstance.find(searchQuery); + + if (!active) return; + + const matchedKeys = new Set(); + results.forEach((res: FzfResult) => { + const key = searchMap.get(res.item.toLowerCase()); + if (key) matchedKeys.add(key); + }); + setFilteredKeys(Array.from(matchedKeys)); + }; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + doSearch(); + + return () => { + active = false; + }; + }, [searchQuery, fzfInstance, searchMap]); + // Local pending settings state for the selected scope const [pendingSettings, setPendingSettings] = useState(() => // Deep clone to avoid mutation @@ -117,8 +183,39 @@ export function SettingsDialog({ setShowRestartPrompt(newRestartRequired.size > 0); }, [selectedScope, settings, globalPendingChanges]); - // Generate items for SearchableList - const settingKeys = useMemo(() => getDialogSettingKeys(), []); + // Calculate max width for the left column (Label/Description) to keep values aligned or close + const maxLabelOrDescriptionWidth = useMemo(() => { + const allKeys = getDialogSettingKeys(); + let max = 0; + for (const key of allKeys) { + const def = getSettingDefinition(key); + if (!def) continue; + + const scopeMessage = getScopeMessageForSetting( + key, + selectedScope, + settings, + ); + const label = def.label || key; + const labelFull = label + (scopeMessage ? ` ${scopeMessage}` : ''); + const lWidth = getCachedStringWidth(labelFull); + const dWidth = def.description + ? getCachedStringWidth(def.description) + : 0; + + max = Math.max(max, lWidth, dWidth); + } + return max; + }, [selectedScope, settings]); + + // Search input buffer + const searchBuffer = useSearchBuffer({ + initialText: '', + onChange: setSearchQuery, + }); + + // Generate items for BaseSettingsDialog + const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys(); const items: SettingsDialogItem[] = useMemo(() => { const scopeSettings = settings.forScope(selectedScope).settings; const mergedSettings = settings.merged; @@ -164,10 +261,6 @@ export function SettingsDialog({ }); }, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]); - const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ - items, - }); - // Scope selection handler const handleScopeChange = useCallback((scope: LoadableSettingScope) => { setSelectedScope(scope); @@ -594,12 +687,12 @@ export function SettingsDialog({ borderColor={showRestartPrompt ? theme.status.warning : undefined} searchEnabled={showSearch} searchBuffer={searchBuffer} - items={filteredItems} + items={items} showScopeSelector={showScopeSelection} selectedScope={selectedScope} onScopeChange={handleScopeChange} maxItemsToShow={effectiveMaxItemsToShow} - maxLabelWidth={maxLabelWidth} + maxLabelWidth={maxLabelOrDescriptionWidth} onItemToggle={handleItemToggle} onEditCommit={handleEditCommit} onItemClear={handleItemClear} diff --git a/packages/cli/src/ui/components/ShowMoreLines.test.tsx b/packages/cli/src/ui/components/ShowMoreLines.test.tsx index 699e2b7f01..4a6829809a 100644 --- a/packages/cli/src/ui/components/ShowMoreLines.test.tsx +++ b/packages/cli/src/ui/components/ShowMoreLines.test.tsx @@ -29,7 +29,6 @@ describe('ShowMoreLines', () => { it.each([ [new Set(), StreamingState.Idle, true], // No overflow [new Set(['1']), StreamingState.Idle, false], // Not constraining height - [new Set(['1']), StreamingState.Responding, true], // Streaming ])( 'renders nothing when: overflow=%s, streaming=%s, constrain=%s', async (overflowingIds, streamingState, constrainHeight) => { @@ -46,9 +45,28 @@ describe('ShowMoreLines', () => { }, ); - it.each([[StreamingState.Idle], [StreamingState.WaitingForConfirmation]])( - 'renders message when overflowing and state is %s', + it('renders nothing in STANDARD mode even if overflowing', async () => { + mockUseAlternateBuffer.mockReturnValue(false); + mockUseOverflowState.mockReturnValue({ + overflowingIds: new Set(['1']), + } as NonNullable>); + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); + }); + + it.each([ + [StreamingState.Idle], + [StreamingState.WaitingForConfirmation], + [StreamingState.Responding], + ])( + 'renders message in ASB mode when overflowing and state is %s', async (streamingState) => { + mockUseAlternateBuffer.mockReturnValue(true); mockUseOverflowState.mockReturnValue({ overflowingIds: new Set(['1']), } as NonNullable>); @@ -57,8 +75,39 @@ describe('ShowMoreLines', () => { , ); await waitUntilReady(); - expect(lastFrame()).toContain('Press ctrl-o to show more lines'); + expect(lastFrame().toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); unmount(); }, ); + + it('renders message in ASB mode when isOverflowing prop is true even if internal overflow state is empty', async () => { + mockUseAlternateBuffer.mockReturnValue(true); + mockUseOverflowState.mockReturnValue({ + overflowingIds: new Set(), + } as NonNullable>); + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame().toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); + unmount(); + }); + + it('renders nothing when isOverflowing prop is false even if internal overflow state has IDs', async () => { + mockUseOverflowState.mockReturnValue({ + overflowingIds: new Set(['1']), + } as NonNullable>); + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/ShowMoreLines.tsx b/packages/cli/src/ui/components/ShowMoreLines.tsx index a3317d4dc6..92acd2b29a 100644 --- a/packages/cli/src/ui/components/ShowMoreLines.tsx +++ b/packages/cli/src/ui/components/ShowMoreLines.tsx @@ -9,31 +9,42 @@ import { useOverflowState } from '../contexts/OverflowContext.js'; import { useStreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; import { theme } from '../semantic-colors.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; interface ShowMoreLinesProps { constrainHeight: boolean; + isOverflowing?: boolean; } -export const ShowMoreLines = ({ constrainHeight }: ShowMoreLinesProps) => { +export const ShowMoreLines = ({ + constrainHeight, + isOverflowing: isOverflowingProp, +}: ShowMoreLinesProps) => { + const isAlternateBuffer = useAlternateBuffer(); const overflowState = useOverflowState(); const streamingState = useStreamingContext(); + const isOverflowing = + isOverflowingProp ?? + (overflowState !== undefined && overflowState.overflowingIds.size > 0); + if ( - overflowState === undefined || - overflowState.overflowingIds.size === 0 || + !isAlternateBuffer || + !isOverflowing || !constrainHeight || !( streamingState === StreamingState.Idle || - streamingState === StreamingState.WaitingForConfirmation + streamingState === StreamingState.WaitingForConfirmation || + streamingState === StreamingState.Responding ) ) { return null; } return ( - - - Press ctrl-o to show more lines + + + Press Ctrl+O to show more lines ); diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index 3b42512424..d12dd4eb07 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -23,11 +23,13 @@ import { import { computeSessionStats } from '../utils/computeStats.js'; import { type RetrieveUserQuotaResponse, - VALID_GEMINI_MODELS, + isActiveModel, getDisplayString, isAutoModel, + AuthType, } from '@google/gemini-cli-core'; import { useSettings } from '../contexts/SettingsContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; import type { QuotaStats } from '../types.js'; import { QuotaStatsInfo } from './QuotaStatsInfo.js'; @@ -82,9 +84,13 @@ const Section: React.FC = ({ title, children }) => ( const buildModelRows = ( models: Record, quotas?: RetrieveUserQuotaResponse, + useGemini3_1 = false, + useCustomToolModel = false, ) => { const getBaseModelName = (name: string) => name.replace('-001', ''); - const usedModelNames = new Set(Object.keys(models).map(getBaseModelName)); + const usedModelNames = new Set( + Object.keys(models).map(getBaseModelName).map(getDisplayString), + ); // 1. Models with active usage const activeRows = Object.entries(models).map(([name, metrics]) => { @@ -93,7 +99,7 @@ const buildModelRows = ( const inputTokens = metrics.tokens.input; return { key: name, - modelName, + modelName: getDisplayString(modelName), requests: metrics.api.totalRequests, cachedTokens: cachedTokens.toLocaleString(), inputTokens: inputTokens.toLocaleString(), @@ -109,12 +115,12 @@ const buildModelRows = ( ?.filter( (b) => b.modelId && - VALID_GEMINI_MODELS.has(b.modelId) && - !usedModelNames.has(b.modelId), + isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) && + !usedModelNames.has(getDisplayString(b.modelId)), ) .map((bucket) => ({ key: bucket.modelId!, - modelName: bucket.modelId!, + modelName: getDisplayString(bucket.modelId!), requests: '-', cachedTokens: '-', inputTokens: '-', @@ -135,6 +141,8 @@ const ModelUsageTable: React.FC<{ pooledRemaining?: number; pooledLimit?: number; pooledResetTime?: string; + useGemini3_1?: boolean; + useCustomToolModel?: boolean; }> = ({ models, quotas, @@ -144,8 +152,10 @@ const ModelUsageTable: React.FC<{ pooledRemaining, pooledLimit, pooledResetTime, + useGemini3_1, + useCustomToolModel, }) => { - const rows = buildModelRows(models, quotas); + const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel); if (rows.length === 0) { return null; @@ -403,7 +413,11 @@ export const StatsDisplay: React.FC = ({ const { models, tools, files } = metrics; const computed = computeSessionStats(metrics); const settings = useSettings(); - + const config = useConfig(); + const useGemini3_1 = config.getGemini31LaunchedSync?.() ?? false; + const useCustomToolModel = + useGemini3_1 && + config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI; const pooledRemaining = quotaStats?.remaining; const pooledLimit = quotaStats?.limit; const pooledResetTime = quotaStats?.resetTime; @@ -544,6 +558,8 @@ export const StatsDisplay: React.FC = ({ pooledRemaining={pooledRemaining} pooledLimit={pooledLimit} pooledResetTime={pooledResetTime} + useGemini3_1={useGemini3_1} + useCustomToolModel={useCustomToolModel} /> {renderFooter()} diff --git a/packages/cli/src/ui/components/ToastDisplay.test.tsx b/packages/cli/src/ui/components/ToastDisplay.test.tsx index da50999204..f2ef9a287b 100644 --- a/packages/cli/src/ui/components/ToastDisplay.test.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.test.tsx @@ -35,12 +35,22 @@ describe('ToastDisplay', () => { buffer: { text: '' } as TextBuffer, history: [] as HistoryItem[], queueErrorMessage: null, + showIsExpandableHint: false, }; it('returns false for default state', () => { expect(shouldShowToast(baseState as UIState)).toBe(false); }); + it('returns true when showIsExpandableHint is true', () => { + expect( + shouldShowToast({ + ...baseState, + showIsExpandableHint: true, + } as UIState), + ).toBe(true); + }); + it('returns true when ctrlCPressedOnce is true', () => { expect( shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState), @@ -170,4 +180,22 @@ describe('ToastDisplay', () => { await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); + + it('renders expansion hint when showIsExpandableHint is true', async () => { + const { lastFrame, waitUntilReady } = renderToastDisplay({ + showIsExpandableHint: true, + constrainHeight: true, + }); + await waitUntilReady(); + expect(lastFrame()).toContain('Press Ctrl+O to show more lines'); + }); + + it('renders collapse hint when showIsExpandableHint is true and constrainHeight is false', async () => { + const { lastFrame, waitUntilReady } = renderToastDisplay({ + showIsExpandableHint: true, + constrainHeight: false, + }); + await waitUntilReady(); + expect(lastFrame()).toContain('Press Ctrl+O to collapse lines'); + }); }); diff --git a/packages/cli/src/ui/components/ToastDisplay.tsx b/packages/cli/src/ui/components/ToastDisplay.tsx index 37d2997e33..e383201219 100644 --- a/packages/cli/src/ui/components/ToastDisplay.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.tsx @@ -17,7 +17,8 @@ export function shouldShowToast(uiState: UIState): boolean { uiState.ctrlDPressedOnce || (uiState.showEscapePrompt && (uiState.buffer.text.length > 0 || uiState.history.length > 0)) || - Boolean(uiState.queueErrorMessage) + Boolean(uiState.queueErrorMessage) || + uiState.showIsExpandableHint ); } @@ -73,5 +74,14 @@ export const ToastDisplay: React.FC = () => { return {uiState.queueErrorMessage}; } + if (uiState.showIsExpandableHint) { + const action = uiState.constrainHeight ? 'show more' : 'collapse'; + return ( + + Press Ctrl+O to {action} lines for the most recent response + + ); + } + return null; }; diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index 4ea2f22796..ab7d080b37 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -49,7 +49,7 @@ describe('ToolConfirmationQueue', () => { readFile: vi.fn().mockResolvedValue('Plan content'), }), storage: { - getProjectTempPlansDir: () => '/mock/temp/plans', + getPlansDir: () => '/mock/temp/plans', }, } as unknown as Config; @@ -161,7 +161,7 @@ describe('ToolConfirmationQueue', () => { , { config: mockConfig, - useAlternateBuffer: false, + useAlternateBuffer: true, uiState: { terminalWidth: 80, terminalHeight: 20, @@ -173,10 +173,11 @@ describe('ToolConfirmationQueue', () => { await waitUntilReady(); await waitFor(() => - expect(lastFrame()).toContain('Press ctrl-o to show more lines'), + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ), ); expect(lastFrame()).toMatchSnapshot(); - expect(lastFrame()).toContain('Press ctrl-o to show more lines'); unmount(); }); @@ -324,7 +325,7 @@ describe('ToolConfirmationQueue', () => { await waitUntilReady(); const output = lastFrame(); - expect(output).not.toContain('Press ctrl-o to show more lines'); + expect(output).not.toContain('Press CTRL-O to show more lines'); expect(output).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index e3c18e0231..c89c98f8d4 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -71,13 +71,12 @@ export const ToolConfirmationQueue: React.FC = ({ // - 2 lines for the rounded border // - 2 lines for the Header (text + margin) // - 2 lines for Tool Identity (text + margin) - const availableContentHeight = - constrainHeight && !isAlternateBuffer - ? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4) - : undefined; + const availableContentHeight = constrainHeight + ? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4) + : undefined; - return ( - + const content = ( + <> = ({ /> - + + ); + + return isAlternateBuffer ? ( + /* Shadow the global provider to maintain isolation in ASB mode. */ + {content} + ) : ( + content ); }; diff --git a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap index 3da5a05c0c..8fb49b8b71 100644 --- a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap @@ -43,7 +43,6 @@ Tips for getting started: │ ✓ tool1 Description for tool 1 │ │ │ ╰──────────────────────────────────────────────────────────────────────────╯ - ╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool2 Description for tool 2 │ │ │ @@ -90,7 +89,6 @@ Tips for getting started: │ ✓ tool1 Description for tool 1 │ │ │ ╰──────────────────────────────────────────────────────────────────────────╯ - ╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool2 Description for tool 2 │ │ │ diff --git a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap index 7163409cc0..2ba370a000 100644 --- a/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/Composer.test.tsx.snap @@ -1,8 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Composer > Snapshots > matches snapshot in idle state 1`] = ` -" - ShortcutsHint +" ShortcutsHint ──────────────────────────────────────────────────────────────────────────────────────────────────── ApprovalModeIndicator StatusDisplay InputPrompt: Type your message or @path/to/file @@ -11,22 +10,19 @@ Footer `; exports[`Composer > Snapshots > matches snapshot in minimal UI mode 1`] = ` -" - ShortcutsHint +" ShortcutsHint InputPrompt: Type your message or @path/to/file " `; exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = ` -" - LoadingIndicator +" LoadingIndicator InputPrompt: Type your message or @path/to/file " `; exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = ` " - ShortcutsHint ──────────────────────────────────────── ApprovalModeIndicator @@ -39,8 +35,7 @@ Footer `; exports[`Composer > Snapshots > matches snapshot while streaming 1`] = ` -" - LoadingIndicator: Thinking ShortcutsHint +" LoadingIndicator: Thinking ShortcutsHint ──────────────────────────────────────────────────────────────────────────────────────────────────── ApprovalModeIndicator InputPrompt: Type your message or @path/to/file diff --git a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap index 28929deee5..8d03baaa49 100644 --- a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap @@ -18,20 +18,8 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more " `; -exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = ` -" -Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more -" -`; - exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = ` " Spinner Connecting to MCP servers... (1/2) - Waiting for: server2 " `; - -exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = ` -" -Spinner Connecting to MCP servers... (1/2) - Waiting for: server2 -" -`; diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap index faa759a050..587ded8f29 100644 --- a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap @@ -27,33 +27,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; -exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = ` -"Overview - -Add user authentication to the CLI application. - -Implementation Steps - - 1. Create src/auth/AuthService.ts with login/logout methods - 2. Add session storage in src/storage/SessionStore.ts - 3. Update src/commands/index.ts to check auth status - 4. Add tests in src/auth/__tests__/ - -Files to Modify - - - src/index.ts - Add auth middleware - - src/config.ts - Add auth configuration options - - 1. Yes, automatically accept edits - Approves plan and allows tools to run automatically - 2. Yes, manually accept edits - Approves plan but requires confirmation for each tool -● 3. Type your feedback... - -Enter to submit · Esc to cancel -" -`; - exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = ` "Overview @@ -81,33 +54,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; -exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = ` -"Overview - -Add user authentication to the CLI application. - -Implementation Steps - - 1. Create src/auth/AuthService.ts with login/logout methods - 2. Add session storage in src/storage/SessionStore.ts - 3. Update src/commands/index.ts to check auth status - 4. Add tests in src/auth/__tests__/ - -Files to Modify - - - src/index.ts - Add auth middleware - - src/config.ts - Add auth configuration options - - 1. Yes, automatically accept edits - Approves plan and allows tools to run automatically - 2. Yes, manually accept edits - Approves plan but requires confirmation for each tool -● 3. Add tests - -Enter to submit · Esc to cancel -" -`; - exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = ` " Error reading plan: File not found " @@ -194,33 +140,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; -exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = ` -"Overview - -Add user authentication to the CLI application. - -Implementation Steps - - 1. Create src/auth/AuthService.ts with login/logout methods - 2. Add session storage in src/storage/SessionStore.ts - 3. Update src/commands/index.ts to check auth status - 4. Add tests in src/auth/__tests__/ - -Files to Modify - - - src/index.ts - Add auth middleware - - src/config.ts - Add auth configuration options - - 1. Yes, automatically accept edits - Approves plan and allows tools to run automatically - 2. Yes, manually accept edits - Approves plan but requires confirmation for each tool -● 3. Type your feedback... - -Enter to submit · Esc to cancel -" -`; - exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = ` "Overview @@ -248,33 +167,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; -exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = ` -"Overview - -Add user authentication to the CLI application. - -Implementation Steps - - 1. Create src/auth/AuthService.ts with login/logout methods - 2. Add session storage in src/storage/SessionStore.ts - 3. Update src/commands/index.ts to check auth status - 4. Add tests in src/auth/__tests__/ - -Files to Modify - - - src/index.ts - Add auth middleware - - src/config.ts - Add auth configuration options - - 1. Yes, automatically accept edits - Approves plan and allows tools to run automatically - 2. Yes, manually accept edits - Approves plan but requires confirmation for each tool -● 3. Add tests - -Enter to submit · Esc to cancel -" -`; - exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = ` " Error reading plan: File not found " diff --git a/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap new file mode 100644 index 0000000000..5f5b3c9c27 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap @@ -0,0 +1,15 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PolicyUpdateDialog > renders correctly and matches snapshot 1`] = ` +" ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ New or changed workspace policies detected │ + │ Location: /test/workspace/.gemini/policies │ + │ Do you want to accept and load these policies? │ + │ │ + │ ● 1. Accept and Load │ + │ 2. Ignore (Use Default Policies) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 0dba43a791..3fec2244d7 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -22,6 +22,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -69,6 +69,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -78,9 +81,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -116,6 +116,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false* │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -125,9 +128,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -163,6 +163,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -172,9 +175,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -210,6 +210,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -219,9 +222,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -257,6 +257,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -266,9 +269,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ > Apply To │ @@ -304,6 +304,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -313,9 +316,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -351,6 +351,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -360,9 +363,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -398,6 +398,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion true* │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -407,9 +410,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap index 56dcb64d70..b5e013ef48 100644 --- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap @@ -16,7 +16,6 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai │ 4. No, suggest changes (esc) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines " `; @@ -107,7 +106,7 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and │ 4. No, suggest changes (esc) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines + Press Ctrl+O to show more lines " `; diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index 3c17a3850f..0bdf9b65e9 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -12,6 +12,7 @@ import { theme } from '../../semantic-colors.js'; import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js'; import { useUIState } from '../../contexts/UIStateContext.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; +import { OverflowProvider } from '../../contexts/OverflowContext.js'; interface GeminiMessageProps { text: string; @@ -31,7 +32,7 @@ export const GeminiMessage: React.FC = ({ const prefixWidth = prefix.length; const isAlternateBuffer = useAlternateBuffer(); - return ( + const content = ( @@ -61,4 +62,11 @@ export const GeminiMessage: React.FC = ({ ); + + return isAlternateBuffer ? ( + /* Shadow the global provider to maintain isolation in ASB mode. */ + {content} + ) : ( + content + ); }; diff --git a/packages/cli/src/ui/components/messages/ModelMessage.tsx b/packages/cli/src/ui/components/messages/ModelMessage.tsx index bddbae8e8b..b313dab6f1 100644 --- a/packages/cli/src/ui/components/messages/ModelMessage.tsx +++ b/packages/cli/src/ui/components/messages/ModelMessage.tsx @@ -7,6 +7,7 @@ import type React from 'react'; import { Text, Box } from 'ink'; import { theme } from '../../semantic-colors.js'; +import { getDisplayString } from '@google/gemini-cli-core'; interface ModelMessageProps { model: string; @@ -15,7 +16,7 @@ interface ModelMessageProps { export const ModelMessage: React.FC = ({ model }) => ( - Responding with {model} + Responding with {getDisplayString(model)} ); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 6359b5b250..72ce8cec5f 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -191,7 +191,7 @@ describe('', () => { true, ], [ - 'defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined', + 'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined', undefined, ACTIVE_SHELL_MAX_LINES, false, @@ -219,5 +219,75 @@ describe('', () => { expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines); expect(frame).toMatchSnapshot(); }); + + it('fully expands in standard mode when availableTerminalHeight is undefined', async () => { + const { lastFrame } = renderShell( + { + resultDisplay: LONG_OUTPUT, + renderOutputAsMarkdown: false, + availableTerminalHeight: undefined, + status: CoreToolCallStatus.Executing, + }, + { useAlternateBuffer: false }, + ); + + await waitFor(() => { + const frame = lastFrame(); + // Should show all 100 lines + expect(frame.match(/Line \d+/g)?.length).toBe(100); + }); + }); + + it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => { + const { lastFrame, waitUntilReady } = renderShell( + { + resultDisplay: LONG_OUTPUT, + renderOutputAsMarkdown: false, + availableTerminalHeight: undefined, + status: CoreToolCallStatus.Success, + isExpandable: true, + }, + { + useAlternateBuffer: true, + uiState: { + constrainHeight: false, + }, + }, + ); + + await waitUntilReady(); + await waitFor(() => { + const frame = lastFrame(); + // Should show all 100 lines because constrainHeight is false and isExpandable is true + expect(frame.match(/Line \d+/g)?.length).toBe(100); + }); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => { + const { lastFrame, waitUntilReady } = renderShell( + { + resultDisplay: LONG_OUTPUT, + renderOutputAsMarkdown: false, + availableTerminalHeight: undefined, + status: CoreToolCallStatus.Success, + isExpandable: false, + }, + { + useAlternateBuffer: true, + uiState: { + constrainHeight: false, + }, + }, + ); + + await waitUntilReady(); + await waitFor(() => { + const frame = lastFrame(); + // Should still be constrained to ACTIVE_SHELL_MAX_LINES (15) because isExpandable is false + expect(frame.match(/Line \d+/g)?.length).toBe(15); + }); + expect(lastFrame()).toMatchSnapshot(); + }); }); }); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 50af3bc1e6..54abbc09d3 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -15,24 +15,21 @@ import { ToolStatusIndicator, ToolInfo, TrailingIndicator, - STATUS_INDICATOR_WIDTH, isThisShellFocusable as checkIsShellFocusable, isThisShellFocused as checkIsShellFocused, useFocusHint, FocusHint, } from './ToolShared.js'; import type { ToolMessageProps } from './ToolMessage.js'; -import { - ACTIVE_SHELL_MAX_LINES, - COMPLETED_SHELL_MAX_LINES, -} from '../../constants.js'; +import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; -import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core'; - import { useUIState } from '../../contexts/UIStateContext.js'; +import { type Config } from '@google/gemini-cli-core'; +import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js'; export interface ShellToolMessageProps extends ToolMessageProps { config?: Config; + isExpandable?: boolean; } export const ShellToolMessage: React.FC = ({ @@ -61,9 +58,15 @@ export const ShellToolMessage: React.FC = ({ borderColor, borderDimColor, + isExpandable, }) => { - const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState(); + const { + activePtyId: activeShellPtyId, + embeddedShellFocused, + constrainHeight, + } = useUIState(); const isAlternateBuffer = useAlternateBuffer(); + const isThisShellFocused = checkIsShellFocused( name, status, @@ -155,59 +158,23 @@ export const ShellToolMessage: React.FC = ({ terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} hasFocus={isThisShellFocused} - maxLines={getShellMaxLines( + maxLines={calculateShellMaxLines({ status, isAlternateBuffer, isThisShellFocused, availableTerminalHeight, - )} + constrainHeight, + isExpandable, + })} /> {isThisShellFocused && config && ( - - - + )} ); }; - -/** - * Calculates the maximum number of lines to display for shell output. - * - * For completed processes (Success, Error, Canceled), it returns COMPLETED_SHELL_MAX_LINES. - * For active processes, it returns the available terminal height if in alternate buffer mode - * and focused. Otherwise, it returns ACTIVE_SHELL_MAX_LINES. - * - * This function ensures a finite number of lines is always returned to prevent performance issues. - */ -function getShellMaxLines( - status: CoreToolCallStatus, - isAlternateBuffer: boolean, - isThisShellFocused: boolean, - availableTerminalHeight: number | undefined, -): number { - if ( - status === CoreToolCallStatus.Success || - status === CoreToolCallStatus.Error || - status === CoreToolCallStatus.Cancelled - ) { - return COMPLETED_SHELL_MAX_LINES; - } - - if (availableTerminalHeight === undefined) { - return ACTIVE_SHELL_MAX_LINES; - } - - const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2); - - if (isAlternateBuffer && isThisShellFocused) { - return maxLinesBasedOnHeight; - } - - return Math.min(maxLinesBasedOnHeight, ACTIVE_SHELL_MAX_LINES); -} diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index bef187ea22..22d522e06c 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -8,6 +8,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import type { SerializableConfirmationDetails, + ToolCallConfirmationDetails, Config, } from '@google/gemini-cli-core'; import { renderWithProviders } from '../../../test-utils/render.js'; @@ -87,6 +88,122 @@ describe('ToolConfirmationMessage', () => { unmount(); }); + it('should display WarningMessage for deceptive URLs in info type', async () => { + const confirmationDetails: SerializableConfirmationDetails = { + type: 'info', + title: 'Confirm Web Fetch', + prompt: 'https://täst.com', + urls: ['https://täst.com'], + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('Deceptive URL(s) detected'); + expect(output).toContain('Original: https://täst.com'); + expect(output).toContain( + 'Actual Host (Punycode): https://xn--tst-qla.com/', + ); + unmount(); + }); + + it('should display WarningMessage for deceptive URLs in exec type commands', async () => { + const confirmationDetails: SerializableConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command: 'curl https://еxample.com', + rootCommand: 'curl', + rootCommands: ['curl'], + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('Deceptive URL(s) detected'); + expect(output).toContain('Original: https://еxample.com/'); + expect(output).toContain( + 'Actual Host (Punycode): https://xn--xample-2of.com/', + ); + unmount(); + }); + + it('should exclude shell delimiters from extracted URLs in exec type commands', async () => { + const confirmationDetails: SerializableConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command: 'curl https://еxample.com;ls', + rootCommand: 'curl', + rootCommands: ['curl'], + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('Deceptive URL(s) detected'); + // It should extract "https://еxample.com" and NOT "https://еxample.com;ls" + expect(output).toContain('Original: https://еxample.com/'); + // The command itself still contains 'ls', so we check specifically that 'ls' is not part of the URL line. + expect(output).not.toContain('Original: https://еxample.com/;ls'); + unmount(); + }); + + it('should aggregate multiple deceptive URLs into a single WarningMessage', async () => { + const confirmationDetails: SerializableConfirmationDetails = { + type: 'info', + title: 'Confirm Web Fetch', + prompt: 'Fetch both', + urls: ['https://еxample.com', 'https://täst.com'], + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('Deceptive URL(s) detected'); + expect(output).toContain('Original: https://еxample.com/'); + expect(output).toContain('Original: https://täst.com/'); + unmount(); + }); + it('should display multiple commands for exec type when provided', async () => { const confirmationDetails: SerializableConfirmationDetails = { type: 'exec', @@ -372,4 +489,35 @@ describe('ToolConfirmationMessage', () => { unmount(); }); }); + + it('should strip BiDi characters from MCP tool and server names', async () => { + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'mcp', + title: 'Confirm MCP Tool', + serverName: 'test\u202Eserver', + toolName: 'test\u202Dtool', + toolDisplayName: 'Test Tool', + onConfirm: vi.fn(), + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame(); + // BiDi characters \u202E and \u202D should be stripped + expect(output).toContain('MCP Server: testserver'); + expect(output).toContain('Tool: testtool'); + expect(output).toContain('Allow execution of MCP tool "testtool"'); + expect(output).toContain('from server "testserver"?'); + expect(output).toMatchSnapshot(); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 42642d66f9..c4e73b73f6 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -21,7 +21,10 @@ import type { RadioSelectItem } from '../shared/RadioButtonSelect.js'; import { useToolActions } from '../../contexts/ToolActionsContext.js'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; -import { sanitizeForDisplay } from '../../utils/textUtils.js'; +import { + sanitizeForDisplay, + stripUnsafeCharacters, +} from '../../utils/textUtils.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; @@ -34,6 +37,12 @@ import { } from '../../textConstants.js'; import { AskUserDialog } from '../AskUserDialog.js'; import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js'; +import { WarningMessage } from './WarningMessage.js'; +import { + getDeceptiveUrlDetails, + toUnicodeUrl, + type DeceptiveUrlDetails, +} from '../../utils/urlSecurityUtils.js'; export interface ToolConfirmationMessageProps { callId: string; @@ -99,6 +108,37 @@ export const ToolConfirmationMessage: React.FC< [handleConfirm], ); + const deceptiveUrlWarnings = useMemo(() => { + const urls: string[] = []; + if (confirmationDetails.type === 'info' && confirmationDetails.urls) { + urls.push(...confirmationDetails.urls); + } else if (confirmationDetails.type === 'exec') { + const commands = + confirmationDetails.commands && confirmationDetails.commands.length > 0 + ? confirmationDetails.commands + : [confirmationDetails.command]; + for (const cmd of commands) { + const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g); + if (matches) urls.push(...matches); + } + } + + const uniqueUrls = Array.from(new Set(urls)); + return uniqueUrls + .map(getDeceptiveUrlDetails) + .filter((d): d is DeceptiveUrlDetails => d !== null); + }, [confirmationDetails]); + + const deceptiveUrlWarningText = useMemo(() => { + if (deceptiveUrlWarnings.length === 0) return null; + return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings + .map( + (w) => + ` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`, + ) + .join('\n\n')}`; + }, [deceptiveUrlWarnings]); + const getOptions = useCallback(() => { const options: Array> = []; @@ -259,11 +299,21 @@ export const ToolConfirmationMessage: React.FC< return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); }, [availableTerminalHeight, getOptions, handlesOwnUI]); - const { question, bodyContent, options } = useMemo(() => { + const { question, bodyContent, options, securityWarnings } = useMemo<{ + question: string; + bodyContent: React.ReactNode; + options: Array>; + securityWarnings: React.ReactNode; + }>(() => { let bodyContent: React.ReactNode | null = null; + let securityWarnings: React.ReactNode | null = null; let question = ''; const options = getOptions(); + if (deceptiveUrlWarningText) { + securityWarnings = ; + } + if (confirmationDetails.type === 'ask_user') { bodyContent = ( ); - return { question: '', bodyContent, options: [] }; + return { + question: '', + bodyContent, + options: [], + securityWarnings: null, + }; } if (confirmationDetails.type === 'exit_plan_mode') { @@ -304,7 +359,7 @@ export const ToolConfirmationMessage: React.FC< availableHeight={availableBodyContentHeight()} /> ); - return { question: '', bodyContent, options: [] }; + return { question: '', bodyContent, options: [], securityWarnings: null }; } if (confirmationDetails.type === 'edit') { @@ -324,15 +379,15 @@ export const ToolConfirmationMessage: React.FC< } else if (confirmationDetails.type === 'mcp') { // mcp tool confirmation const mcpProps = confirmationDetails; - question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`; + question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`; } if (confirmationDetails.type === 'edit') { if (!confirmationDetails.isModifying) { bodyContent = ( @@ -433,10 +488,10 @@ export const ToolConfirmationMessage: React.FC< {displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( URLs to fetch: - {infoProps.urls.map((url) => ( - + {infoProps.urls.map((urlString) => ( + {' '} - - + - ))} @@ -449,19 +504,24 @@ export const ToolConfirmationMessage: React.FC< bodyContent = ( - MCP Server: {mcpProps.serverName} - Tool: {mcpProps.toolName} + + MCP Server: {sanitizeForDisplay(mcpProps.serverName)} + + + Tool: {sanitizeForDisplay(mcpProps.toolName)} + ); } - return { question, bodyContent, options }; + return { question, bodyContent, options, securityWarnings }; }, [ confirmationDetails, getOptions, availableBodyContentHeight, terminalWidth, handleConfirm, + deceptiveUrlWarningText, ]); if (confirmationDetails.type === 'edit') { @@ -505,6 +565,12 @@ export const ToolConfirmationMessage: React.FC< + {securityWarnings && ( + + {securityWarnings} + + )} + {question} diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index b3e0275a1b..1ead1503e5 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -6,6 +6,7 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act } from 'react'; import { ToolGroupMessage } from './ToolGroupMessage.js'; import type { HistoryItem, @@ -678,4 +679,194 @@ describe('', () => { }, ); }); + + describe('Manual Overflow Detection', () => { + it('detects overflow for string results exceeding available height', async () => { + const toolCalls = [ + createToolCall({ + resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5', + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); + unmount(); + }); + + it('detects overflow for array results exceeding available height', async () => { + // resultDisplay when array is expected to be AnsiLine[] + // AnsiLine is AnsiToken[] + const toolCalls = [ + createToolCall({ + resultDisplay: Array(5).fill([{ text: 'line', fg: 'default' }]), + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); + unmount(); + }); + + it('respects ACTIVE_SHELL_MAX_LINES for focused shell tools', async () => { + const toolCalls = [ + createToolCall({ + name: 'run_shell_command', + status: CoreToolCallStatus.Executing, + ptyId: 1, + resultDisplay: Array(20).fill('line').join('\n'), // 20 lines > 15 (limit) + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + activePtyId: 1, + embeddedShellFocused: true, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); + unmount(); + }); + + it('does not show expansion hint when content is within limits', async () => { + const toolCalls = [ + createToolCall({ + resultDisplay: 'small result', + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines'); + unmount(); + }); + + it('hides expansion hint when constrainHeight is false', async () => { + const toolCalls = [ + createToolCall({ + resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5', + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: false, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines'); + unmount(); + }); + + it('isolates overflow hint in ASB mode (ignores global overflow state)', async () => { + // In this test, the tool output is SHORT (no local overflow). + // We will inject a dummy ID into the global overflow state. + // ToolGroupMessage should still NOT show the hint because it calculates + // overflow locally and passes it as a prop. + const toolCalls = [ + createToolCall({ + resultDisplay: 'short result', + }), + ]; + const { lastFrame, unmount, waitUntilReady, capturedOverflowActions } = + renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + }, + }, + ); + await waitUntilReady(); + + // Manually trigger a global overflow + act(() => { + expect(capturedOverflowActions).toBeDefined(); + capturedOverflowActions!.addOverflowingId('unrelated-global-id'); + }); + + // The hint should NOT appear because ToolGroupMessage is isolated by its prop logic + expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines'); + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index f4e1c200db..3c3dcf56d3 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -17,10 +17,15 @@ import { ToolMessage } from './ToolMessage.js'; import { ShellToolMessage } from './ShellToolMessage.js'; import { theme } from '../../semantic-colors.js'; import { useConfig } from '../../contexts/ConfigContext.js'; -import { isShellTool } from './ToolShared.js'; +import { isShellTool, isThisShellFocused } from './ToolShared.js'; import { shouldHideToolCall } from '@google/gemini-cli-core'; import { ShowMoreLines } from '../ShowMoreLines.js'; import { useUIState } from '../../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; +import { + calculateShellMaxLines, + calculateToolContentMaxLines, +} from '../../utils/toolLayoutUtils.js'; import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js'; interface ToolGroupMessageProps { @@ -31,6 +36,7 @@ interface ToolGroupMessageProps { onShellInputSubmit?: (input: string) => void; borderTop?: boolean; borderBottom?: boolean; + isExpandable?: boolean; } // Main component renders the border and maps the tools using ToolMessage @@ -43,6 +49,7 @@ export const ToolGroupMessage: React.FC = ({ terminalWidth, borderTop: borderTopOverride, borderBottom: borderBottomOverride, + isExpandable, }) => { // Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations). const toolCalls = useMemo( @@ -67,6 +74,7 @@ export const ToolGroupMessage: React.FC = ({ backgroundShells, pendingHistoryItems, } = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); const { borderColor, borderDimColor } = useMemo( () => @@ -106,14 +114,6 @@ export const ToolGroupMessage: React.FC = ({ const staticHeight = /* border */ 2; - // If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools), - // only render if we need to close a border from previous - // tool groups. borderBottomOverride=true means we must render the closing border; - // undefined or false means there's nothing to display. - if (visibleToolCalls.length === 0 && borderBottomOverride !== true) { - return null; - } - let countToolCallsWithResults = 0; for (const tool of visibleToolCalls) { if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') { @@ -134,21 +134,91 @@ export const ToolGroupMessage: React.FC = ({ const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN; - return ( - // This box doesn't have a border even though it conceptually does because - // we need to allow the sticky headers to render the borders themselves so - // that the top border can be sticky. + /* + * ToolGroupMessage calculates its own overflow state locally and passes + * it as a prop to ShowMoreLines. This isolates it from global overflow + * reports in ASB mode, while allowing it to contribute to the global + * 'Toast' hint in Standard mode. + * + * Because of this prop-based isolation and the explicit mode-checks in + * AppContainer, we do not need to shadow the OverflowProvider here. + */ + const hasOverflow = useMemo(() => { + if (!availableTerminalHeightPerToolMessage) return false; + return visibleToolCalls.some((tool) => { + const isShellToolCall = isShellTool(tool.name); + const isFocused = isThisShellFocused( + tool.name, + tool.status, + tool.ptyId, + activePtyId, + embeddedShellFocused, + ); + + let maxLines: number | undefined; + + if (isShellToolCall) { + maxLines = calculateShellMaxLines({ + status: tool.status, + isAlternateBuffer, + isThisShellFocused: isFocused, + availableTerminalHeight: availableTerminalHeightPerToolMessage, + constrainHeight, + isExpandable, + }); + } + + // Standard tools and Shell tools both eventually use ToolResultDisplay's logic. + // ToolResultDisplay uses calculateToolContentMaxLines to find the final line budget. + const contentMaxLines = calculateToolContentMaxLines({ + availableTerminalHeight: availableTerminalHeightPerToolMessage, + isAlternateBuffer, + maxLinesLimit: maxLines, + }); + + if (!contentMaxLines) return false; + + if (typeof tool.resultDisplay === 'string') { + const text = tool.resultDisplay; + const hasTrailingNewline = text.endsWith('\n'); + const contentText = hasTrailingNewline ? text.slice(0, -1) : text; + const lineCount = contentText.split('\n').length; + return lineCount > contentMaxLines; + } + if (Array.isArray(tool.resultDisplay)) { + return tool.resultDisplay.length > contentMaxLines; + } + return false; + }); + }, [ + visibleToolCalls, + availableTerminalHeightPerToolMessage, + activePtyId, + embeddedShellFocused, + isAlternateBuffer, + constrainHeight, + isExpandable, + ]); + + // If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools), + // only render if we need to close a border from previous + // tool groups. borderBottomOverride=true means we must render the closing border; + // undefined or false means there's nothing to display. + if (visibleToolCalls.length === 0 && borderBottomOverride !== true) { + return null; + } + + const content = ( {visibleToolCalls.map((tool, index) => { const isFirst = index === 0; @@ -165,6 +235,7 @@ export const ToolGroupMessage: React.FC = ({ : isFirst, borderColor, borderDimColor, + isExpandable, }; return ( @@ -179,34 +250,34 @@ export const ToolGroupMessage: React.FC = ({ ) : ( )} - - {tool.outputFile && ( + {tool.outputFile && ( + Output too long and was saved to: {tool.outputFile} - )} - + + )} ); })} { /* - We have to keep the bottom border separate so it doesn't get - drawn over by the sticky header directly inside it. - */ + We have to keep the bottom border separate so it doesn't get + drawn over by the sticky header directly inside it. + */ (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && ( = ({ ) } {(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && ( - + )} ); + + return content; }; diff --git a/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx b/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx new file mode 100644 index 0000000000..f7629945d9 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { ToolGroupMessage } from './ToolGroupMessage.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; +import { StreamingState, type IndividualToolCallDisplay } from '../../types.js'; +import { OverflowProvider } from '../../contexts/OverflowContext.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { CoreToolCallStatus } from '@google/gemini-cli-core'; + +describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => { + it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => { + /** + * Logic: + * 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool. + * 2. ASB mode reserves 1 + 6 = 7 lines. + * 3. Line budget = 10 - 7 = 3 lines. + * 4. 5 lines of output > 3 lines budget => hasOverflow should be TRUE. + */ + + const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`); + const resultDisplay = lines.join('\n'); + + const toolCalls: IndividualToolCallDisplay[] = [ + { + callId: 'call-1', + name: 'test-tool', + description: 'a test tool', + status: CoreToolCallStatus.Success, + resultDisplay, + confirmationDetails: undefined, + }, + ]; + + const { lastFrame } = renderWithProviders( + + + , + { + uiState: { + streamingState: StreamingState.Idle, + constrainHeight: true, + }, + useAlternateBuffer: true, + }, + ); + + // In ASB mode, the hint should appear because hasOverflow is now correctly calculated. + await waitFor(() => + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ), + ); + }); + + it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => { + /** + * Logic: + * 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool. + * 2. Standard mode reserves 1 + 2 = 3 lines. + * 3. Line budget = 10 - 3 = 7 lines. + * 4. 9 lines of output > 7 lines budget => hasOverflow should be TRUE. + */ + + const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`); + const resultDisplay = lines.join('\n'); + + const toolCalls: IndividualToolCallDisplay[] = [ + { + callId: 'call-1', + name: 'test-tool', + description: 'a test tool', + status: CoreToolCallStatus.Success, + resultDisplay, + confirmationDetails: undefined, + }, + ]; + + const { lastFrame } = renderWithProviders( + + + , + { + uiState: { + streamingState: StreamingState.Idle, + constrainHeight: true, + }, + useAlternateBuffer: false, + }, + ); + + // Verify truncation is occurring (standard mode uses MaxSizedBox) + await waitFor(() => expect(lastFrame()).toContain('hidden ...')); + + // In Standard mode, ToolGroupMessage calculates hasOverflow correctly now. + // While Standard mode doesn't render the inline hint (ShowMoreLines returns null), + // the logic inside ToolGroupMessage is now synchronized. + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index c24fb8e58b..f7d158d68c 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -277,21 +277,47 @@ describe('ToolResultDisplay', () => { inverse: false, }, ], + [ + { + text: 'Line 4', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + [ + { + text: 'Line 5', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], ]; const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); - expect(output).toContain('Line 2'); - expect(output).toContain('Line 3'); + expect(output).not.toContain('Line 2'); + expect(output).not.toContain('Line 3'); + expect(output).toContain('Line 4'); + expect(output).toContain('Line 5'); unmount(); }); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 61f1540017..8e0fc4442a 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -19,10 +19,7 @@ import { Scrollable } from '../shared/Scrollable.js'; import { ScrollableList } from '../shared/ScrollableList.js'; import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js'; import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; - -const STATIC_HEIGHT = 1; -const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint -const MIN_LINES_SHOWN = 2; // show at least this many lines +import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js'; // Large threshold to ensure we don't cause performance issues for very large // outputs that will get truncated further MaxSizedBox anyway. @@ -53,16 +50,11 @@ export const ToolResultDisplay: React.FC = ({ const { renderMarkdown } = useUIState(); const isAlternateBuffer = useAlternateBuffer(); - let availableHeight = availableTerminalHeight - ? Math.max( - availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT, - MIN_LINES_SHOWN + 1, // enforce minimum lines shown - ) - : undefined; - - if (maxLines && availableHeight) { - availableHeight = Math.min(availableHeight, maxLines); - } + const availableHeight = calculateToolContentMaxLines({ + availableTerminalHeight, + isAlternateBuffer, + maxLinesLimit: maxLines, + }); const combinedPaddingAndBorderWidth = 4; const childWidth = terminalWidth - combinedPaddingAndBorderWidth; @@ -81,7 +73,8 @@ export const ToolResultDisplay: React.FC = ({ [], ); - const truncatedResultDisplay = React.useMemo(() => { + const { truncatedResultDisplay, hiddenLinesCount } = React.useMemo(() => { + let hiddenLines = 0; // Only truncate string output if not in alternate buffer mode to ensure // we can scroll through the full output. if (typeof resultDisplay === 'string' && !isAlternateBuffer) { @@ -94,14 +87,29 @@ export const ToolResultDisplay: React.FC = ({ const contentText = hasTrailingNewline ? text.slice(0, -1) : text; const lines = contentText.split('\n'); if (lines.length > maxLines) { + // We will have a label from MaxSizedBox. Reserve space for it. + const targetLines = Math.max(1, maxLines - 1); + hiddenLines = lines.length - targetLines; text = - lines.slice(-maxLines).join('\n') + + lines.slice(-targetLines).join('\n') + (hasTrailingNewline ? '\n' : ''); } } - return text; + return { truncatedResultDisplay: text, hiddenLinesCount: hiddenLines }; } - return resultDisplay; + + if (Array.isArray(resultDisplay) && !isAlternateBuffer && maxLines) { + if (resultDisplay.length > maxLines) { + // We will have a label from MaxSizedBox. Reserve space for it. + const targetLines = Math.max(1, maxLines - 1); + return { + truncatedResultDisplay: resultDisplay.slice(-targetLines), + hiddenLinesCount: resultDisplay.length - targetLines, + }; + } + } + + return { truncatedResultDisplay: resultDisplay, hiddenLinesCount: 0 }; }, [resultDisplay, isAlternateBuffer, maxLines]); if (!truncatedResultDisplay) return null; @@ -229,7 +237,11 @@ export const ToolResultDisplay: React.FC = ({ return ( - + {content} diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx index dd3184a19c..a196b8d989 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx @@ -39,6 +39,7 @@ describe('ToolResultDisplay Overflow', () => { toolCalls={toolCalls} availableTerminalHeight={15} // Small height to force overflow terminalWidth={80} + isExpandable={true} /> , { @@ -46,26 +47,28 @@ describe('ToolResultDisplay Overflow', () => { streamingState: StreamingState.Idle, constrainHeight: true, }, - useAlternateBuffer: false, + useAlternateBuffer: true, }, ); // ResizeObserver might take a tick await waitFor(() => - expect(lastFrame()).toContain('Press ctrl-o to show more lines'), + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ), ); const frame = lastFrame(); expect(frame).toBeDefined(); if (frame) { - expect(frame).toContain('Press ctrl-o to show more lines'); + expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines'); // Ensure it's AFTER the bottom border const linesOfOutput = frame.split('\n'); const bottomBorderIndex = linesOfOutput.findLastIndex((l) => l.includes('╰─'), ); const hintIndex = linesOfOutput.findIndex((l) => - l.includes('Press ctrl-o to show more lines'), + l.toLowerCase().includes('press ctrl+o to show more lines'), ); expect(hintIndex).toBeGreaterThan(bottomBorderIndex); expect(frame).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap index 7e3d34a577..0d34c7e49d 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined 1`] = ` +exports[` > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command A shell command │ │ │ @@ -22,6 +22,113 @@ exports[` > Height Constraints > defaults to ACTIVE_SHELL_MA " `; +exports[` > Height Constraints > fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ Shell Command A shell command │ +│ │ +│ Line 1 │ +│ Line 2 │ +│ Line 3 │ +│ Line 4 │ +│ Line 5 │ +│ Line 6 │ +│ Line 7 │ +│ Line 8 │ +│ Line 9 │ +│ Line 10 │ +│ Line 11 │ +│ Line 12 │ +│ Line 13 │ +│ Line 14 │ +│ Line 15 │ +│ Line 16 │ +│ Line 17 │ +│ Line 18 │ +│ Line 19 │ +│ Line 20 │ +│ Line 21 │ +│ Line 22 │ +│ Line 23 │ +│ Line 24 │ +│ Line 25 │ +│ Line 26 │ +│ Line 27 │ +│ Line 28 │ +│ Line 29 │ +│ Line 30 │ +│ Line 31 │ +│ Line 32 │ +│ Line 33 │ +│ Line 34 │ +│ Line 35 │ +│ Line 36 │ +│ Line 37 │ +│ Line 38 │ +│ Line 39 │ +│ Line 40 │ +│ Line 41 │ +│ Line 42 │ +│ Line 43 │ +│ Line 44 │ +│ Line 45 │ +│ Line 46 │ +│ Line 47 │ +│ Line 48 │ +│ Line 49 │ +│ Line 50 │ +│ Line 51 │ +│ Line 52 │ +│ Line 53 │ +│ Line 54 │ +│ Line 55 │ +│ Line 56 │ +│ Line 57 │ +│ Line 58 │ +│ Line 59 │ +│ Line 60 │ +│ Line 61 │ +│ Line 62 │ +│ Line 63 │ +│ Line 64 │ +│ Line 65 │ +│ Line 66 │ +│ Line 67 │ +│ Line 68 │ +│ Line 69 │ +│ Line 70 │ +│ Line 71 │ +│ Line 72 │ +│ Line 73 │ +│ Line 74 │ +│ Line 75 │ +│ Line 76 │ +│ Line 77 │ +│ Line 78 │ +│ Line 79 │ +│ Line 80 │ +│ Line 81 │ +│ Line 82 │ +│ Line 83 │ +│ Line 84 │ +│ Line 85 │ +│ Line 86 │ +│ Line 87 │ +│ Line 88 │ +│ Line 89 │ +│ Line 90 │ +│ Line 91 │ +│ Line 92 │ +│ Line 93 │ +│ Line 94 │ +│ Line 95 │ +│ Line 96 │ +│ Line 97 │ +│ Line 98 │ +│ Line 99 │ +│ Line 100 │ +" +`; + exports[` > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command A shell command │ @@ -37,6 +144,28 @@ exports[` > Height Constraints > respects availableTerminalH " `; +exports[` > Height Constraints > stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ Shell Command A shell command │ +│ │ +│ Line 86 │ +│ Line 87 │ +│ Line 88 │ +│ Line 89 │ +│ Line 90 │ +│ Line 91 │ +│ Line 92 │ +│ Line 93 │ +│ Line 94 │ +│ Line 95 │ +│ Line 96 │ +│ Line 97 │ +│ Line 98 ▄ │ +│ Line 99 █ │ +│ Line 100 █ │ +" +`; + exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command A shell command │ @@ -161,7 +290,6 @@ exports[` > Height Constraints > uses full availableTerminal │ Line 98 █ │ │ Line 99 █ │ │ Line 100 █ │ -│ │ " `; @@ -170,7 +298,6 @@ exports[` > Snapshots > renders in Alternate Buffer mode whi │ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │ │ │ │ Test result │ -│ │ " `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap index 69574a60c6..72eda055d5 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap @@ -35,6 +35,18 @@ Do you want to proceed? " `; +exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool and server names 1`] = ` +"MCP Server: testserver +Tool: testtool +Allow execution of MCP tool "testtool" from server "testserver"? + +● 1. Allow once + 2. Allow tool for this session + 3. Allow all server tools for this session + 4. No, suggest changes (esc) +" +`; + exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations' > should NOT show "allow always" when folder is untrusted 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ │ diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index c8a9707004..6adcb80a5c 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -55,13 +55,13 @@ exports[` > Golden Snapshots > renders header when scrolled "╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool-1 Description 1. This is a long description that will need to b… │ │──────────────────────────────────────────────────────────────────────────│ -│ │ ▄ +│ line5 │ █ +│ │ █ │ ✓ tool-2 Description 2 │ █ │ │ █ │ line1 │ █ │ line2 │ █ ╰──────────────────────────────────────────────────────────────────────────╯ █ - █ " `; @@ -111,12 +111,12 @@ exports[` > Golden Snapshots > renders tool call with output `; exports[` > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ +"╰──────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool-2 Description 2 │ -│ │ -│ line1 │ ▄ +│ │ ▄ +│ line1 │ █ ╰──────────────────────────────────────────────────────────────────────────╯ █ - █ " `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap index 1d9b58f0ce..d1e4b16d2f 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap @@ -37,7 +37,11 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp `; exports[`ToolResultDisplay > truncates very long string results 1`] = ` -"... first 252 lines hidden ... +"... first 248 lines hidden ... +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap index 3854b291db..aab4b690a1 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap @@ -4,13 +4,13 @@ exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when co "╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool a test tool │ │ │ -│ ... first 45 lines hidden ... │ +│ line 45 │ │ line 46 │ │ line 47 │ │ line 48 │ │ line 49 │ -│ line 50 │ +│ line 50 █ │ ╰──────────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines + Press Ctrl+O to show more lines " `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap index 66ca527b4b..dda93c1c21 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap @@ -2,7 +2,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = ` "╭────────────────────────────────────────────────────────────────────────╮ █ -│ ✓ Shell Command Description for Shell Command │ ▀ +│ ✓ Shell Command Description for Shell Command │ █ │ │ │ shell-01 │ │ shell-02 │ @@ -11,7 +11,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = ` "╭────────────────────────────────────────────────────────────────────────╮ -│ ✓ Shell Command Description for Shell Command │ +│ ✓ Shell Command Description for Shell Command │ ▄ │────────────────────────────────────────────────────────────────────────│ █ │ shell-06 │ ▀ │ shell-07 │ diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index e257600188..29592b479b 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -144,30 +144,28 @@ export function BaseSettingsDialog({ useEffect(() => { const prevItems = prevItemsRef.current; if (prevItems !== items) { - if (items.length === 0) { + const prevActiveItem = prevItems[activeIndex]; + if (prevActiveItem) { + const newIndex = items.findIndex((i) => i.key === prevActiveItem.key); + if (newIndex !== -1) { + // Item still exists in the filtered list, keep focus on it + setActiveIndex(newIndex); + // Adjust scroll offset to ensure the item is visible + let newScroll = scrollOffset; + if (newIndex < scrollOffset) newScroll = newIndex; + else if (newIndex >= scrollOffset + maxItemsToShow) + newScroll = newIndex - maxItemsToShow + 1; + + const maxScroll = Math.max(0, items.length - maxItemsToShow); + setScrollOffset(Math.min(newScroll, maxScroll)); + } else { + // Item was filtered out, reset to the top + setActiveIndex(0); + setScrollOffset(0); + } + } else { setActiveIndex(0); setScrollOffset(0); - } else { - const prevActiveItem = prevItems[activeIndex]; - if (prevActiveItem) { - const newIndex = items.findIndex((i) => i.key === prevActiveItem.key); - if (newIndex !== -1) { - // Item still exists in the filtered list, keep focus on it - setActiveIndex(newIndex); - // Adjust scroll offset to ensure the item is visible - let newScroll = scrollOffset; - if (newIndex < scrollOffset) newScroll = newIndex; - else if (newIndex >= scrollOffset + maxItemsToShow) - newScroll = newIndex - maxItemsToShow + 1; - - const maxScroll = Math.max(0, items.length - maxItemsToShow); - setScrollOffset(Math.min(newScroll, maxScroll)); - } else { - // Item was filtered out, reset to the top - setActiveIndex(0); - setScrollOffset(0); - } - } } prevItemsRef.current = items; } diff --git a/packages/cli/src/ui/components/shared/Scrollable.test.tsx b/packages/cli/src/ui/components/shared/Scrollable.test.tsx index 8c765c5acc..db32a1a2e9 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.test.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.test.tsx @@ -108,7 +108,27 @@ describe('', () => { throw new Error('capturedEntry is undefined'); } - // Initial state (starts at bottom because of auto-scroll logic) + // Initial state (starts at top by default) + expect(capturedEntry.getScrollState().scrollTop).toBe(0); + + // Initial state with scrollToBottom={true} + unmount(); + const { waitUntilReady: waitUntilReady2, unmount: unmount2 } = + renderWithProviders( + + Line 1 + Line 2 + Line 3 + Line 4 + Line 5 + Line 6 + Line 7 + Line 8 + Line 9 + Line 10 + , + ); + await waitUntilReady2(); expect(capturedEntry.getScrollState().scrollTop).toBe(5); // Call scrollBy multiple times (upwards) in the same tick @@ -116,14 +136,14 @@ describe('', () => { capturedEntry!.scrollBy(-1); capturedEntry!.scrollBy(-1); }); - // Should have moved up by 2 + // Should have moved up by 2 (5 -> 3) expect(capturedEntry.getScrollState().scrollTop).toBe(3); await act(async () => { capturedEntry!.scrollBy(-2); }); expect(capturedEntry.getScrollState().scrollTop).toBe(1); - unmount(); + unmount2(); }); describe('keypress handling', () => { diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index 8c53266d30..a830cbecfe 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -54,8 +54,7 @@ export const Scrollable: React.FC = ({ const childrenCountRef = useRef(0); // This effect needs to run on every render to correctly measure the container - // and scroll to the bottom if new children are added. The if conditions - // prevent infinite loops. + // and scroll to the bottom if new children are added. // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { if (!ref.current) { @@ -64,7 +63,8 @@ export const Scrollable: React.FC = ({ const innerHeight = Math.round(getInnerHeight(ref.current)); const scrollHeight = Math.round(getScrollHeight(ref.current)); - const isAtBottom = scrollTop >= size.scrollHeight - size.innerHeight - 1; + const isAtBottom = + scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1; if ( size.innerHeight !== innerHeight || diff --git a/packages/cli/src/ui/components/shared/SearchableList.test.tsx b/packages/cli/src/ui/components/shared/SearchableList.test.tsx index fa20352a8b..e156c12695 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.test.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.test.tsx @@ -8,11 +8,50 @@ import React from 'react'; import { render } from '../../../test-utils/render.js'; import { waitFor } from '../../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SearchableList, type SearchableListProps } from './SearchableList.js'; +import { + SearchableList, + type SearchableListProps, + type SearchListState, + type GenericListItem, +} from './SearchableList.js'; import { KeypressProvider } from '../../contexts/KeypressContext.js'; -import { type GenericListItem } from '../../hooks/useFuzzyList.js'; +import { useTextBuffer } from './text-buffer.js'; + +const useMockSearch = (props: { + items: GenericListItem[]; + initialQuery?: string; + onSearch?: (query: string) => void; +}): SearchListState => { + const { onSearch, items, initialQuery = '' } = props; + const [text, setText] = React.useState(initialQuery); + const filteredItems = React.useMemo( + () => + items.filter((item: GenericListItem) => + item.label.toLowerCase().includes(text.toLowerCase()), + ), + [items, text], + ); + + React.useEffect(() => { + onSearch?.(text); + }, [text, onSearch]); + + const searchBuffer = useTextBuffer({ + initialText: text, + onChange: setText, + viewport: { width: 100, height: 1 }, + singleLine: true, + }); + + return { + filteredItems, + searchBuffer, + searchQuery: text, + setSearchQuery: setText, + maxLabelWidth: 10, + }; +}; -// Mock UI State vi.mock('../../contexts/UIStateContext.js', () => ({ useUIState: () => ({ mainAreaWidth: 100, @@ -55,6 +94,7 @@ describe('SearchableList', () => { items: mockItems, onSelect: mockOnSelect, onClose: mockOnClose, + useSearch: useMockSearch, ...props, }; @@ -70,22 +110,59 @@ describe('SearchableList', () => { await waitUntilReady(); const frame = lastFrame(); - // Check for title expect(frame).toContain('Test List'); - // Check for items expect(frame).toContain('Item One'); expect(frame).toContain('Item Two'); expect(frame).toContain('Item Three'); - // Check for descriptions expect(frame).toContain('Description for item one'); }); + it('should reset selection to top when items change if resetSelectionOnItemsChange is true', async () => { + const { lastFrame, stdin, waitUntilReady } = renderList({ + resetSelectionOnItemsChange: true, + }); + await waitUntilReady(); + + await React.act(async () => { + stdin.write('\u001B[B'); // Down arrow + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('● Item Two'); + }); + expect(lastFrame()).toMatchSnapshot(); + + await React.act(async () => { + stdin.write('One'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Item One'); + expect(frame).not.toContain('Item Two'); + }); + expect(lastFrame()).toMatchSnapshot(); + + await React.act(async () => { + // Backspace "One" (3 chars) + stdin.write('\u007F\u007F\u007F'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Item Two'); + expect(frame).toContain('● Item One'); + expect(frame).not.toContain('● Item Two'); + }); + expect(lastFrame()).toMatchSnapshot(); + }); + it('should filter items based on search query', async () => { const { lastFrame, stdin } = renderList(); - // Type "Two" into search await React.act(async () => { stdin.write('Two'); }); @@ -101,7 +178,6 @@ describe('SearchableList', () => { it('should show "No items found." when no items match', async () => { const { lastFrame, stdin } = renderList(); - // Type something that won't match await React.act(async () => { stdin.write('xyz123'); }); @@ -115,7 +191,6 @@ describe('SearchableList', () => { it('should handle selection with Enter', async () => { const { stdin } = renderList(); - // Select first item (default active) await React.act(async () => { stdin.write('\r'); // Enter }); @@ -128,12 +203,10 @@ describe('SearchableList', () => { it('should handle navigation and selection', async () => { const { stdin } = renderList(); - // Navigate down to second item await React.act(async () => { - stdin.write('\u001B[B'); // Down Arrow + stdin.write('\u001B[B'); // Down arrow }); - // Select second item await React.act(async () => { stdin.write('\r'); // Enter }); @@ -154,4 +227,10 @@ describe('SearchableList', () => { expect(mockOnClose).toHaveBeenCalled(); }); }); + + it('should match snapshot', async () => { + const { lastFrame, waitUntilReady } = renderList(); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); }); diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx index 07720ce5d6..1611bc2842 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -1,136 +1,206 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; -import { useState, useEffect } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; +import { useSelectionList } from '../../hooks/useSelectionList.js'; import { TextInput } from './TextInput.js'; -import { useKeypress, type Key } from '../../hooks/useKeypress.js'; +import type { TextBuffer } from './text-buffer.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; import { keyMatchers, Command } from '../../keyMatchers.js'; -import { - useFuzzyList, - type GenericListItem, -} from '../../hooks/useFuzzyList.js'; -export interface SearchableListProps { - /** List title */ - title?: string; - /** Available items */ - items: T[]; - /** Callback when an item is selected */ - onSelect: (item: T) => void; - /** Callback when the list is closed (e.g. via Esc) */ - onClose?: () => void; - /** Initial search query */ - initialSearchQuery?: string; - /** Placeholder for search input */ - searchPlaceholder?: string; - /** Max items to show at once */ - maxItemsToShow?: number; +/** + * Generic interface for items in a searchable list. + */ +export interface GenericListItem { + key: string; + label: string; + description?: string; + [key: string]: unknown; } /** - * A generic searchable list component. + * State returned by the search hook. + */ +export interface SearchListState { + filteredItems: T[]; + searchBuffer: TextBuffer | undefined; + searchQuery: string; + setSearchQuery: (query: string) => void; + maxLabelWidth: number; +} + +/** + * Props for the SearchableList component. + */ +export interface SearchableListProps { + title?: string; + items: T[]; + onSelect: (item: T) => void; + onClose: () => void; + searchPlaceholder?: string; + /** Custom item renderer */ + renderItem?: ( + item: T, + isActive: boolean, + labelWidth: number, + ) => React.ReactNode; + /** Optional header content */ + header?: React.ReactNode; + /** Optional footer content */ + footer?: (info: { + startIndex: number; + endIndex: number; + totalVisible: number; + }) => React.ReactNode; + maxItemsToShow?: number; + /** Hook to handle search logic */ + useSearch: (props: { + items: T[]; + onSearch?: (query: string) => void; + }) => SearchListState; + onSearch?: (query: string) => void; + /** Whether to reset selection to the top when items change (e.g. after search) */ + resetSelectionOnItemsChange?: boolean; +} + +/** + * A generic searchable list component with keyboard navigation. */ export function SearchableList({ title, items, onSelect, onClose, - initialSearchQuery = '', searchPlaceholder = 'Search...', + renderItem, + header, + footer, maxItemsToShow = 10, + useSearch, + onSearch, + resetSelectionOnItemsChange = false, }: SearchableListProps): React.JSX.Element { - const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ + const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({ items, - initialQuery: initialSearchQuery, + onSearch, }); - const [activeIndex, setActiveIndex] = useState(0); - const [scrollOffset, setScrollOffset] = useState(0); - - // Reset selection when filtered items change - useEffect(() => { - setActiveIndex(0); - setScrollOffset(0); - }, [filteredItems]); - - // Calculate visible items - const visibleItems = filteredItems.slice( - scrollOffset, - scrollOffset + maxItemsToShow, + const selectionItems = useMemo( + () => + filteredItems.map((item) => ({ + key: item.key, + value: item, + })), + [filteredItems], ); - const showScrollUp = scrollOffset > 0; - const showScrollDown = scrollOffset + maxItemsToShow < filteredItems.length; + const handleSelectValue = useCallback( + (item: T) => { + onSelect(item); + }, + [onSelect], + ); + + const { activeIndex, setActiveIndex } = useSelectionList({ + items: selectionItems, + onSelect: handleSelectValue, + isFocused: true, + showNumbers: false, + wrapAround: true, + priority: true, + }); + + const [scrollOffsetState, setScrollOffsetState] = React.useState(0); + + // Compute effective scroll offset during render to avoid visual flicker + let scrollOffset = scrollOffsetState; + + if (activeIndex < scrollOffset) { + scrollOffset = activeIndex; + } else if (activeIndex >= scrollOffset + maxItemsToShow) { + scrollOffset = activeIndex - maxItemsToShow + 1; + } + + const maxScroll = Math.max(0, filteredItems.length - maxItemsToShow); + if (scrollOffset > maxScroll) { + scrollOffset = maxScroll; + } + + // Update state to match derived value if it changed + if (scrollOffsetState !== scrollOffset) { + setScrollOffsetState(scrollOffset); + } + + // Reset selection to top when items change if requested + const prevItemsRef = React.useRef(filteredItems); + React.useLayoutEffect(() => { + if (resetSelectionOnItemsChange && filteredItems !== prevItemsRef.current) { + setActiveIndex(0); + setScrollOffsetState(0); + } + prevItemsRef.current = filteredItems; + }, [filteredItems, setActiveIndex, resetSelectionOnItemsChange]); + + // Handle global Escape key to close the list useKeypress( - (key: Key) => { - // Navigation - if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { - const newIndex = - activeIndex > 0 ? activeIndex - 1 : filteredItems.length - 1; - setActiveIndex(newIndex); - if (newIndex === filteredItems.length - 1) { - setScrollOffset(Math.max(0, filteredItems.length - maxItemsToShow)); - } else if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - return; - } - if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { - const newIndex = - activeIndex < filteredItems.length - 1 ? activeIndex + 1 : 0; - setActiveIndex(newIndex); - if (newIndex === 0) { - setScrollOffset(0); - } else if (newIndex >= scrollOffset + maxItemsToShow) { - setScrollOffset(newIndex - maxItemsToShow + 1); - } - return; - } - - // Selection - if (keyMatchers[Command.RETURN](key)) { - const item = filteredItems[activeIndex]; - if (item) { - onSelect(item); - } - return; - } - - // Close + (key) => { if (keyMatchers[Command.ESCAPE](key)) { - onClose?.(); - return; + onClose(); + return true; } + return false; }, { isActive: true }, ); + const visibleItems = filteredItems.slice( + scrollOffset, + scrollOffset + maxItemsToShow, + ); + + const defaultRenderItem = ( + item: T, + isActive: boolean, + labelWidth: number, + ) => ( + + + + {isActive ? '●' : ''} + + + + + {item.label.padEnd(labelWidth)} + + {item.description && ( + + {item.description} + + )} + + + ); + return ( - - {/* Header */} + {title && ( - {title} + + {title} + )} - {/* Search Input */} {searchBuffer && ( @@ -142,46 +212,46 @@ export function SearchableList({ )} - {/* List */} - - {visibleItems.length === 0 ? ( - No items found. - ) : ( - visibleItems.map((item, idx) => { - const index = scrollOffset + idx; - const isActive = index === activeIndex; + {header && {header}} - return ( - - - {isActive ? '> ' : ' '} - - - - {item.label} - - - {item.description && ( - {item.description} - )} + + {filteredItems.length === 0 ? ( + + No items found. + + ) : ( + <> + {filteredItems.length > maxItemsToShow && ( + + - ); - }) + )} + {visibleItems.map((item, index) => { + const isSelected = activeIndex === scrollOffset + index; + return ( + + {renderItem + ? renderItem(item, isSelected, maxLabelWidth) + : defaultRenderItem(item, isSelected, maxLabelWidth)} + + ); + })} + {filteredItems.length > maxItemsToShow && ( + + + + )} + )} - {/* Footer/Scroll Indicators */} - {(showScrollUp || showScrollDown) && ( - - - {showScrollUp ? '▲ ' : ' '} - {filteredItems.length} items - {showScrollDown ? ' ▼' : ' '} - + {footer && ( + + {footer({ + startIndex: scrollOffset, + endIndex: scrollOffset + visibleItems.length, + totalVisible: filteredItems.length, + })} )} diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap new file mode 100644 index 0000000000..35f21daee3 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap @@ -0,0 +1,67 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SearchableList > should match snapshot 1`] = ` +" Test List + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Search... │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ● Item One + Description for item one + + Item Two + Description for item two + + Item Three + Description for item three +" +`; + +exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 1`] = ` +" Test List + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Search... │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Item One + Description for item one + + ● Item Two + Description for item two + + Item Three + Description for item three +" +`; + +exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 2`] = ` +" Test List + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ One │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ● Item One + Description for item one +" +`; + +exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 3`] = ` +" Test List + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Search... │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ● Item One + Description for item one + + Item Two + Description for item two + + Item Three + Description for item three +" +`; diff --git a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx index abc749b6d3..878cacfed0 100644 --- a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx +++ b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx @@ -451,6 +451,7 @@ Return a JSON object with: '--limit', String(limit), ]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const issues: Issue[] = JSON.parse(stdout); if (issues.length === 0) { setState((s) => ({ diff --git a/packages/cli/src/ui/components/triage/TriageIssues.tsx b/packages/cli/src/ui/components/triage/TriageIssues.tsx index 3a654a40de..595384a124 100644 --- a/packages/cli/src/ui/components/triage/TriageIssues.tsx +++ b/packages/cli/src/ui/components/triage/TriageIssues.tsx @@ -137,6 +137,7 @@ export const TriageIssues = ({ '--limit', String(limit), ]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const issues: Issue[] = JSON.parse(stdout); if (issues.length === 0) { setState((s) => ({ diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx new file mode 100644 index 0000000000..954dff1f07 --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ExtensionRegistryView } from './ExtensionRegistryView.js'; +import { type ExtensionManager } from '../../../config/extension-manager.js'; +import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js'; +import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js'; +import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; +import { type RegistryExtension } from '../../../config/extensionRegistryClient.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { + type SearchListState, + type GenericListItem, +} from '../shared/SearchableList.js'; +import { type TextBuffer } from '../shared/text-buffer.js'; + +// Mocks +vi.mock('../../hooks/useExtensionRegistry.js'); +vi.mock('../../hooks/useExtensionUpdates.js'); +vi.mock('../../hooks/useRegistrySearch.js'); +vi.mock('../../../config/extension-manager.js'); +vi.mock('../../contexts/UIStateContext.js'); +vi.mock('../../contexts/ConfigContext.js'); + +const mockExtensions: RegistryExtension[] = [ + { + id: 'ext1', + extensionName: 'Test Extension 1', + extensionDescription: 'Description 1', + fullName: 'author/ext1', + extensionVersion: '1.0.0', + rank: 1, + stars: 10, + url: 'http://example.com', + repoDescription: 'Repo Desc 1', + avatarUrl: 'http://avatar.com', + lastUpdated: '2023-01-01', + hasMCP: false, + hasContext: false, + hasHooks: false, + hasSkills: false, + hasCustomCommands: false, + isGoogleOwned: false, + licenseKey: 'mit', + }, + { + id: 'ext2', + extensionName: 'Test Extension 2', + extensionDescription: 'Description 2', + fullName: 'author/ext2', + extensionVersion: '2.0.0', + rank: 2, + stars: 20, + url: 'http://example.com/2', + repoDescription: 'Repo Desc 2', + avatarUrl: 'http://avatar.com/2', + lastUpdated: '2023-01-02', + hasMCP: true, + hasContext: true, + hasHooks: true, + hasSkills: true, + hasCustomCommands: true, + isGoogleOwned: true, + licenseKey: 'apache-2.0', + }, +]; + +describe('ExtensionRegistryView', () => { + let mockExtensionManager: ExtensionManager; + let mockOnSelect: ReturnType; + let mockOnClose: ReturnType; + let mockSearch: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockExtensionManager = { + getExtensions: vi.fn().mockReturnValue([]), + } as unknown as ExtensionManager; + + mockOnSelect = vi.fn(); + mockOnClose = vi.fn(); + mockSearch = vi.fn(); + + vi.mocked(useExtensionRegistry).mockReturnValue({ + extensions: mockExtensions, + loading: false, + error: null, + search: mockSearch, + }); + + vi.mocked(useExtensionUpdates).mockReturnValue({ + extensionsUpdateState: new Map(), + } as unknown as ReturnType); + + // Mock useRegistrySearch implementation + vi.mocked(useRegistrySearch).mockImplementation( + (props: { items: GenericListItem[]; onSearch?: (q: string) => void }) => + ({ + filteredItems: props.items, // Pass through items + searchBuffer: { + text: '', + cursorOffset: 0, + viewport: { width: 10, height: 1 }, + visualCursor: [0, 0] as [number, number], + viewportVisualLines: [{ text: '', visualRowIndex: 0 }], + visualScrollRow: 0, + lines: [''], + cursor: [0, 0] as [number, number], + selectionAnchor: undefined, + } as unknown as TextBuffer, + searchQuery: '', + setSearchQuery: vi.fn(), + maxLabelWidth: 10, + }) as unknown as SearchListState, + ); + + vi.mocked(useUIState).mockReturnValue({ + mainAreaWidth: 100, + terminalHeight: 40, + staticExtraHeight: 5, + } as unknown as ReturnType); + + vi.mocked(useConfig).mockReturnValue({ + getEnableExtensionReloading: vi.fn().mockReturnValue(false), + } as unknown as ReturnType); + }); + + const renderView = () => + render( + + + , + ); + + it('should render extensions', async () => { + const { lastFrame } = renderView(); + await waitFor(() => { + expect(lastFrame()).toContain('Test Extension 1'); + expect(lastFrame()).toContain('Test Extension 2'); + }); + }); + + it('should use useRegistrySearch hook', () => { + renderView(); + expect(useRegistrySearch).toHaveBeenCalled(); + }); + + it('should call search function when typing', async () => { + // Mock useRegistrySearch to trigger onSearch + vi.mocked(useRegistrySearch).mockImplementation( + (props: { + items: GenericListItem[]; + onSearch?: (q: string) => void; + }): SearchListState => { + const { onSearch } = props; + // Simulate typing + React.useEffect(() => { + if (onSearch) { + onSearch('test query'); + } + }, [onSearch]); + return { + filteredItems: props.items, + searchBuffer: { + text: 'test query', + cursorOffset: 10, + viewport: { width: 10, height: 1 }, + visualCursor: [0, 10] as [number, number], + viewportVisualLines: [{ text: 'test query', visualRowIndex: 0 }], + visualScrollRow: 0, + lines: ['test query'], + cursor: [0, 10] as [number, number], + selectionAnchor: undefined, + } as unknown as TextBuffer, + searchQuery: 'test query', + setSearchQuery: vi.fn(), + maxLabelWidth: 10, + } as unknown as SearchListState; + }, + ); + + renderView(); + + await waitFor(() => { + expect(useRegistrySearch).toHaveBeenCalledWith( + expect.objectContaining({ + onSearch: mockSearch, + }), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx new file mode 100644 index 0000000000..1f6fba96ea --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useMemo, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import type { RegistryExtension } from '../../../config/extensionRegistryClient.js'; + +import { + SearchableList, + type GenericListItem, +} from '../shared/SearchableList.js'; +import { theme } from '../../semantic-colors.js'; + +import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js'; +import { ExtensionUpdateState } from '../../state/extensions.js'; +import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import type { ExtensionManager } from '../../../config/extension-manager.js'; +import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; + +import { useUIState } from '../../contexts/UIStateContext.js'; + +interface ExtensionRegistryViewProps { + onSelect?: (extension: RegistryExtension) => void; + onClose?: () => void; + extensionManager: ExtensionManager; +} + +interface ExtensionItem extends GenericListItem { + extension: RegistryExtension; +} + +export function ExtensionRegistryView({ + onSelect, + onClose, + extensionManager, +}: ExtensionRegistryViewProps): React.JSX.Element { + const { extensions, loading, error, search } = useExtensionRegistry(); + const config = useConfig(); + const { terminalHeight, staticExtraHeight } = useUIState(); + + const { extensionsUpdateState } = useExtensionUpdates( + extensionManager, + () => 0, + config.getEnableExtensionReloading(), + ); + + const installedExtensions = extensionManager.getExtensions(); + + const items: ExtensionItem[] = useMemo( + () => + extensions.map((ext) => ({ + key: ext.id, + label: ext.extensionName, + description: ext.extensionDescription || ext.repoDescription, + extension: ext, + })), + [extensions], + ); + + const handleSelect = useCallback( + (item: ExtensionItem) => { + onSelect?.(item.extension); + }, + [onSelect], + ); + + const renderItem = useCallback( + (item: ExtensionItem, isActive: boolean, _labelWidth: number) => { + const isInstalled = installedExtensions.some( + (e) => e.name === item.extension.extensionName, + ); + const updateState = extensionsUpdateState.get( + item.extension.extensionName, + ); + const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE; + + return ( + + + + + {isActive ? '● ' : ' '} + + + + + {item.label} + + + + | + + {isInstalled && ( + + [Installed] + + )} + {hasUpdate && ( + + [Update available] + + )} + + + {item.description} + + + + + + + {' '} + {item.extension.stars || 0} + + + + ); + }, + [installedExtensions, extensionsUpdateState], + ); + + const header = useMemo( + () => ( + + + + Browse and search extensions from the registry. + + + + + {installedExtensions.length && + `${installedExtensions.length} installed`} + + + + ), + [installedExtensions.length], + ); + + const footer = useCallback( + ({ + startIndex, + endIndex, + totalVisible, + }: { + startIndex: number; + endIndex: number; + totalVisible: number; + }) => ( + + ({startIndex + 1}-{endIndex}) / {totalVisible} + + ), + [], + ); + + const maxItemsToShow = useMemo(() => { + // SearchableList layout overhead: + // Container paddingY: 0 + // Title (marginBottom 1): 2 + // Search buffer (border 2, marginBottom 1): 4 + // Header (marginBottom 1): 2 + // Footer (marginTop 1): 2 + // List item (marginBottom 1): 2 per item + // Total static height = 2 + 4 + 2 + 2 = 10 + const staticHeight = 10; + const availableTerminalHeight = terminalHeight - staticExtraHeight; + const remainingHeight = Math.max(0, availableTerminalHeight - staticHeight); + const itemHeight = 2; // Each item takes 2 lines (content + marginBottom 1) + + // Ensure we show at least a few items and not more than we have + return Math.max(4, Math.floor(remainingHeight / itemHeight)); + }, [terminalHeight, staticExtraHeight]); + + if (loading) { + return ( + + Loading extensions... + + ); + } + + if (error) { + return ( + + Error loading extensions: + {error} + + ); + } + + return ( + + title="Extensions" + items={items} + onSelect={handleSelect} + onClose={onClose || (() => {})} + searchPlaceholder="Search extension gallery" + renderItem={renderItem} + header={header} + footer={footer} + maxItemsToShow={maxItemsToShow} + useSearch={useRegistrySearch} + onSearch={search} + resetSelectionOnItemsChange={true} + /> + ); +} diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 197711f82f..93a3198ca8 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -33,6 +33,7 @@ export const WARNING_PROMPT_DURATION_MS = 3000; export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000; export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000; +export const EXPAND_HINT_DURATION_MS = 5000; export const DEFAULT_BACKGROUND_OPACITY = 0.16; export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24; diff --git a/packages/cli/src/ui/contexts/AppContext.tsx b/packages/cli/src/ui/contexts/AppContext.tsx index 791c8a73ac..ea5de0c105 100644 --- a/packages/cli/src/ui/contexts/AppContext.tsx +++ b/packages/cli/src/ui/contexts/AppContext.tsx @@ -5,10 +5,11 @@ */ import { createContext, useContext } from 'react'; +import type { StartupWarning } from '@google/gemini-cli-core'; export interface AppState { version: string; - startupWarnings: string[]; + startupWarnings: StartupWarning[]; } export const AppContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 1635fd3c14..e25ff57642 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -758,6 +758,80 @@ describe('KeypressContext', () => { ); }); + describe('Numpad support', () => { + it.each([ + { + sequence: '\x1bOj', + expected: { name: '*', sequence: '*', insertable: true }, + }, + { + sequence: '\x1bOk', + expected: { name: '+', sequence: '+', insertable: true }, + }, + { + sequence: '\x1bOm', + expected: { name: '-', sequence: '-', insertable: true }, + }, + { + sequence: '\x1bOo', + expected: { name: '/', sequence: '/', insertable: true }, + }, + { + sequence: '\x1bOp', + expected: { name: '0', sequence: '0', insertable: true }, + }, + { + sequence: '\x1bOq', + expected: { name: '1', sequence: '1', insertable: true }, + }, + { + sequence: '\x1bOr', + expected: { name: '2', sequence: '2', insertable: true }, + }, + { + sequence: '\x1bOs', + expected: { name: '3', sequence: '3', insertable: true }, + }, + { + sequence: '\x1bOt', + expected: { name: '4', sequence: '4', insertable: true }, + }, + { + sequence: '\x1bOu', + expected: { name: '5', sequence: '5', insertable: true }, + }, + { + sequence: '\x1bOv', + expected: { name: '6', sequence: '6', insertable: true }, + }, + { + sequence: '\x1bOw', + expected: { name: '7', sequence: '7', insertable: true }, + }, + { + sequence: '\x1bOx', + expected: { name: '8', sequence: '8', insertable: true }, + }, + { + sequence: '\x1bOy', + expected: { name: '9', sequence: '9', insertable: true }, + }, + { + sequence: '\x1bOn', + expected: { name: '.', sequence: '.', insertable: true }, + }, + ])( + 'should recognize numpad sequence "$sequence" as $expected.name', + ({ sequence, expected }) => { + const { keyHandler } = setupKeypressTest(); + act(() => stdin.write(sequence)); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining(expected), + ); + }, + ); + }); + describe('Double-tap and batching', () => { it('should emit two delete events for double-tap CSI[3~', async () => { const { keyHandler } = setupKeypressTest(); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 217f5182bb..7d1881644d 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -122,6 +122,25 @@ const KEY_INFO_MAP: Record< '[8^': { name: 'end', ctrl: true }, }; +// Numpad keys in Application Keypad Mode (SS3 sequences) +const NUMPAD_MAP: Record = { + Oj: '*', + Ok: '+', + Om: '-', + Oo: '/', + Op: '0', + Oq: '1', + Or: '2', + Os: '3', + Ot: '4', + Ou: '5', + Ov: '6', + Ow: '7', + Ox: '8', + Oy: '9', + On: '.', +}; + const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16 function charLengthAt(str: string, i: number): number { if (str.length <= i) { @@ -538,18 +557,27 @@ function* emitKeys( insertable = true; } } else { - name = 'undefined'; - if ( - (ctrl || cmd || alt) && - (code.endsWith('u') || code.endsWith('~')) - ) { - // CSI-u or tilde-coded functional keys: ESC [ ; (u|~) - const codeNumber = parseInt(code.slice(1, -1), 10); + const numpadChar = NUMPAD_MAP[code]; + if (numpadChar) { + name = numpadChar; + if (!ctrl && !cmd && !alt) { + sequence = numpadChar; + insertable = true; + } + } else { + name = 'undefined'; if ( - codeNumber >= 'a'.charCodeAt(0) && - codeNumber <= 'z'.charCodeAt(0) + (ctrl || cmd || alt) && + (code.endsWith('u') || code.endsWith('~')) ) { - name = String.fromCharCode(codeNumber); + // CSI-u or tilde-coded functional keys: ESC [ ; (u|~) + const codeNumber = parseInt(code.slice(1, -1), 10); + if ( + codeNumber >= 'a'.charCodeAt(0) && + codeNumber <= 'z'.charCodeAt(0) + ) { + name = String.fromCharCode(codeNumber); + } } } } diff --git a/packages/cli/src/ui/contexts/OverflowContext.tsx b/packages/cli/src/ui/contexts/OverflowContext.tsx index 9d8b78d4c7..cee02090b6 100644 --- a/packages/cli/src/ui/contexts/OverflowContext.tsx +++ b/packages/cli/src/ui/contexts/OverflowContext.tsx @@ -13,13 +13,14 @@ import { useMemo, } from 'react'; -interface OverflowState { +export interface OverflowState { overflowingIds: ReadonlySet; } -interface OverflowActions { +export interface OverflowActions { addOverflowingId: (id: string) => void; removeOverflowingId: (id: string) => void; + reset: () => void; } const OverflowStateContext = createContext( @@ -63,6 +64,10 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({ }); }, []); + const reset = useCallback(() => { + setOverflowingIds(new Set()); + }, []); + const stateValue = useMemo( () => ({ overflowingIds, @@ -74,8 +79,9 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({ () => ({ addOverflowingId, removeOverflowingId, + reset, }), - [addOverflowingId, removeOverflowingId], + [addOverflowingId, removeOverflowingId, reset], ); return ( diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index af8706cfb1..03780c5068 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -52,6 +52,7 @@ export interface UIActions { vimHandleInput: (key: Key) => boolean; handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void; handleFolderTrustSelect: (choice: FolderTrustChoice) => void; + setIsPolicyUpdateDialogOpen: (value: boolean) => void; setConstrainHeight: (value: boolean) => void; onEscapePromptChange: (show: boolean) => void; refreshStatic: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 2df7473b0c..9fb2852361 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -27,6 +27,8 @@ import type { FallbackIntent, ValidationIntent, AgentDefinition, + FolderDiscoveryResults, + PolicyUpdateConfirmationRequest, } from '@google/gemini-cli-core'; import { type TransientMessageType } from '../../utils/events.js'; import type { DOMElement } from 'ink'; @@ -112,6 +114,9 @@ export interface UIState { isResuming: boolean; shouldShowIdePrompt: boolean; isFolderTrustDialogOpen: boolean; + folderDiscoveryResults: FolderDiscoveryResults | null; + isPolicyUpdateDialogOpen: boolean; + policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined; isTrustedFolder: boolean | undefined; constrainHeight: boolean; showErrorDetails: boolean; @@ -177,6 +182,7 @@ export interface UIState { isBackgroundShellListOpen: boolean; adminSettingsChanged: boolean; newAgents: AgentDefinition[] | null; + showIsExpandableHint: boolean; hintMode: boolean; hintBuffer: string; transientMessage: { diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index a4c5317de8..8d860bb6ce 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -166,9 +166,11 @@ async function searchResourceCandidates( const fzf = new AsyncFzf(candidates, { selector: (candidate: ResourceSuggestionCandidate) => candidate.searchKey, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const results = await fzf.find(normalizedPattern, { limit: MAX_SUGGESTIONS_TO_SHOW * 3, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return results.map( (result: { item: ResourceSuggestionCandidate }) => result.item.suggestion, ); @@ -188,9 +190,11 @@ async function searchAgentCandidates( const fzf = new AsyncFzf(candidates, { selector: (s: Suggestion) => s.label, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const results = await fzf.find(normalizedPattern, { limit: MAX_SUGGESTIONS_TO_SHOW, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return results.map((r: { item: Suggestion }) => r.item); } diff --git a/packages/cli/src/ui/hooks/useExtensionRegistry.ts b/packages/cli/src/ui/hooks/useExtensionRegistry.ts new file mode 100644 index 0000000000..cfd85ef229 --- /dev/null +++ b/packages/cli/src/ui/hooks/useExtensionRegistry.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { + ExtensionRegistryClient, + type RegistryExtension, +} from '../../config/extensionRegistryClient.js'; + +export interface UseExtensionRegistryResult { + extensions: RegistryExtension[]; + loading: boolean; + error: string | null; + search: (query: string) => void; +} + +export function useExtensionRegistry( + initialQuery = '', +): UseExtensionRegistryResult { + const [extensions, setExtensions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const client = useMemo(() => new ExtensionRegistryClient(), []); + + // Ref to track the latest query to avoid race conditions + const latestQueryRef = useRef(initialQuery); + + // Ref for debounce timeout + const debounceTimeoutRef = useRef(undefined); + + const searchExtensions = useCallback( + async (query: string) => { + try { + setLoading(true); + const results = await client.searchExtensions(query); + + // Only update if this is still the latest query + if (query === latestQueryRef.current) { + // Check if results are different from current extensions + setExtensions((prev) => { + if ( + prev.length === results.length && + prev.every((ext, i) => ext.id === results[i].id) + ) { + return prev; + } + return results; + }); + setError(null); + setLoading(false); + } + } catch (err) { + if (query === latestQueryRef.current) { + setError(err instanceof Error ? err.message : String(err)); + setExtensions([]); + setLoading(false); + } + } + }, + [client], + ); + + const search = useCallback( + (query: string) => { + latestQueryRef.current = query; + + // Clear existing timeout + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + // Debounce + debounceTimeoutRef.current = setTimeout(() => { + void searchExtensions(query); + }, 300); + }, + [searchExtensions], + ); + + // Initial load + useEffect(() => { + void searchExtensions(initialQuery); + + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + }; + }, [initialQuery, searchExtensions]); + + return { + extensions, + loading, + error, + search, + }; +} diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index 742ad61fed..277180404c 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -36,6 +36,9 @@ vi.mock('@google/gemini-cli-core', async () => { return { ...actual, isHeadlessMode: vi.fn().mockReturnValue(false), + FolderTrustDiscoveryService: { + discover: vi.fn(() => new Promise(() => {})), + }, }; }); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index 3711cb8d05..e2a5373e34 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -14,7 +14,13 @@ import { } from '../../config/trustedFolders.js'; import * as process from 'node:process'; import { type HistoryItemWithoutId, MessageType } from '../types.js'; -import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core'; +import { + coreEvents, + ExitCodes, + isHeadlessMode, + FolderTrustDiscoveryService, + type FolderDiscoveryResults, +} from '@google/gemini-cli-core'; import { runExitCleanup } from '../../utils/cleanup.js'; export const useFolderTrust = ( @@ -24,6 +30,8 @@ export const useFolderTrust = ( ) => { const [isTrusted, setIsTrusted] = useState(undefined); const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(false); + const [discoveryResults, setDiscoveryResults] = + useState(null); const [isRestarting, setIsRestarting] = useState(false); const startupMessageSent = useRef(false); @@ -33,6 +41,19 @@ export const useFolderTrust = ( let isMounted = true; const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged); + if (trusted === undefined || trusted === false) { + void FolderTrustDiscoveryService.discover(process.cwd()) + .then((results) => { + if (isMounted) { + setDiscoveryResults(results); + } + }) + .catch(() => { + // Silently ignore discovery errors as they are handled within the service + // and reported via results.discoveryErrors if successful. + }); + } + const showUntrustedMessage = () => { if (trusted === false && !startupMessageSent.current) { addItem( @@ -100,8 +121,6 @@ export const useFolderTrust = ( onTrustChange(currentIsTrusted); setIsTrusted(currentIsTrusted); - // logic: we restart if the trust state *effectively* changes from the previous state. - // previous state was `isTrusted`. If undefined, we assume false (untrusted). const wasTrusted = isTrusted ?? false; if (wasTrusted !== currentIsTrusted) { @@ -117,6 +136,7 @@ export const useFolderTrust = ( return { isTrusted, isFolderTrustDialogOpen, + discoveryResults, handleFolderTrustSelect, isRestarting, }; diff --git a/packages/cli/src/ui/hooks/useFuzzyList.ts b/packages/cli/src/ui/hooks/useFuzzyList.ts deleted file mode 100644 index 6d07b0ea75..0000000000 --- a/packages/cli/src/ui/hooks/useFuzzyList.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useMemo, useEffect } from 'react'; -import { AsyncFzf } from 'fzf'; -import { useUIState } from '../contexts/UIStateContext.js'; -import { - useTextBuffer, - type TextBuffer, -} from '../components/shared/text-buffer.js'; -import { getCachedStringWidth } from '../utils/textUtils.js'; - -interface FzfResult { - item: string; - start: number; - end: number; - score: number; - positions?: number[]; -} - -export interface GenericListItem { - key: string; - label: string; - description?: string; - scopeMessage?: string; -} - -export interface UseFuzzyListProps { - items: T[]; - initialQuery?: string; - onSearch?: (query: string) => void; -} - -export interface UseFuzzyListResult { - filteredItems: T[]; - searchBuffer: TextBuffer | undefined; - searchQuery: string; - setSearchQuery: (query: string) => void; - maxLabelWidth: number; -} - -export function useFuzzyList({ - items, - initialQuery = '', - onSearch, -}: UseFuzzyListProps): UseFuzzyListResult { - // Search state - const [searchQuery, setSearchQuery] = useState(initialQuery); - const [filteredKeys, setFilteredKeys] = useState(() => - items.map((i) => i.key), - ); - - // FZF instance for fuzzy searching - const { fzfInstance, searchMap } = useMemo(() => { - const map = new Map(); - const searchItems: string[] = []; - - items.forEach((item) => { - searchItems.push(item.label); - map.set(item.label.toLowerCase(), item.key); - }); - - const fzf = new AsyncFzf(searchItems, { - fuzzy: 'v2', - casing: 'case-insensitive', - }); - return { fzfInstance: fzf, searchMap: map }; - }, [items]); - - // Perform search - useEffect(() => { - let active = true; - if (!searchQuery.trim() || !fzfInstance) { - setFilteredKeys(items.map((i) => i.key)); - return; - } - - const doSearch = async () => { - const results = await fzfInstance.find(searchQuery); - - if (!active) return; - - const matchedKeys = new Set(); - results.forEach((res: FzfResult) => { - const key = searchMap.get(res.item.toLowerCase()); - if (key) matchedKeys.add(key); - }); - setFilteredKeys(Array.from(matchedKeys)); - onSearch?.(searchQuery); - }; - - void doSearch().catch((error) => { - // eslint-disable-next-line no-console - console.error('Search failed:', error); - setFilteredKeys(items.map((i) => i.key)); // Reset to all items on error - }); - - return () => { - active = false; - }; - }, [searchQuery, fzfInstance, searchMap, items, onSearch]); - - // Get mainAreaWidth for search buffer viewport from UIState - const { mainAreaWidth } = useUIState(); - const viewportWidth = Math.max(20, mainAreaWidth - 8); - - // Search input buffer - const searchBuffer = useTextBuffer({ - initialText: searchQuery, - initialCursorOffset: searchQuery.length, - viewport: { - width: viewportWidth, - height: 1, - }, - singleLine: true, - onChange: (text) => setSearchQuery(text), - }); - - // Filtered items to display - const filteredItems = useMemo(() => { - if (!searchQuery) return items; - return items.filter((item) => filteredKeys.includes(item.key)); - }, [items, filteredKeys, searchQuery]); - - // Calculate max label width for alignment - const maxLabelWidth = useMemo(() => { - let max = 0; - // We use all items for consistent alignment even when filtered - items.forEach((item) => { - const labelFull = - item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : ''); - const lWidth = getCachedStringWidth(labelFull); - const dWidth = item.description - ? getCachedStringWidth(item.description) - : 0; - max = Math.max(max, lWidth, dWidth); - }); - return max; - }, [items]); - - return { - filteredItems, - searchBuffer, - searchQuery, - setSearchQuery, - maxLabelWidth, - }; -} diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 9f644c1c61..a1a64463fd 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -970,6 +970,10 @@ export const useGeminiStream = ( 'Response stopped due to prohibited image content.', [FinishReason.NO_IMAGE]: 'Response stopped because no image was generated.', + [FinishReason.IMAGE_RECITATION]: + 'Response stopped due to image recitation policy.', + [FinishReason.IMAGE_OTHER]: + 'Response stopped due to other image-related reasons.', }; const message = finishReasonMessages[finishReason]; diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts index d0f81bea60..5d6db5abfa 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts @@ -155,9 +155,10 @@ describe('useQuotaAndFallback', () => { expect(request?.isTerminalQuotaError).toBe(true); const message = request!.message; - expect(message).toContain('Usage limit reached for gemini-pro.'); + expect(message).toContain('Usage limit reached for all Pro models.'); expect(message).toContain('Access resets at'); // From getResetTimeMessage expect(message).toContain('/stats model for usage details'); + expect(message).toContain('/model to switch models.'); expect(message).toContain('/auth to switch to API key.'); expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); @@ -176,6 +177,77 @@ describe('useQuotaAndFallback', () => { expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1); }); + it('should show the model name for a terminal quota error on a non-pro model', async () => { + const { result } = renderHook(() => + useQuotaAndFallback({ + config: mockConfig, + historyManager: mockHistoryManager, + userTier: UserTierId.FREE, + setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, + onShowAuthSelection: mockOnShowAuthSelection, + }), + ); + + const handler = setFallbackHandlerSpy.mock + .calls[0][0] as FallbackModelHandler; + + let promise: Promise; + const error = new TerminalQuotaError( + 'flash quota', + mockGoogleApiError, + 1000 * 60 * 5, + ); + act(() => { + promise = handler('gemini-flash', 'gemini-pro', error); + }); + + const request = result.current.proQuotaRequest; + expect(request).not.toBeNull(); + expect(request?.failedModel).toBe('gemini-flash'); + + const message = request!.message; + expect(message).toContain('Usage limit reached for gemini-flash.'); + expect(message).not.toContain('all Pro models'); + + act(() => { + result.current.handleProQuotaChoice('retry_later'); + }); + + await promise!; + }); + + it('should handle terminal quota error without retry delay', async () => { + const { result } = renderHook(() => + useQuotaAndFallback({ + config: mockConfig, + historyManager: mockHistoryManager, + userTier: UserTierId.FREE, + setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, + onShowAuthSelection: mockOnShowAuthSelection, + }), + ); + + const handler = setFallbackHandlerSpy.mock + .calls[0][0] as FallbackModelHandler; + + let promise: Promise; + const error = new TerminalQuotaError('no delay', mockGoogleApiError); + act(() => { + promise = handler('gemini-pro', 'gemini-flash', error); + }); + + const request = result.current.proQuotaRequest; + const message = request!.message; + expect(message).not.toContain('Access resets at'); + expect(message).toContain('Usage limit reached for all Pro models.'); + + act(() => { + result.current.handleProQuotaChoice('retry_later'); + }); + + await promise!; + }); + it('should handle race conditions by stopping subsequent requests', async () => { const { result } = renderHook(() => useQuotaAndFallback({ diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts index 60c91f3143..1ba03f2a47 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts @@ -14,9 +14,9 @@ import { TerminalQuotaError, ModelNotFoundError, type UserTierId, - PREVIEW_GEMINI_MODEL, - DEFAULT_GEMINI_MODEL, VALID_GEMINI_MODELS, + isProModel, + getDisplayString, } from '@google/gemini-cli-core'; import { useCallback, useEffect, useRef, useState } from 'react'; import { type UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -67,11 +67,9 @@ export function useQuotaAndFallback({ let message: string; let isTerminalQuotaError = false; let isModelNotFoundError = false; - const usageLimitReachedModel = - failedModel === DEFAULT_GEMINI_MODEL || - failedModel === PREVIEW_GEMINI_MODEL - ? 'all Pro models' - : failedModel; + const usageLimitReachedModel = isProModel(failedModel) + ? 'all Pro models' + : failedModel; if (error instanceof TerminalQuotaError) { isTerminalQuotaError = true; // Common part of the message for both tiers @@ -87,7 +85,7 @@ export function useQuotaAndFallback({ isModelNotFoundError = true; if (VALID_GEMINI_MODELS.has(failedModel)) { const messageLines = [ - `It seems like you don't have access to ${failedModel}.`, + `It seems like you don't have access to ${getDisplayString(failedModel)}.`, `Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`, ]; message = messageLines.join('\n'); diff --git a/packages/cli/src/ui/hooks/useRegistrySearch.ts b/packages/cli/src/ui/hooks/useRegistrySearch.ts new file mode 100644 index 0000000000..3a93c61a0a --- /dev/null +++ b/packages/cli/src/ui/hooks/useRegistrySearch.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useRef } from 'react'; +import type { TextBuffer } from '../components/shared/text-buffer.js'; +import type { GenericListItem } from '../components/shared/SearchableList.js'; +import { useSearchBuffer } from './useSearchBuffer.js'; + +export interface UseRegistrySearchResult { + filteredItems: T[]; + searchBuffer: TextBuffer | undefined; + searchQuery: string; + setSearchQuery: (query: string) => void; + maxLabelWidth: number; +} + +export function useRegistrySearch(props: { + items: T[]; + initialQuery?: string; + onSearch?: (query: string) => void; +}): UseRegistrySearchResult { + const { items, initialQuery = '', onSearch } = props; + + const [searchQuery, setSearchQuery] = useState(initialQuery); + const isFirstRender = useRef(true); + const onSearchRef = useRef(onSearch); + + onSearchRef.current = onSearch; + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + onSearchRef.current?.(searchQuery); + }, [searchQuery]); + + const searchBuffer = useSearchBuffer({ + initialText: searchQuery, + onChange: setSearchQuery, + }); + + const maxLabelWidth = 0; + + const filteredItems = items; + + return { + filteredItems, + searchBuffer, + searchQuery, + setSearchQuery, + maxLabelWidth, + }; +} diff --git a/packages/cli/src/ui/hooks/useSearchBuffer.ts b/packages/cli/src/ui/hooks/useSearchBuffer.ts new file mode 100644 index 0000000000..d1c8f9f8b8 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSearchBuffer.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useTextBuffer, + type TextBuffer, +} from '../components/shared/text-buffer.js'; +import { useUIState } from '../contexts/UIStateContext.js'; + +const MIN_VIEWPORT_WIDTH = 20; +const VIEWPORT_WIDTH_OFFSET = 8; + +export interface UseSearchBufferProps { + initialText?: string; + onChange: (text: string) => void; +} + +export function useSearchBuffer({ + initialText = '', + onChange, +}: UseSearchBufferProps): TextBuffer { + const { mainAreaWidth } = useUIState(); + const viewportWidth = Math.max( + MIN_VIEWPORT_WIDTH, + mainAreaWidth - VIEWPORT_WIDTH_OFFSET, + ); + + return useTextBuffer({ + initialText, + initialCursorOffset: initialText.length, + viewport: { + width: viewportWidth, + height: 1, + }, + singleLine: true, + onChange, + }); +} diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index c2a78e0c64..9c6d05b322 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -60,6 +60,7 @@ 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'), ); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 7809d6cf0f..a53a469571 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -271,6 +271,7 @@ function useCommandSuggestions( const fzfInstance = getFzfForCommands(commandsToSearch); if (fzfInstance) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const fzfResults = await fzfInstance.fzf.find(partial); if (signal.aborted) return; const uniqueCommands = new Set(); diff --git a/packages/cli/src/ui/hooks/useStateAndRef.ts b/packages/cli/src/ui/hooks/useStateAndRef.ts index d8dce68ec8..5d2a81a92c 100644 --- a/packages/cli/src/ui/hooks/useStateAndRef.ts +++ b/packages/cli/src/ui/hooks/useStateAndRef.ts @@ -22,6 +22,7 @@ export const useStateAndRef = < (newStateOrCallback) => { let newValue: T; if (typeof newStateOrCallback === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment newValue = newStateOrCallback(ref.current); } else { newValue = newStateOrCallback; diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx index 1034e7372e..56e34eefa4 100644 --- a/packages/cli/src/ui/utils/CodeColorizer.tsx +++ b/packages/cli/src/ui/utils/CodeColorizer.tsx @@ -22,7 +22,6 @@ import { } from '../components/shared/MaxSizedBox.js'; import type { LoadedSettings } from '../../config/settings.js'; import { debugLogger } from '@google/gemini-cli-core'; -import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js'; // Configure theming and parsing utilities. const lowlight = createLowlight(common); @@ -152,7 +151,6 @@ export function colorizeCode({ ? false : settings.merged.ui.showLineNumbers; - const useMaxSizedBox = !isAlternateBufferEnabled(settings); try { // Render the HAST tree using the adapted theme // Apply the theme's default foreground color to the top-level Text element @@ -162,7 +160,7 @@ export function colorizeCode({ let hiddenLinesCount = 0; // Optimization to avoid highlighting lines that cannot possibly be displayed. - if (availableHeight !== undefined && useMaxSizedBox) { + if (availableHeight !== undefined) { availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT); if (lines.length > availableHeight) { const sliceIndex = lines.length - availableHeight; @@ -200,7 +198,7 @@ export function colorizeCode({ ); }); - if (useMaxSizedBox) { + if (availableHeight !== undefined) { return ( )); - if (useMaxSizedBox) { + if (availableHeight !== undefined) { return ( = ({ - text, + text: rawText, defaultColor, }) => { + const text = stripUnsafeCharacters(rawText); const baseColor = defaultColor ?? theme.text.primary; // Early return for plain text without markdown or URLs if (!/[*_~`<[https?:]/.test(text)) { diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index fd19b51000..ab1981762c 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -17,6 +17,7 @@ import { } from 'ink'; import { theme } from '../semantic-colors.js'; import { RenderInline } from './InlineMarkdownRenderer.js'; +import { stripUnsafeCharacters } from './textUtils.js'; interface TableRendererProps { headers: string[]; @@ -60,12 +61,18 @@ export const TableRenderer: React.FC = ({ ); const styledHeaders = useMemo( - () => cleanedHeaders.map((header) => toStyledCharacters(header)), + () => + cleanedHeaders.map((header) => + toStyledCharacters(stripUnsafeCharacters(header)), + ), [cleanedHeaders], ); const styledRows = useMemo( - () => rows.map((row) => row.map((cell) => toStyledCharacters(cell))), + () => + rows.map((row) => + row.map((cell) => toStyledCharacters(stripUnsafeCharacters(cell))), + ), [rows], ); @@ -186,7 +193,11 @@ export const TableRenderer: React.FC = ({ const wrappedRows = styledRows.map((row) => wrapAndProcessRow(row)); // Use the TIGHTEST widths that fit the wrapped content + padding - const adjustedWidths = actualColumnWidths.map((w) => w + COLUMN_PADDING); + const adjustedWidths = actualColumnWidths.map( + (w) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + w + COLUMN_PADDING, + ); return { wrappedHeaders, wrappedRows, adjustedWidths }; }, [styledHeaders, styledRows, terminalWidth]); @@ -236,6 +247,7 @@ export const TableRenderer: React.FC = ({ isHeader = false, ): React.ReactNode => { const renderedCells = cells.map((cell, index) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const width = adjustedWidths[index] || 0; return renderCell(cell, width, isHeader); }); diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts index 820497cc2f..5132df4996 100644 --- a/packages/cli/src/ui/utils/terminalSetup.ts +++ b/packages/cli/src/ui/utils/terminalSetup.ts @@ -179,6 +179,7 @@ async function configureVSCodeStyle( await backupFile(keybindingsFile); try { const cleanContent = stripJsonComments(content); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedContent = JSON.parse(cleanContent); if (!Array.isArray(parsedContent)) { return { diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index be7f69d9f6..4927486d43 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -332,6 +332,35 @@ describe('textUtils', () => { }); }); + describe('BiDi and deceptive Unicode characters', () => { + it('should strip BiDi override characters', () => { + const input = 'safe\u202Etxt.sh'; + // When stripped, it should be 'safetxt.sh' + expect(stripUnsafeCharacters(input)).toBe('safetxt.sh'); + }); + + it('should strip all BiDi control characters (LRM, RLM, U+202A-U+202E, U+2066-U+2069)', () => { + const bidiChars = + '\u200E\u200F\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069'; + expect(stripUnsafeCharacters('a' + bidiChars + 'b')).toBe('ab'); + }); + + it('should strip zero-width characters (U+200B, U+FEFF)', () => { + const zeroWidthChars = '\u200B\uFEFF'; + expect(stripUnsafeCharacters('a' + zeroWidthChars + 'b')).toBe('ab'); + }); + + it('should preserve ZWJ (U+200D) for complex emojis', () => { + const input = 'Family: 👨‍👩‍👧‍👦'; + expect(stripUnsafeCharacters(input)).toBe('Family: 👨‍👩‍👧‍👦'); + }); + + it('should preserve ZWNJ (U+200C)', () => { + const input = 'hello\u200Cworld'; + expect(stripUnsafeCharacters(input)).toBe('hello\u200Cworld'); + }); + }); + describe('performance: regex vs array-based', () => { it('should handle real-world terminal output with control chars', () => { // Simulate terminal output with various control sequences diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index d2ad40c148..a039a43991 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -106,9 +106,13 @@ export function cpSlice(str: string, start: number, end?: number): string { * - VT control sequences (via Node.js util.stripVTControlCharacters) * - C0 control chars (0x00-0x1F) except TAB(0x09), LF(0x0A), CR(0x0D) * - C1 control chars (0x80-0x9F) that can cause display issues + * - BiDi control chars (U+200E, U+200F, U+202A-U+202E, U+2066-U+2069) + * - Zero-width chars (U+200B, U+FEFF) * * Characters preserved: * - All printable Unicode including emojis + * - ZWJ (U+200D) - needed for complex emoji sequences + * - ZWNJ (U+200C) - preserve zero-width non-joiner * - DEL (0x7F) - handled functionally by applyOperations, not a display issue * - CR/LF (0x0D/0x0A) - needed for line breaks * - TAB (0x09) - preserve tabs @@ -120,8 +124,13 @@ export function stripUnsafeCharacters(str: string): string { // Use a regex to strip remaining unsafe control characters // C0: 0x00-0x1F except 0x09 (TAB), 0x0A (LF), 0x0D (CR) // C1: 0x80-0x9F - // eslint-disable-next-line no-control-regex - return strippedVT.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F]/g, ''); + // BiDi: U+200E (LRM), U+200F (RLM), U+202A-U+202E, U+2066-U+2069 + // Zero-width: U+200B (ZWSP), U+FEFF (BOM) + return strippedVT.replace( + // eslint-disable-next-line no-control-regex + /[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F\u200E\u200F\u202A-\u202E\u2066-\u2069\u200B\uFEFF]/g, + '', + ); } /** @@ -224,7 +233,9 @@ export function escapeAnsiCtrlCodes(obj: T): T { let newArr: unknown[] | null = null; for (let i = 0; i < obj.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const value = obj[i]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const escapedValue = escapeAnsiCtrlCodes(value); if (escapedValue !== value) { if (newArr === null) { diff --git a/packages/cli/src/ui/utils/toolLayoutUtils.ts b/packages/cli/src/ui/utils/toolLayoutUtils.ts new file mode 100644 index 0000000000..8f619901f6 --- /dev/null +++ b/packages/cli/src/ui/utils/toolLayoutUtils.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ACTIVE_SHELL_MAX_LINES, + COMPLETED_SHELL_MAX_LINES, +} from '../constants.js'; +import { CoreToolCallStatus } from '@google/gemini-cli-core'; + +/** + * Constants used for calculating available height for tool results. + * These MUST be kept in sync between ToolGroupMessage (for overflow detection) + * and ToolResultDisplay (for actual truncation). + */ +export const TOOL_RESULT_STATIC_HEIGHT = 1; +export const TOOL_RESULT_ASB_RESERVED_LINE_COUNT = 6; +export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 2; +export const TOOL_RESULT_MIN_LINES_SHOWN = 2; + +/** + * Calculates the final height available for the content of a tool result display. + * + * This accounts for: + * 1. The static height of the tool message (name, status line). + * 2. Reserved space for hints and padding (different in ASB vs Standard mode). + * 3. Enforcing a minimum number of lines shown. + */ +export function calculateToolContentMaxLines(options: { + availableTerminalHeight: number | undefined; + isAlternateBuffer: boolean; + maxLinesLimit?: number; +}): number | undefined { + const { availableTerminalHeight, isAlternateBuffer, maxLinesLimit } = options; + + const reservedLines = isAlternateBuffer + ? TOOL_RESULT_ASB_RESERVED_LINE_COUNT + : TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT; + + let contentHeight = availableTerminalHeight + ? Math.max( + availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines, + TOOL_RESULT_MIN_LINES_SHOWN + 1, + ) + : undefined; + + if (maxLinesLimit) { + contentHeight = + contentHeight !== undefined + ? Math.min(contentHeight, maxLinesLimit) + : maxLinesLimit; + } + + return contentHeight; +} + +/** + * Calculates the maximum number of lines to display for shell output. + * + * This logic distinguishes between: + * 1. Process Status: Active (Executing) vs Completed. + * 2. UI Focus: Whether the user is currently interacting with the shell. + * 3. Expansion State: Whether the user has explicitly requested to "Show More Lines" (CTRL+O). + */ +export function calculateShellMaxLines(options: { + status: CoreToolCallStatus; + isAlternateBuffer: boolean; + isThisShellFocused: boolean; + availableTerminalHeight: number | undefined; + constrainHeight: boolean; + isExpandable: boolean | undefined; +}): number | undefined { + const { + status, + isAlternateBuffer, + isThisShellFocused, + availableTerminalHeight, + constrainHeight, + isExpandable, + } = options; + + // 1. If the user explicitly requested expansion (unconstrained), remove all caps. + if (!constrainHeight && isExpandable) { + return undefined; + } + + // 2. Handle cases where height is unknown (Standard mode history). + if (availableTerminalHeight === undefined) { + return isAlternateBuffer ? ACTIVE_SHELL_MAX_LINES : undefined; + } + + const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2); + + // 3. Handle ASB mode focus expansion. + // We allow a focused shell in ASB mode to take up the full available height, + // BUT only if we aren't trying to maintain a constrained view (e.g., history items). + if (isAlternateBuffer && isThisShellFocused && !constrainHeight) { + return maxLinesBasedOnHeight; + } + + // 4. Fall back to process-based constants. + const isExecuting = status === CoreToolCallStatus.Executing; + const shellMaxLinesLimit = isExecuting + ? ACTIVE_SHELL_MAX_LINES + : COMPLETED_SHELL_MAX_LINES; + + return Math.min(maxLinesBasedOnHeight, shellMaxLinesLimit); +} diff --git a/packages/cli/src/ui/utils/urlSecurityUtils.test.ts b/packages/cli/src/ui/utils/urlSecurityUtils.test.ts new file mode 100644 index 0000000000..3bec00a534 --- /dev/null +++ b/packages/cli/src/ui/utils/urlSecurityUtils.test.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { getDeceptiveUrlDetails, toUnicodeUrl } from './urlSecurityUtils.js'; + +describe('urlSecurityUtils', () => { + describe('toUnicodeUrl', () => { + it('should convert a Punycode URL string to its Unicode version', () => { + expect(toUnicodeUrl('https://xn--tst-qla.com/')).toBe( + 'https://täst.com/', + ); + }); + + it('should convert a URL object to its Unicode version', () => { + const urlObj = new URL('https://xn--tst-qla.com/path'); + expect(toUnicodeUrl(urlObj)).toBe('https://täst.com/path'); + }); + + it('should handle complex URLs with credentials and ports', () => { + const complexUrl = 'https://user:pass@xn--tst-qla.com:8080/path?q=1#hash'; + expect(toUnicodeUrl(complexUrl)).toBe( + 'https://user:pass@täst.com:8080/path?q=1#hash', + ); + }); + + it('should correctly reconstruct the URL even if the hostname appears in the path', () => { + const urlWithHostnameInPath = + 'https://xn--tst-qla.com/some/path/xn--tst-qla.com/index.html'; + expect(toUnicodeUrl(urlWithHostnameInPath)).toBe( + 'https://täst.com/some/path/xn--tst-qla.com/index.html', + ); + }); + + it('should return the original string if URL parsing fails', () => { + expect(toUnicodeUrl('not a url')).toBe('not a url'); + }); + + it('should return the original string for already safe URLs', () => { + expect(toUnicodeUrl('https://google.com/')).toBe('https://google.com/'); + }); + }); + + describe('getDeceptiveUrlDetails', () => { + it('should return full details for a deceptive URL', () => { + const details = getDeceptiveUrlDetails('https://еxample.com'); + expect(details).not.toBeNull(); + expect(details?.originalUrl).toBe('https://еxample.com/'); + expect(details?.punycodeUrl).toBe('https://xn--xample-2of.com/'); + }); + + it('should return null for safe URLs', () => { + expect(getDeceptiveUrlDetails('https://google.com')).toBeNull(); + }); + + it('should handle already Punycoded hostnames', () => { + const details = getDeceptiveUrlDetails('https://xn--tst-qla.com'); + expect(details).not.toBeNull(); + expect(details?.originalUrl).toBe('https://täst.com/'); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/urlSecurityUtils.ts b/packages/cli/src/ui/utils/urlSecurityUtils.ts new file mode 100644 index 0000000000..c3a5ca20a2 --- /dev/null +++ b/packages/cli/src/ui/utils/urlSecurityUtils.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import url from 'node:url'; + +/** + * Details about a deceptive URL. + */ +export interface DeceptiveUrlDetails { + /** The Unicode version of the visually deceptive URL. */ + originalUrl: string; + /** The ASCII-safe Punycode version of the URL. */ + punycodeUrl: string; +} + +/** + * Whether a hostname contains non-ASCII or Punycode markers. + * + * @param hostname The hostname to check. + * @returns true if deceptive markers are found, false otherwise. + */ +function containsDeceptiveMarkers(hostname: string): boolean { + return ( + // eslint-disable-next-line no-control-regex + hostname.toLowerCase().includes('xn--') || /[^\x00-\x7F]/.test(hostname) + ); +} + +/** + * Converts a URL (string or object) to its visually deceptive Unicode version. + * + * This function manually reconstructs the URL to bypass the automatic Punycode + * conversion performed by the WHATWG URL class when setting the hostname. + * + * @param urlInput The URL string or URL object to convert. + * @returns The reconstructed URL string with the hostname in Unicode. + */ +export function toUnicodeUrl(urlInput: string | URL): string { + try { + const urlObj = typeof urlInput === 'string' ? new URL(urlInput) : urlInput; + const punycodeHost = urlObj.hostname; + const unicodeHost = url.domainToUnicode(punycodeHost); + + // Reconstruct the URL manually because the WHATWG URL class automatically + // Punycodes the hostname if we try to set it. + const protocol = urlObj.protocol + '//'; + const credentials = urlObj.username + ? `${urlObj.username}${urlObj.password ? ':' + urlObj.password : ''}@` + : ''; + const port = urlObj.port ? ':' + urlObj.port : ''; + + return `${protocol}${credentials}${unicodeHost}${port}${urlObj.pathname}${urlObj.search}${urlObj.hash}`; + } catch { + return typeof urlInput === 'string' ? urlInput : urlInput.href; + } +} + +/** + * Extracts deceptive URL details if a URL hostname contains non-ASCII characters + * or is already in Punycode. + * + * @param urlString The URL string to check. + * @returns DeceptiveUrlDetails if a potential deceptive URL is detected, otherwise null. + */ +export function getDeceptiveUrlDetails( + urlString: string, +): DeceptiveUrlDetails | null { + try { + if (!urlString.includes('://')) { + return null; + } + + const urlObj = new URL(urlString); + + if (!containsDeceptiveMarkers(urlObj.hostname)) { + return null; + } + + return { + originalUrl: toUnicodeUrl(urlObj), + punycodeUrl: urlObj.href, + }; + } catch { + // If URL parsing fails, it's not a valid URL we can safely analyze. + return null; + } +} diff --git a/packages/cli/src/utils/activityLogger.ts b/packages/cli/src/utils/activityLogger.ts index cf852257a9..9f1d268a91 100644 --- a/packages/cli/src/utils/activityLogger.ts +++ b/packages/cli/src/utils/activityLogger.ts @@ -472,7 +472,7 @@ export class ActivityLogger extends EventEmitter { body, pending: true, }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return return (oldEnd as any).apply(this, [chunk, ...etc]); }; diff --git a/packages/cli/src/utils/envVarResolver.ts b/packages/cli/src/utils/envVarResolver.ts index fac43682a5..6e01f67ac7 100644 --- a/packages/cli/src/utils/envVarResolver.ts +++ b/packages/cli/src/utils/envVarResolver.ts @@ -23,6 +23,7 @@ export function resolveEnvVarsInString( ): string { const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} return value.replace(envVarRegex, (match, varName1, varName2) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const varName = varName1 || varName2; if (customEnv && typeof customEnv[varName] === 'string') { return customEnv[varName]; @@ -97,6 +98,7 @@ function resolveEnvVarsInObjectInternal( visited.add(obj); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = obj.map((item) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return resolveEnvVarsInObjectInternal(item, visited, customEnv), ) as unknown as T; visited.delete(obj); diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index b415dadc6c..e27673f0fe 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -78,10 +78,12 @@ export const getLatestGitHubRelease = async ( ); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const releaseTag = (await response.json()).tag_name; if (!releaseTag) { throw new Error(`Response did not include tag_name field`); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return releaseTag; } catch (_error) { debugLogger.debug( diff --git a/packages/cli/src/utils/jsonoutput.ts b/packages/cli/src/utils/jsonoutput.ts index ae170ec591..7f60c34104 100644 --- a/packages/cli/src/utils/jsonoutput.ts +++ b/packages/cli/src/utils/jsonoutput.ts @@ -29,6 +29,7 @@ export function tryParseJSON(input: string): object | null { if (!checkInput(input)) return null; const trimmed = input.trim(); try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(trimmed); if (parsed === null || typeof parsed !== 'object') { return null; @@ -39,6 +40,7 @@ export function tryParseJSON(input: string): object | null { if (!Array.isArray(parsed) && Object.keys(parsed).length === 0) return null; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return parsed; } catch (_err) { return null; diff --git a/packages/cli/src/utils/persistentState.ts b/packages/cli/src/utils/persistentState.ts index cbdf1fc6cb..6ad5695097 100644 --- a/packages/cli/src/utils/persistentState.ts +++ b/packages/cli/src/utils/persistentState.ts @@ -15,6 +15,7 @@ interface PersistentStateData { tipsShown?: number; hasSeenScreenReaderNudge?: boolean; focusUiEnabled?: boolean; + startupWarningCounts?: Record; // Add other persistent state keys here as needed } @@ -37,6 +38,7 @@ export class PersistentState { const filePath = this.getPath(); if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this.cache = JSON.parse(content); } else { this.cache = {}; diff --git a/packages/cli/src/utils/readStdin.ts b/packages/cli/src/utils/readStdin.ts index 30834e0d14..3b5b17fe04 100644 --- a/packages/cli/src/utils/readStdin.ts +++ b/packages/cli/src/utils/readStdin.ts @@ -23,6 +23,7 @@ export async function readStdin(): Promise { const onReadable = () => { let chunk; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment while ((chunk = process.stdin.read()) !== null) { if (pipedInputTimerId) { clearTimeout(pipedInputTimerId); diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts index 29fc5bdff9..8491f748bd 100644 --- a/packages/cli/src/utils/sessionUtils.test.ts +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -445,9 +445,76 @@ describe('SessionSelector', () => { const sessionSelector = new SessionSelector(config); const sessions = await sessionSelector.listSessions(); + // Should list the session with gemini message expect(sessions.length).toBe(1); expect(sessions[0].id).toBe(sessionIdGeminiOnly); }); + + it('should not list sessions marked as subagent', async () => { + const mainSessionId = randomUUID(); + const subagentSessionId = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Main session - should be listed + const mainSession = { + sessionId: mainSessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Hello world', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + kind: 'main', + }; + + // Subagent session - should NOT be listed + const subagentSession = { + sessionId: subagentSessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T11:00:00.000Z', + lastUpdated: '2024-01-01T11:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Internal subagent task', + id: 'msg1', + timestamp: '2024-01-01T11:00:00.000Z', + }, + ], + kind: 'subagent', + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${mainSessionId.slice(0, 8)}.json`, + ), + JSON.stringify(mainSession, null, 2), + ); + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${subagentSessionId.slice(0, 8)}.json`, + ), + JSON.stringify(subagentSession, null, 2), + ); + + const sessionSelector = new SessionSelector(config); + const sessions = await sessionSelector.listSessions(); + + // Should only list the main session + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(mainSessionId); + }); }); describe('extractFirstUserMessage', () => { diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 18a53b048b..7bf05fe94a 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -253,6 +253,7 @@ export const getAllSessionFiles = async ( 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'), ); @@ -274,6 +275,12 @@ export const getAllSessionFiles = async ( return { fileName: file, sessionInfo: null }; } + // Skip subagent sessions - these are implementation details of a tool call + // and shouldn't be surfaced for resumption in the main agent history. + if (content.kind === 'subagent') { + return { fileName: file, sessionInfo: null }; + } + const firstUserMessage = extractFirstUserMessage(content.messages); const isCurrentSession = currentSessionId ? file.includes(currentSessionId.slice(0, 8)) @@ -497,6 +504,7 @@ 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'), ); diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index f5aa18a41e..3fa1d8bd5d 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -371,8 +371,10 @@ export function setPendingSettingValue( pendingSettings: Settings, ): Settings { const path = key.split('.'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const newSettings = JSON.parse(JSON.stringify(pendingSettings)); setNestedValue(newSettings, path, value); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return newSettings; } diff --git a/packages/cli/src/utils/userStartupWarnings.test.ts b/packages/cli/src/utils/userStartupWarnings.test.ts index 131d77f580..41ed061166 100644 --- a/packages/cli/src/utils/userStartupWarnings.test.ts +++ b/packages/cli/src/utils/userStartupWarnings.test.ts @@ -13,7 +13,10 @@ import { isFolderTrustEnabled, isWorkspaceTrusted, } from '../config/trustedFolders.js'; -import { getCompatibilityWarnings } from '@google/gemini-cli-core'; +import { + getCompatibilityWarnings, + WarningPriority, +} from '@google/gemini-cli-core'; // Mock os.homedir to control the home directory in tests vi.mock('os', async (importOriginal) => { @@ -31,6 +34,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...actual, homedir: () => os.homedir(), getCompatibilityWarnings: vi.fn().mockReturnValue([]), + WarningPriority: { + Low: 'low', + High: 'high', + }, }; }); @@ -65,12 +72,13 @@ describe('getUserStartupWarnings', () => { it('should return a warning when running in home directory', async () => { const warnings = await getUserStartupWarnings({}, homeDir); expect(warnings).toContainEqual( - expect.stringContaining( - 'Warning you are running Gemini CLI in your home directory', - ), - ); - expect(warnings).toContainEqual( - expect.stringContaining('warning can be disabled in /settings'), + expect.objectContaining({ + id: 'home-directory', + message: expect.stringContaining( + 'Warning you are running Gemini CLI in your home directory', + ), + priority: WarningPriority.Low, + }), ); }); @@ -78,9 +86,7 @@ describe('getUserStartupWarnings', () => { const projectDir = path.join(testRootDir, 'project'); await fs.mkdir(projectDir); const warnings = await getUserStartupWarnings({}, projectDir); - expect(warnings).not.toContainEqual( - expect.stringContaining('home directory'), - ); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); }); it('should not return a warning when showHomeDirectoryWarning is false', async () => { @@ -88,9 +94,7 @@ describe('getUserStartupWarnings', () => { { ui: { showHomeDirectoryWarning: false } }, homeDir, ); - expect(warnings).not.toContainEqual( - expect.stringContaining('home directory'), - ); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); }); it('should not return a warning when folder trust is enabled and workspace is trusted', async () => { @@ -101,9 +105,7 @@ describe('getUserStartupWarnings', () => { }); const warnings = await getUserStartupWarnings({}, homeDir); - expect(warnings).not.toContainEqual( - expect.stringContaining('home directory'), - ); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); }); }); @@ -112,10 +114,11 @@ describe('getUserStartupWarnings', () => { const rootDir = path.parse(testRootDir).root; const warnings = await getUserStartupWarnings({}, rootDir); expect(warnings).toContainEqual( - expect.stringContaining('root directory'), - ); - expect(warnings).toContainEqual( - expect.stringContaining('folder structure will be used'), + expect.objectContaining({ + id: 'root-directory', + message: expect.stringContaining('root directory'), + priority: WarningPriority.High, + }), ); }); @@ -123,9 +126,7 @@ describe('getUserStartupWarnings', () => { const projectDir = path.join(testRootDir, 'project'); await fs.mkdir(projectDir); const warnings = await getUserStartupWarnings({}, projectDir); - expect(warnings).not.toContainEqual( - expect.stringContaining('root directory'), - ); + expect(warnings.find((w) => w.id === 'root-directory')).toBeUndefined(); }); }); @@ -133,24 +134,37 @@ describe('getUserStartupWarnings', () => { it('should handle errors when checking directory', async () => { const nonExistentPath = path.join(testRootDir, 'non-existent'); const warnings = await getUserStartupWarnings({}, nonExistentPath); - const expectedWarning = + const expectedMessage = 'Could not verify the current directory due to a file system error.'; - expect(warnings).toEqual([expectedWarning, expectedWarning]); + expect(warnings).toEqual([ + expect.objectContaining({ message: expectedMessage }), + expect.objectContaining({ message: expectedMessage }), + ]); }); }); describe('compatibility warnings', () => { it('should include compatibility warnings by default', async () => { - vi.mocked(getCompatibilityWarnings).mockReturnValue(['Comp warning 1']); + const compWarning = { + id: 'comp-1', + message: 'Comp warning 1', + priority: WarningPriority.High, + }; + vi.mocked(getCompatibilityWarnings).mockReturnValue([compWarning]); const projectDir = path.join(testRootDir, 'project'); await fs.mkdir(projectDir); const warnings = await getUserStartupWarnings({}, projectDir); - expect(warnings).toContain('Comp warning 1'); + expect(warnings).toContainEqual(compWarning); }); it('should not include compatibility warnings when showCompatibilityWarnings is false', async () => { - vi.mocked(getCompatibilityWarnings).mockReturnValue(['Comp warning 1']); + const compWarning = { + id: 'comp-1', + message: 'Comp warning 1', + priority: WarningPriority.High, + }; + vi.mocked(getCompatibilityWarnings).mockReturnValue([compWarning]); const projectDir = path.join(testRootDir, 'project'); await fs.mkdir(projectDir); @@ -158,7 +172,7 @@ describe('getUserStartupWarnings', () => { { ui: { showCompatibilityWarnings: false } }, projectDir, ); - expect(warnings).not.toContain('Comp warning 1'); + expect(warnings).not.toContainEqual(compWarning); }); }); }); diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts index cc2d2638d6..6174e6c420 100644 --- a/packages/cli/src/utils/userStartupWarnings.ts +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -7,7 +7,12 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; -import { homedir, getCompatibilityWarnings } from '@google/gemini-cli-core'; +import { + homedir, + getCompatibilityWarnings, + WarningPriority, + type StartupWarning, +} from '@google/gemini-cli-core'; import type { Settings } from '../config/settingsSchema.js'; import { isFolderTrustEnabled, @@ -17,11 +22,13 @@ import { type WarningCheck = { id: string; check: (workspaceRoot: string, settings: Settings) => Promise; + priority: WarningPriority; }; // Individual warning checks const homeDirectoryCheck: WarningCheck = { id: 'home-directory', + priority: WarningPriority.Low, check: async (workspaceRoot: string, settings: Settings) => { if (settings.ui?.showHomeDirectoryWarning === false) { return null; @@ -53,6 +60,7 @@ const homeDirectoryCheck: WarningCheck = { const rootDirectoryCheck: WarningCheck = { id: 'root-directory', + priority: WarningPriority.High, check: async (workspaceRoot: string, _settings: Settings) => { try { const workspaceRealPath = await fs.realpath(workspaceRoot); @@ -80,14 +88,29 @@ const WARNING_CHECKS: readonly WarningCheck[] = [ export async function getUserStartupWarnings( settings: Settings, workspaceRoot: string = process.cwd(), -): Promise { + options?: { isAlternateBuffer?: boolean }, +): Promise { const results = await Promise.all( - WARNING_CHECKS.map((check) => check.check(workspaceRoot, settings)), + WARNING_CHECKS.map(async (check) => { + const message = await check.check(workspaceRoot, settings); + if (message) { + return { + id: check.id, + message, + priority: check.priority, + }; + } + return null; + }), ); - const warnings = results.filter((msg) => msg !== null); + const warnings = results.filter((w): w is StartupWarning => w !== null); if (settings.ui?.showCompatibilityWarnings !== false) { - warnings.push(...getCompatibilityWarnings()); + warnings.push( + ...getCompatibilityWarnings({ + isAlternateBuffer: options?.isAlternateBuffer, + }), + ); } return warnings; diff --git a/packages/cli/src/zed-integration/acpErrors.ts b/packages/cli/src/zed-integration/acpErrors.ts index 2e111b2876..57067115bf 100644 --- a/packages/cli/src/zed-integration/acpErrors.ts +++ b/packages/cli/src/zed-integration/acpErrors.ts @@ -25,7 +25,9 @@ function extractRecursiveMessage(input: string): string { (trimmed.startsWith('[') && trimmed.endsWith(']')) ) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(trimmed); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const next = parsed?.error?.message || parsed?.[0]?.error?.message || diff --git a/packages/cli/src/zed-integration/fileSystemService.ts b/packages/cli/src/zed-integration/fileSystemService.ts index 51a32f2779..1d3c8ad0b8 100644 --- a/packages/cli/src/zed-integration/fileSystemService.ts +++ b/packages/cli/src/zed-integration/fileSystemService.ts @@ -23,11 +23,13 @@ export class AcpFileSystemService implements FileSystemService { return this.fallback.readTextFile(filePath); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const response = await this.connection.readTextFile({ path: filePath, sessionId: this.sessionId, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.content; } diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 3360b64513..1dce8d5e6d 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -516,7 +516,10 @@ export class Session { const functionCalls: FunctionCall[] = []; try { - const model = resolveModel(this.config.getModel()); + const model = resolveModel( + this.config.getModel(), + (await this.config.getGemini31Launched?.()) ?? false, + ); const responseStream = await chat.sendMessageStream( { model }, nextMessage?.parts ?? [], @@ -695,6 +698,7 @@ export class Session { }, }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const output = await this.connection.requestPermission(params); const outcome = output.outcome.outcome === CoreToolCallStatus.Cancelled diff --git a/packages/core/package.json b/packages/core/package.json index 529a788b44..e01efe9b3f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,7 +25,7 @@ "@google-cloud/logging": "^11.2.1", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", - "@google/genai": "1.30.0", + "@google/genai": "1.41.0", "@iarna/toml": "^2.2.5", "@joshua.litt/get-ripgrep": "^0.0.3", "@modelcontextprotocol/sdk": "^1.23.0", @@ -57,7 +57,7 @@ "fdir": "^6.4.6", "fzf": "^0.5.2", "glob": "^12.0.0", - "google-auth-library": "^9.11.0", + "google-auth-library": "^10.5.0", "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", @@ -72,6 +72,7 @@ "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", + "strip-json-comments": "^3.1.1", "systeminformation": "^5.25.11", "tree-sitter-bash": "^0.25.0", "undici": "^7.10.0", diff --git a/packages/core/src/agents/a2a-client-manager.test.ts b/packages/core/src/agents/a2a-client-manager.test.ts index 2f653ba176..42e31d2405 100644 --- a/packages/core/src/agents/a2a-client-manager.test.ts +++ b/packages/core/src/agents/a2a-client-manager.test.ts @@ -11,12 +11,13 @@ import { } from './a2a-client-manager.js'; import type { AgentCard, Task } from '@a2a-js/sdk'; import type { AuthenticationHandler, Client } from '@a2a-js/sdk/client'; -import { ClientFactory, DefaultAgentCardResolver } from '@a2a-js/sdk/client'; -import { debugLogger } from '../utils/debugLogger.js'; import { + ClientFactory, + DefaultAgentCardResolver, createAuthenticatingFetchWithRetry, ClientFactoryOptions, } from '@a2a-js/sdk/client'; +import { debugLogger } from '../utils/debugLogger.js'; vi.mock('../utils/debugLogger.js', () => ({ debugLogger: { diff --git a/packages/core/src/agents/acknowledgedAgents.ts b/packages/core/src/agents/acknowledgedAgents.ts index 230b62443a..98c90afb96 100644 --- a/packages/core/src/agents/acknowledgedAgents.ts +++ b/packages/core/src/agents/acknowledgedAgents.ts @@ -27,6 +27,7 @@ export class AcknowledgedAgentsService { const filePath = Storage.getAcknowledgedAgentsPath(); try { const content = await fs.readFile(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this.acknowledgedAgents = JSON.parse(content); } catch (error: unknown) { if (!isNodeError(error) || error.code !== 'ENOENT') { diff --git a/packages/core/src/agents/agent-scheduler.ts b/packages/core/src/agents/agent-scheduler.ts index 4b2e0fa587..ecb4ed960a 100644 --- a/packages/core/src/agents/agent-scheduler.ts +++ b/packages/core/src/agents/agent-scheduler.ts @@ -54,6 +54,7 @@ export async function scheduleAgentTools( } = options; // Create a proxy/override of the config to provide the agent-specific tool registry. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const agentConfig: Config = Object.create(config); agentConfig.getToolRegistry = () => toolRegistry; diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index a54626b637..a62c0b02ba 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -373,7 +373,6 @@ agent_card_url: https://example.com/card auth: type: apiKey key: $MY_API_KEY - in: header name: X-Custom-Key --- `); @@ -385,7 +384,6 @@ auth: auth: { type: 'apiKey', key: '$MY_API_KEY', - in: 'header', name: 'X-Custom-Key', }, }); @@ -468,7 +466,7 @@ auth: --- `); await expect(parseAgentMarkdown(filePath)).rejects.toThrow( - /Basic scheme requires "username" and "password"/, + /Basic authentication requires "password"/, ); }); @@ -494,7 +492,6 @@ auth: auth: { type: 'apiKey' as const, key: '$API_KEY', - in: 'header' as const, }, }; @@ -505,7 +502,6 @@ auth: auth: { type: 'apiKey', key: '$API_KEY', - location: 'header', }, }); }); diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index cb2a605779..bdc59de746 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import yaml from 'js-yaml'; +import { load } from 'js-yaml'; import * as fs from 'node:fs/promises'; import { type Dirent } from 'node:fs'; import * as path from 'node:path'; @@ -48,7 +48,6 @@ interface FrontmatterAuthConfig { agent_card_requires_auth?: boolean; // API Key key?: string; - in?: 'header' | 'query' | 'cookie'; name?: string; // HTTP scheme?: 'Bearer' | 'Basic'; @@ -129,7 +128,6 @@ const apiKeyAuthSchema = z.object({ ...baseAuthFields, type: z.literal('apiKey'), key: z.string().min(1, 'API key is required'), - in: z.enum(['header', 'query', 'cookie']).optional(), name: z.string().optional(), }); @@ -138,24 +136,18 @@ const apiKeyAuthSchema = z.object({ * Note: Validation for scheme-specific fields is applied in authConfigSchema * since discriminatedUnion doesn't support refined schemas directly. */ -const httpAuthSchemaBase = z.object({ +const httpAuthSchema = z.object({ ...baseAuthFields, type: z.literal('http'), scheme: z.enum(['Bearer', 'Basic']), - token: z.string().optional(), - username: z.string().optional(), - password: z.string().optional(), + token: z.string().min(1).optional(), + username: z.string().min(1).optional(), + password: z.string().min(1).optional(), }); -/** - * Combined auth schema - discriminated union of all auth types. - * Note: We use the base schema for discriminatedUnion, then apply refinements - * via superRefine since discriminatedUnion doesn't support refined schemas directly. - */ const authConfigSchema = z - .discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchemaBase]) + .discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchema]) .superRefine((data, ctx) => { - // Apply HTTP auth validation after union parsing if (data.type === 'http') { if (data.scheme === 'Bearer' && !data.token) { ctx.addIssue({ @@ -164,12 +156,21 @@ const authConfigSchema = z path: ['token'], }); } - if (data.scheme === 'Basic' && (!data.username || !data.password)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Basic scheme requires "username" and "password"', - path: data.username ? ['password'] : ['username'], - }); + if (data.scheme === 'Basic') { + if (!data.username) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Basic authentication requires "username"', + path: ['username'], + }); + } + if (!data.password) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Basic authentication requires "password"', + path: ['password'], + }); + } } } }); @@ -261,7 +262,7 @@ export async function parseAgentMarkdown( let rawFrontmatter: unknown; try { - rawFrontmatter = yaml.load(frontmatterStr); + rawFrontmatter = load(frontmatterStr); } catch (error) { throw new AgentLoadError( filePath, @@ -338,7 +339,6 @@ function convertFrontmatterAuthToConfig( ...base, type: 'apiKey', key: frontmatter.key, - location: frontmatter.in, name: frontmatter.name, }; diff --git a/packages/core/src/agents/auth-provider/api-key-provider.test.ts b/packages/core/src/agents/auth-provider/api-key-provider.test.ts new file mode 100644 index 0000000000..82d8c271e5 --- /dev/null +++ b/packages/core/src/agents/auth-provider/api-key-provider.test.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ApiKeyAuthProvider } from './api-key-provider.js'; + +describe('ApiKeyAuthProvider', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('initialization', () => { + it('should initialize with literal API key', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-api-key', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'my-api-key' }); + }); + + it('should resolve API key from environment variable', async () => { + vi.stubEnv('TEST_API_KEY', 'env-api-key'); + + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$TEST_API_KEY', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'env-api-key' }); + }); + + it('should throw if environment variable is not set', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$MISSING_KEY_12345', + }); + + await expect(provider.initialize()).rejects.toThrow( + "Environment variable 'MISSING_KEY_12345' is not set", + ); + }); + }); + + describe('headers', () => { + it('should throw if not initialized', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test-key', + }); + + await expect(provider.headers()).rejects.toThrow('not initialized'); + }); + + it('should use custom header name', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + name: 'X-Custom-Auth', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-Custom-Auth': 'my-key' }); + }); + + it('should use default header name X-API-Key', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'my-key' }); + }); + }); + + describe('shouldRetryWithHeaders', () => { + it('should return undefined for non-auth errors', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test-key', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 500 }), + ); + expect(result).toBeUndefined(); + }); + + it('should return undefined for literal keys on 401 (same headers would fail again)', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test-key', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(result).toBeUndefined(); + }); + + it('should return undefined for env-var keys on 403', async () => { + vi.stubEnv('RETRY_TEST_KEY', 'some-key'); + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$RETRY_TEST_KEY', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 403 }), + ); + expect(result).toBeUndefined(); + }); + + it('should re-resolve and return headers for command keys on 401', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '!echo refreshed-key', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(result).toEqual({ 'X-API-Key': 'refreshed-key' }); + }); + + it('should stop retrying after MAX_AUTH_RETRIES', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '!echo rotating-key', + }); + await provider.initialize(); + + const r1 = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(r1).toBeDefined(); + + const r2 = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(r2).toBeDefined(); + + const r3 = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(r3).toBeUndefined(); + }); + }); + + describe('type property', () => { + it('should have type apiKey', () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test', + }); + expect(provider.type).toBe('apiKey'); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/api-key-provider.ts b/packages/core/src/agents/auth-provider/api-key-provider.ts new file mode 100644 index 0000000000..207c987271 --- /dev/null +++ b/packages/core/src/agents/auth-provider/api-key-provider.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HttpHeaders } from '@a2a-js/sdk/client'; +import { BaseA2AAuthProvider } from './base-provider.js'; +import type { ApiKeyAuthConfig } from './types.js'; +import { resolveAuthValue, needsResolution } from './value-resolver.js'; +import { debugLogger } from '../../utils/debugLogger.js'; + +const DEFAULT_HEADER_NAME = 'X-API-Key'; + +/** + * Authentication provider for API Key authentication. + * Sends the API key as an HTTP header. + * + * The API key value can be: + * - A literal string + * - An environment variable reference ($ENV_VAR) + * - A shell command (!command) + */ +export class ApiKeyAuthProvider extends BaseA2AAuthProvider { + readonly type = 'apiKey' as const; + + private resolvedKey: string | undefined; + private readonly headerName: string; + + constructor(private readonly config: ApiKeyAuthConfig) { + super(); + this.headerName = config.name ?? DEFAULT_HEADER_NAME; + } + + override async initialize(): Promise { + if (needsResolution(this.config.key)) { + this.resolvedKey = await resolveAuthValue(this.config.key); + debugLogger.debug( + `[ApiKeyAuthProvider] Resolved API key from: ${this.config.key.startsWith('$') ? 'env var' : 'command'}`, + ); + } else { + this.resolvedKey = this.config.key; + } + } + + async headers(): Promise { + if (!this.resolvedKey) { + throw new Error( + 'ApiKeyAuthProvider not initialized. Call initialize() first.', + ); + } + return { [this.headerName]: this.resolvedKey }; + } + + /** + * Re-resolve command-based API keys on auth failure. + */ + override async shouldRetryWithHeaders( + _req: RequestInit, + res: Response, + ): Promise { + if (res.status !== 401 && res.status !== 403) { + this.authRetryCount = 0; + return undefined; + } + + // Only retry for command-based keys that may resolve to a new value. + // Literal and env-var keys would just resend the same failing headers. + if (!this.config.key.startsWith('!') || this.config.key.startsWith('!!')) { + return undefined; + } + + if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) { + return undefined; + } + this.authRetryCount++; + + debugLogger.debug( + '[ApiKeyAuthProvider] Re-resolving API key after auth failure', + ); + this.resolvedKey = await resolveAuthValue(this.config.key); + + return this.headers(); + } +} diff --git a/packages/core/src/agents/auth-provider/base-provider.ts b/packages/core/src/agents/auth-provider/base-provider.ts index 7fb2e61acc..2c8fd3ee2a 100644 --- a/packages/core/src/agents/auth-provider/base-provider.ts +++ b/packages/core/src/agents/auth-provider/base-provider.ts @@ -23,8 +23,8 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider { */ abstract headers(): Promise; - private static readonly MAX_AUTH_RETRIES = 2; - private authRetryCount = 0; + protected static readonly MAX_AUTH_RETRIES = 2; + protected authRetryCount = 0; /** * Check if a request should be retried with new headers. diff --git a/packages/core/src/agents/auth-provider/factory.test.ts b/packages/core/src/agents/auth-provider/factory.test.ts index 6aa7069fa9..17de791de9 100644 --- a/packages/core/src/agents/auth-provider/factory.test.ts +++ b/packages/core/src/agents/auth-provider/factory.test.ts @@ -478,5 +478,19 @@ describe('A2AAuthProviderFactory', () => { // Returns undefined - caller should prompt user to configure auth expect(result).toBeUndefined(); }); + + it('should create an ApiKeyAuthProvider for apiKey config', async () => { + const provider = await A2AAuthProviderFactory.create({ + authConfig: { + type: 'apiKey', + key: 'factory-test-key', + }, + }); + + expect(provider).toBeDefined(); + expect(provider!.type).toBe('apiKey'); + const headers = await provider!.headers(); + expect(headers).toEqual({ 'X-API-Key': 'factory-test-key' }); + }); }); }); diff --git a/packages/core/src/agents/auth-provider/factory.ts b/packages/core/src/agents/auth-provider/factory.ts index b79c8b4f77..9562737345 100644 --- a/packages/core/src/agents/auth-provider/factory.ts +++ b/packages/core/src/agents/auth-provider/factory.ts @@ -10,6 +10,7 @@ import type { A2AAuthProvider, AuthValidationResult, } from './types.js'; +import { ApiKeyAuthProvider } from './api-key-provider.js'; export interface CreateAuthProviderOptions { /** Required for OAuth/OIDC token storage. */ @@ -43,9 +44,11 @@ export class A2AAuthProviderFactory { // TODO: Implement throw new Error('google-credentials auth provider not yet implemented'); - case 'apiKey': - // TODO: Implement - throw new Error('apiKey auth provider not yet implemented'); + case 'apiKey': { + const provider = new ApiKeyAuthProvider(authConfig); + await provider.initialize(); + return provider; + } case 'http': // TODO: Implement diff --git a/packages/core/src/agents/auth-provider/types.ts b/packages/core/src/agents/auth-provider/types.ts index 67fce94ca8..7d41b1b4a9 100644 --- a/packages/core/src/agents/auth-provider/types.ts +++ b/packages/core/src/agents/auth-provider/types.ts @@ -34,14 +34,13 @@ export interface GoogleCredentialsAuthConfig extends BaseAuthConfig { scopes?: string[]; } -/** Client config corresponding to APIKeySecurityScheme. */ +/** Client config corresponding to APIKeySecurityScheme. Only header location is supported. */ +// TODO: Add 'query' and 'cookie' location support if needed. export interface ApiKeyAuthConfig extends BaseAuthConfig { type: 'apiKey'; /** The secret. Supports $ENV_VAR, !command, or literal. */ key: string; - /** Defaults to server's SecurityScheme.in value. */ - location?: 'header' | 'query' | 'cookie'; - /** Defaults to server's SecurityScheme.name value. */ + /** Header name. @default 'X-API-Key' */ name?: string; } diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index a9a0697bce..8f7269b784 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -41,14 +41,15 @@ import type { Config } from '../config/config.js'; import { MockTool } from '../test-utils/mock-tool.js'; import { getDirectoryContextString } from '../utils/environmentContext.js'; import { z } from 'zod'; +import { getErrorMessage } from '../utils/errors.js'; import { promptIdContext } from '../utils/promptIdContext.js'; import { logAgentStart, logAgentFinish, logRecoveryAttempt, } from '../telemetry/loggers.js'; -import { LlmRole } from '../telemetry/types.js'; import { + LlmRole, AgentStartEvent, AgentFinishEvent, RecoveryAttemptEvent, @@ -1250,7 +1251,7 @@ describe('LocalAgentExecutor', () => { ); await expect(executor.run({ goal: 'test' }, signal)).rejects.toThrow( - `Failed to create chat object: ${initError}`, + `Failed to create chat object: ${getErrorMessage(initError)}`, ); // Ensure the error was reported via the activity callback @@ -1258,7 +1259,7 @@ describe('LocalAgentExecutor', () => { expect.objectContaining({ type: 'ERROR', data: expect.objectContaining({ - error: `Error: Failed to create chat object: ${initError}`, + error: `Error: Failed to create chat object: ${getErrorMessage(initError)}`, }), }), ); diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index bcb4e888ce..513424ad32 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -33,6 +33,7 @@ import { import { AgentStartEvent, AgentFinishEvent, + LlmRole, RecoveryAttemptEvent, } from '../telemetry/types.js'; import type { @@ -47,6 +48,7 @@ import { DEFAULT_MAX_TURNS, DEFAULT_MAX_TIME_MINUTES, } from './types.js'; +import { getErrorMessage } from '../utils/errors.js'; import { templateString } from './utils.js'; import { DEFAULT_GEMINI_MODEL, isAutoModel } from '../config/models.js'; import type { RoutingContext } from '../routing/routingStrategy.js'; @@ -59,7 +61,6 @@ import { getVersion } from '../utils/version.js'; import { getToolCallContext } from '../utils/toolCallContext.js'; import { scheduleAgentTools } from './agent-scheduler.js'; import { DeadlineTimer } from '../utils/deadlineTimer.js'; -import { LlmRole } from '../telemetry/types.js'; import { formatUserHintsForModel } from '../utils/fastAckHelper.js'; /** A callback function to report on agent activity. */ @@ -826,16 +827,19 @@ export class LocalAgentExecutor { systemInstruction, [{ functionDeclarations: tools }], startHistory, + undefined, + undefined, + 'subagent', ); - } catch (error) { + } catch (e: unknown) { await reportError( - error, + e, `Error initializing Gemini chat for agent ${this.definition.name}.`, startHistory, 'startChat', ); // Re-throw as a more specific error after reporting. - throw new Error(`Failed to create chat object: ${error}`); + throw new Error(`Failed to create chat object: ${getErrorMessage(e)}`); } } @@ -925,6 +929,7 @@ export class LocalAgentExecutor { continue; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const validatedOutput = validationResult.data; if (this.definition.processOutput) { submittedOutput = this.definition.processOutput(validatedOutput); diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index cdaa46fd76..91efcd399f 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -5,10 +5,13 @@ */ import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; -import type { LocalAgentDefinition } from './types.js'; +import type { + LocalAgentDefinition, + SubagentActivityEvent, + AgentInputs, +} from './types.js'; import { LocalSubagentInvocation } from './local-invocation.js'; import { LocalAgentExecutor } from './local-executor.js'; -import type { SubagentActivityEvent, AgentInputs } from './types.js'; import { AgentTerminateMode } from './types.js'; import { makeFakeConfig } from '../test-utils/config.js'; import { ToolErrorType } from '../tools/tool-error.js'; diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 2068968428..8cc45a9a5a 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AgentRegistry, getModelConfigAlias } from './registry.js'; import { makeFakeConfig } from '../test-utils/config.js'; import type { AgentDefinition, LocalAgentDefinition } from './types.js'; -import type { Config, GeminiCLIExtension } from '../config/config.js'; +import type { + Config, + GeminiCLIExtension, + ConfigParameters, +} from '../config/config.js'; import { debugLogger } from '../utils/debugLogger.js'; import { coreEvents, CoreEvent } from '../utils/events.js'; import { A2AClientManager } from './a2a-client-manager.js'; @@ -22,7 +26,6 @@ import { } from '../config/models.js'; import * as tomlLoader from './agentLoader.js'; import { SimpleExtensionLoader } from '../utils/extensionLoader.js'; -import type { ConfigParameters } from '../config/config.js'; import type { ToolRegistry } from '../tools/tool-registry.js'; import { ThinkingLevel } from '@google/genai'; import type { AcknowledgedAgentsService } from './acknowledgedAgents.js'; diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 85747c3964..bcd4d65878 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -394,6 +394,7 @@ export class AgentRegistry { } // Use Object.create to preserve lazy getters on the definition object + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const merged: LocalAgentDefinition = Object.create(definition); if (overrides.runConfig) { diff --git a/packages/core/src/agents/remote-invocation.ts b/packages/core/src/agents/remote-invocation.ts index 41564944ec..ea43c901a2 100644 --- a/packages/core/src/agents/remote-invocation.ts +++ b/packages/core/src/agents/remote-invocation.ts @@ -4,12 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ToolConfirmationOutcome } from '../tools/tools.js'; -import { - BaseToolInvocation, - type ToolResult, - type ToolCallConfirmationDetails, +import type { + ToolConfirmationOutcome, + ToolResult, + ToolCallConfirmationDetails, } from '../tools/tools.js'; +import { BaseToolInvocation } from '../tools/tools.js'; import { DEFAULT_QUERY_STRING } from './types.js'; import type { RemoteAgentInputs, diff --git a/packages/core/src/agents/subagent-tool.test.ts b/packages/core/src/agents/subagent-tool.test.ts index 135365712d..d6d6bdfd89 100644 --- a/packages/core/src/agents/subagent-tool.test.ts +++ b/packages/core/src/agents/subagent-tool.test.ts @@ -17,10 +17,12 @@ 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 type { + DeclarativeTool, ToolCallConfirmationDetails, ToolInvocation, ToolResult, } from '../tools/tools.js'; +import type { ToolRegistry } from 'src/tools/tool-registry.js'; vi.mock('./subagent-tool-wrapper.js'); @@ -274,3 +276,85 @@ describe('SubAgentInvocation', () => { }); }); }); + +describe('SubagentTool Read-Only logic', () => { + let mockConfig: Config; + let mockMessageBus: MessageBus; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = makeFakeConfig(); + 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, 'getToolRegistry').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, 'getToolRegistry').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, 'getToolRegistry').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 index 3a92452c3d..f47b506634 100644 --- a/packages/core/src/agents/subagent-tool.ts +++ b/packages/core/src/agents/subagent-tool.ts @@ -11,6 +11,7 @@ import { type ToolResult, BaseToolInvocation, type ToolCallConfirmationDetails, + isTool, } from '../tools/tools.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { Config } from '../config/config.js'; @@ -48,6 +49,53 @@ export class SubagentTool extends BaseDeclarativeTool { ); } + 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.config, + ); + return this._memoizedIsReadOnly; + } + + private static checkIsReadOnly( + definition: AgentDefinition, + config: Config, + ): boolean { + if (definition.kind === 'remote') { + return false; + } + const tools = definition.toolConfig?.tools ?? []; + const registry = config.getToolRegistry(); + + 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, diff --git a/packages/core/src/availability/policyHelpers.ts b/packages/core/src/availability/policyHelpers.ts index 569157561f..6cf22d6388 100644 --- a/packages/core/src/availability/policyHelpers.ts +++ b/packages/core/src/availability/policyHelpers.ts @@ -44,7 +44,10 @@ export function resolvePolicyChain( const configuredModel = config.getModel(); let chain; - const resolvedModel = resolveModel(modelFromConfig); + const resolvedModel = resolveModel( + modelFromConfig, + config.getGemini31LaunchedSync?.() ?? false, + ); const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false; const isAutoConfigured = isAutoModel(configuredModel); const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true; diff --git a/packages/core/src/code_assist/admin/admin_controls.ts b/packages/core/src/code_assist/admin/admin_controls.ts index d4117c2107..d18fcf3d66 100644 --- a/packages/core/src/code_assist/admin/admin_controls.ts +++ b/packages/core/src/code_assist/admin/admin_controls.ts @@ -31,6 +31,7 @@ export function sanitizeAdminSettings( if (sanitized.mcpSetting?.mcpConfigJson) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(sanitized.mcpSetting.mcpConfigJson); const validationResult = McpConfigDefinitionSchema.safeParse(parsed); diff --git a/packages/core/src/code_assist/converter.test.ts b/packages/core/src/code_assist/converter.test.ts index 31e66bcd17..21fecec547 100644 --- a/packages/core/src/code_assist/converter.test.ts +++ b/packages/core/src/code_assist/converter.test.ts @@ -14,12 +14,12 @@ import { import type { ContentListUnion, GenerateContentParameters, + Part, } from '@google/genai'; import { GenerateContentResponse, FinishReason, BlockedReason, - type Part, } from '@google/genai'; describe('converter', () => { diff --git a/packages/core/src/code_assist/converter.ts b/packages/core/src/code_assist/converter.ts index 1f2b4417ac..1d41101f31 100644 --- a/packages/core/src/code_assist/converter.ts +++ b/packages/core/src/code_assist/converter.ts @@ -181,6 +181,16 @@ function maybeToContent(content?: ContentUnion): Content | undefined { return toContent(content); } +function isPart(c: ContentUnion): c is PartUnion { + return ( + typeof c === 'object' && + c !== null && + !Array.isArray(c) && + !('parts' in c) && + !('role' in c) + ); +} + function toContent(content: ContentUnion): Content { if (Array.isArray(content)) { // it's a PartsUnion[] @@ -196,7 +206,7 @@ function toContent(content: ContentUnion): Content { parts: [{ text: content }], }; } - if ('parts' in content) { + if (!isPart(content)) { // it's a Content - process parts to handle thought filtering return { ...content, @@ -208,8 +218,7 @@ function toContent(content: ContentUnion): Content { // it's a Part return { role: 'user', - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - parts: [toPart(content as Part)], + parts: [toPart(content)], }; } diff --git a/packages/core/src/code_assist/experiments/experiments.ts b/packages/core/src/code_assist/experiments/experiments.ts index 614fbda43e..b46f88a17f 100644 --- a/packages/core/src/code_assist/experiments/experiments.ts +++ b/packages/core/src/code_assist/experiments/experiments.ts @@ -35,7 +35,8 @@ export async function getExperiments( const expPath = process.env['GEMINI_EXP']; debugLogger.debug('Reading experiments from', expPath); const content = await fs.promises.readFile(expPath, 'utf8'); - const response = JSON.parse(content); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const response: ListExperimentsResponse = JSON.parse(content); if ( (response.flags && !Array.isArray(response.flags)) || (response.experimentIds && !Array.isArray(response.experimentIds)) @@ -44,8 +45,7 @@ export async function getExperiments( 'Invalid format for experiments file: `flags` and `experimentIds` must be arrays if present.', ); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return parseExperiments(response as ListExperimentsResponse); + return parseExperiments(response); } catch (e) { debugLogger.debug('Failed to read experiments from GEMINI_EXP', e); } diff --git a/packages/core/src/code_assist/experiments/flagNames.ts b/packages/core/src/code_assist/experiments/flagNames.ts index 03b6aaac0a..e1ae2a1af2 100644 --- a/packages/core/src/code_assist/experiments/flagNames.ts +++ b/packages/core/src/code_assist/experiments/flagNames.ts @@ -16,6 +16,7 @@ export const ExperimentFlags = { MASKING_PROTECTION_THRESHOLD: 45758817, MASKING_PRUNABLE_THRESHOLD: 45758818, MASKING_PROTECT_LATEST_TURN: 45758819, + GEMINI_3_1_PRO_LAUNCHED: 45760185, } as const; export type ExperimentFlagName = diff --git a/packages/core/src/code_assist/oauth-credential-storage.ts b/packages/core/src/code_assist/oauth-credential-storage.ts index 836fe1c4c3..c7c0209cfa 100644 --- a/packages/core/src/code_assist/oauth-credential-storage.ts +++ b/packages/core/src/code_assist/oauth-credential-storage.ts @@ -125,8 +125,8 @@ export class OAuthCredentialStorage { throw error; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const credentials = JSON.parse(credsJson) as Credentials; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const credentials: Credentials = JSON.parse(credsJson); // Save to new storage await this.saveCredentials(credentials); diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index e23f86fe6e..ae45c3a6b3 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -32,6 +32,7 @@ import { writeToStdout } from '../utils/stdio.js'; import { FatalCancellationError } from '../utils/errors.js'; import process from 'node:process'; import { coreEvents } from '../utils/events.js'; +import { isHeadlessMode } from '../utils/headless.js'; vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); @@ -54,6 +55,9 @@ vi.mock('http'); vi.mock('open'); vi.mock('crypto'); vi.mock('node:readline'); +vi.mock('../utils/headless.js', () => ({ + isHeadlessMode: vi.fn(), +})); vi.mock('../utils/browser.js', () => ({ shouldAttemptBrowserLaunch: () => true, })); @@ -98,6 +102,12 @@ global.fetch = vi.fn(); describe('oauth2', () => { beforeEach(() => { + vi.mocked(isHeadlessMode).mockReturnValue(false); + (readline.createInterface as Mock).mockReturnValue({ + question: vi.fn((_query, callback) => callback('')), + close: vi.fn(), + on: vi.fn(), + }); vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => { payload.onConfirm(true); diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 9676f2aa74..7ee3fbe02e 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -115,9 +115,9 @@ async function initOauthClient( if ( credentials && - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (credentials as { type?: string }).type === - 'external_account_authorized_user' + typeof credentials === 'object' && + 'type' in credentials && + credentials.type === 'external_account_authorized_user' ) { const auth = new GoogleAuth({ scopes: OAUTH_SCOPE, @@ -271,7 +271,7 @@ async function initOauthClient( await triggerPostAuthCallbacks(client.credentials); } else { - const userConsent = await getConsentForOauth('Code Assist login required.'); + const userConsent = await getConsentForOauth(''); if (!userConsent) { throw new FatalCancellationError('Authentication cancelled by user.'); } @@ -281,8 +281,7 @@ async function initOauthClient( coreEvents.emit(CoreEvent.UserFeedback, { severity: 'info', message: - `\n\nCode Assist login required.\n` + - `Attempting to open authentication page in your browser.\n` + + `\n\nAttempting to open authentication page in your browser.\n` + `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n\n`, }); try { @@ -603,9 +602,10 @@ export function getAvailablePort(): Promise { } const server = net.createServer(); server.listen(0, () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const address = server.address()! as net.AddressInfo; - port = address.port; + const address = server.address(); + if (address && typeof address === 'object') { + port = address.port; + } }); server.on('listening', () => { server.close(); @@ -635,6 +635,7 @@ async function fetchCachedCredentials(): Promise< for (const keyFile of pathsToTry) { try { const keyFileString = await fs.readFile(keyFile, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(keyFileString); } catch (error) { // Log specific error for debugging, but continue trying other paths @@ -694,6 +695,7 @@ async function fetchAndCacheUserInfo(client: OAuth2Client): Promise { return; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const userInfo = await response.json(); await userAccountManager.cacheGoogleAccount(userInfo.email); } catch (error) { diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts index 89ce45e1aa..8ec8cb8dad 100644 --- a/packages/core/src/code_assist/server.test.ts +++ b/packages/core/src/code_assist/server.test.ts @@ -408,6 +408,48 @@ describe('CodeAssistServer', () => { expect(results[1].candidates?.[0].content?.parts?.[0].text).toBe(' World'); }); + it('should handle Web ReadableStream in generateContentStream', async () => { + const { server, mockRequest } = createTestServer(); + + // Create a mock Web ReadableStream + const mockWebStream = new ReadableStream({ + start(controller) { + const mockResponseData = { + response: { + candidates: [{ content: { parts: [{ text: 'Hello Web' }] } }], + }, + }; + controller.enqueue( + new TextEncoder().encode( + 'data: ' + JSON.stringify(mockResponseData) + '\n\n', + ), + ); + controller.close(); + }, + }); + + mockRequest.mockResolvedValue({ data: mockWebStream }); + + const stream = await server.generateContentStream( + { + model: 'test-model', + contents: [{ role: 'user', parts: [{ text: 'request' }] }], + }, + 'user-prompt-id', + LlmRole.MAIN, + ); + + const results = []; + for await (const res of stream) { + results.push(res); + } + + expect(results).toHaveLength(1); + expect(results[0].candidates?.[0].content?.parts?.[0].text).toBe( + 'Hello Web', + ); + }); + it('should ignore malformed SSE data', async () => { const { server, mockRequest } = createTestServer(); diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index 871af4cbfa..ff5fb76e07 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -7,7 +7,6 @@ import type { AuthClient } from 'google-auth-library'; import type { CodeAssistGlobalUserSettingResponse, - GoogleRpcResponse, LoadCodeAssistRequest, LoadCodeAssistResponse, LongRunningOperationResponse, @@ -36,6 +35,7 @@ import type { GenerateContentResponse, } from '@google/genai'; import * as readline from 'node:readline'; +import { Readable } from 'node:stream'; import type { ContentGenerator } from '../core/contentGenerator.js'; import { UserTierId } from './types.js'; import type { @@ -295,7 +295,7 @@ export class CodeAssistServer implements ContentGenerator { req: object, signal?: AbortSignal, ): Promise { - const res = await this.client.request({ + const res = await this.client.request({ url: this.getMethodUrl(method), method: 'POST', headers: { @@ -306,15 +306,14 @@ export class CodeAssistServer implements ContentGenerator { body: JSON.stringify(req), signal, }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return res.data as T; + return res.data; } private async makeGetRequest( url: string, signal?: AbortSignal, ): Promise { - const res = await this.client.request({ + const res = await this.client.request({ url, method: 'GET', headers: { @@ -324,8 +323,7 @@ export class CodeAssistServer implements ContentGenerator { responseType: 'json', signal, }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return res.data as T; + return res.data; } async requestGet(method: string, signal?: AbortSignal): Promise { @@ -341,7 +339,7 @@ export class CodeAssistServer implements ContentGenerator { req: object, signal?: AbortSignal, ): Promise> { - const res = await this.client.request({ + const res = await this.client.request>({ url: this.getMethodUrl(method), method: 'POST', params: { @@ -358,8 +356,7 @@ export class CodeAssistServer implements ContentGenerator { return (async function* (): AsyncGenerator { const rl = readline.createInterface({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - input: res.data as NodeJS.ReadableStream, + input: Readable.from(res.data), crlfDelay: Infinity, // Recognizes '\r\n' and '\n' as line breaks }); @@ -371,8 +368,7 @@ export class CodeAssistServer implements ContentGenerator { if (bufferedLines.length === 0) { continue; // no data to yield } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - yield JSON.parse(bufferedLines.join('\n')) as T; + yield JSON.parse(bufferedLines.join('\n')); bufferedLines = []; // Reset the buffer after yielding } // Ignore other lines like comments or id fields @@ -397,23 +393,43 @@ export class CodeAssistServer implements ContentGenerator { } } -function isVpcScAffectedUser(error: unknown): boolean { - if (error && typeof error === 'object' && 'response' in error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const gaxiosError = error as { - response?: { - data?: unknown; +interface VpcScErrorResponse { + response: { + data: { + error: { + details: unknown[]; }; }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const response = gaxiosError.response?.data as - | GoogleRpcResponse - | undefined; - if (Array.isArray(response?.error?.details)) { - return response.error.details.some( - (detail) => detail.reason === 'SECURITY_POLICY_VIOLATED', - ); - } + }; +} + +function isVpcScErrorResponse(error: unknown): error is VpcScErrorResponse { + return ( + !!error && + typeof error === 'object' && + 'response' in error && + !!error.response && + typeof error.response === 'object' && + 'data' in error.response && + !!error.response.data && + typeof error.response.data === 'object' && + 'error' in error.response.data && + !!error.response.data.error && + typeof error.response.data.error === 'object' && + 'details' in error.response.data.error && + Array.isArray(error.response.data.error.details) + ); +} + +function isVpcScAffectedUser(error: unknown): boolean { + if (isVpcScErrorResponse(error)) { + return error.response.data.error.details.some( + (detail: unknown) => + detail && + typeof detail === 'object' && + 'reason' in detail && + detail.reason === 'SECURITY_POLICY_VIOLATED', + ); } return false; } diff --git a/packages/core/src/code_assist/telemetry.test.ts b/packages/core/src/code_assist/telemetry.test.ts index c838aeb943..c90040f22e 100644 --- a/packages/core/src/code_assist/telemetry.test.ts +++ b/packages/core/src/code_assist/telemetry.test.ts @@ -316,6 +316,14 @@ describe('telemetry', () => { prompt_id: 'p1', traceId: 'trace-1', }, + response: { + resultDisplay: { + diffStat: { + model_added_lines: 5, + model_removed_lines: 3, + }, + }, + }, outcome: ToolConfirmationOutcome.ProceedOnce, status: 'success', } as unknown as CompletedToolCall, @@ -327,6 +335,8 @@ describe('telemetry', () => { traceId: 'trace-1', status: ActionStatus.ACTION_STATUS_NO_ERROR, interaction: ConversationInteractionInteraction.ACCEPT_FILE, + acceptedLines: '5', + removedLines: '3', isAgentic: true, }); }); diff --git a/packages/core/src/code_assist/telemetry.ts b/packages/core/src/code_assist/telemetry.ts index ad02691d53..59ff179c50 100644 --- a/packages/core/src/code_assist/telemetry.ts +++ b/packages/core/src/code_assist/telemetry.ts @@ -22,6 +22,10 @@ import { EDIT_TOOL_NAMES } from '../tools/tool-names.js'; import { getErrorMessage } from '../utils/errors.js'; import type { CodeAssistServer } from './server.js'; import { ToolConfirmationOutcome } from '../tools/tools.js'; +import { + computeModelAddedAndRemovedLines, + getFileDiffFromResultDisplay, +} from '../utils/fileDiffUtils.js'; export async function recordConversationOffered( server: CodeAssistServer, @@ -110,6 +114,8 @@ function summarizeToolCalls( // Treat file edits as ACCEPT_FILE and everything else as unknown. let isEdit = false; + let acceptedLines = 0; + let removedLines = 0; // Iterate the tool calls and summarize them into a single conversation // interaction so that the ConversationOffered and ConversationInteraction @@ -136,7 +142,18 @@ function summarizeToolCalls( // Edits are ACCEPT_FILE, everything else is UNKNOWN. if (EDIT_TOOL_NAMES.has(toolCall.request.name)) { - isEdit ||= true; + isEdit = true; + + if (toolCall.status === 'success') { + const fileDiff = getFileDiffFromResultDisplay( + toolCall.response.resultDisplay, + ); + if (fileDiff?.diffStat) { + const lines = computeModelAddedAndRemovedLines(fileDiff.diffStat); + acceptedLines += lines.addedLines; + removedLines += lines.removedLines; + } + } } } } @@ -149,6 +166,8 @@ function summarizeToolCalls( isEdit ? ConversationInteractionInteraction.ACCEPT_FILE : ConversationInteractionInteraction.UNKNOWN, + isEdit ? String(acceptedLines) : undefined, + isEdit ? String(removedLines) : undefined, ) : undefined; } @@ -157,15 +176,18 @@ function createConversationInteraction( traceId: string, status: ActionStatus, interaction: ConversationInteractionInteraction, + acceptedLines?: string, + removedLines?: string, ): ConversationInteraction { return { traceId, status, interaction, + acceptedLines, + removedLines, isAgentic: true, }; } - function includesCode(resp: GenerateContentResponse): boolean { if (!resp.candidates) { return false; diff --git a/packages/core/src/code_assist/types.ts b/packages/core/src/code_assist/types.ts index 7845ceee89..0e2f353aa3 100644 --- a/packages/core/src/code_assist/types.ts +++ b/packages/core/src/code_assist/types.ts @@ -295,6 +295,7 @@ export interface ConversationInteraction { status?: ActionStatus; interaction?: ConversationInteractionInteraction; acceptedLines?: string; + removedLines?: string; language?: string; isAgentic?: boolean; } diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index c297a20ef6..bb4123d0db 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -20,6 +20,7 @@ import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryT import { DEFAULT_TELEMETRY_TARGET, DEFAULT_OTLP_ENDPOINT, + uiTelemetryService, } from '../telemetry/index.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import { @@ -33,7 +34,10 @@ import { ShellTool } from '../tools/shell.js'; import { ReadFileTool } from '../tools/read-file.js'; import { GrepTool } from '../tools/grep.js'; import { RipGrepTool, canUseRipgrep } from '../tools/ripGrep.js'; -import { logRipgrepFallback } from '../telemetry/loggers.js'; +import { + logRipgrepFallback, + logApprovalModeDuration, +} from '../telemetry/loggers.js'; import { RipgrepFallbackEvent } from '../telemetry/types.js'; import { ToolRegistry } from '../tools/tool-registry.js'; import { ACTIVATE_SKILL_TOOL_NAME } from '../tools/tool-names.js'; @@ -130,6 +134,7 @@ vi.mock('../telemetry/loggers.js', async (importOriginal) => { return { ...actual, logRipgrepFallback: vi.fn(), + logApprovalModeDuration: vi.fn(), }; }); @@ -201,14 +206,15 @@ vi.mock('../services/contextManager.js', () => ({ import { BaseLlmClient } from '../core/baseLlmClient.js'; import { tokenLimit } from '../core/tokenLimits.js'; -import { uiTelemetryService } from '../telemetry/index.js'; import { getCodeAssistServer } from '../code_assist/codeAssist.js'; import { getExperiments } from '../code_assist/experiments/experiments.js'; import type { CodeAssistServer } from '../code_assist/server.js'; import { ContextManager } from '../services/contextManager.js'; import { UserTierId } from '../code_assist/types.js'; -import type { ModelConfigService } from '../services/modelConfigService.js'; -import type { ModelConfigServiceConfig } from '../services/modelConfigService.js'; +import type { + ModelConfigService, + ModelConfigServiceConfig, +} from '../services/modelConfigService.js'; import { ExitPlanModeTool } from '../tools/exit-plan-mode.js'; import { EnterPlanModeTool } from '../tools/enter-plan-mode.js'; @@ -737,6 +743,42 @@ describe('Server Config (config.ts)', () => { ); }); + describe('Plan Settings', () => { + const testCases = [ + { + name: 'should pass custom plan directory to storage', + planSettings: { directory: 'custom-plans' }, + expected: 'custom-plans', + }, + { + name: 'should call setCustomPlansDir with undefined if directory is not provided', + planSettings: {}, + expected: undefined, + }, + { + name: 'should call setCustomPlansDir with undefined if planSettings is not provided', + planSettings: undefined, + expected: undefined, + }, + ]; + + testCases.forEach(({ name, planSettings, expected }) => { + it(`${name}`, () => { + const setCustomPlansDirSpy = vi.spyOn( + Storage.prototype, + 'setCustomPlansDir', + ); + new Config({ + ...baseParams, + planSettings, + }); + + expect(setCustomPlansDirSpy).toHaveBeenCalledWith(expected); + setCustomPlansDirSpy.mockRestore(); + }); + }); + }); + describe('Telemetry Settings', () => { it('should return default telemetry target if not provided', () => { const params: ConfigParameters = { @@ -1356,7 +1398,22 @@ describe('setApprovalMode with folder trust', () => { expect(updateSpy).toHaveBeenCalled(); }); - it('should not update system instruction when switching between non-Plan modes', () => { + it('should update system instruction when entering YOLO mode', () => { + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + vi.spyOn(config, 'getToolRegistry').mockReturnValue({ + getTool: vi.fn().mockReturnValue(undefined), + unregisterTool: vi.fn(), + registerTool: vi.fn(), + } as Partial as ToolRegistry); + const updateSpy = vi.spyOn(config, 'updateSystemInstructionIfInitialized'); + + config.setApprovalMode(ApprovalMode.YOLO); + + expect(updateSpy).toHaveBeenCalled(); + }); + + it('should not update system instruction when switching between non-Plan/non-YOLO modes', () => { const config = new Config(baseParams); vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); const updateSpy = vi.spyOn(config, 'updateSystemInstructionIfInitialized'); @@ -1366,6 +1423,84 @@ describe('setApprovalMode with folder trust', () => { expect(updateSpy).not.toHaveBeenCalled(); }); + describe('approval mode duration logging', () => { + beforeEach(() => { + vi.mocked(logApprovalModeDuration).mockClear(); + }); + + it('should initialize lastModeSwitchTime with performance.now() and log positive duration', () => { + const startTime = 1000; + const endTime = 5000; + const performanceSpy = vi.spyOn(performance, 'now'); + + performanceSpy.mockReturnValueOnce(startTime); + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + + performanceSpy.mockReturnValueOnce(endTime); + config.setApprovalMode(ApprovalMode.PLAN); + + expect(logApprovalModeDuration).toHaveBeenCalledWith( + config, + expect.objectContaining({ + mode: ApprovalMode.DEFAULT, + duration_ms: endTime - startTime, + }), + ); + performanceSpy.mockRestore(); + }); + + it('should skip logging if duration is zero or negative', () => { + const startTime = 5000; + const endTime = 4000; + const performanceSpy = vi.spyOn(performance, 'now'); + + performanceSpy.mockReturnValueOnce(startTime); + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + + performanceSpy.mockReturnValueOnce(endTime); + config.setApprovalMode(ApprovalMode.PLAN); + + expect(logApprovalModeDuration).not.toHaveBeenCalled(); + performanceSpy.mockRestore(); + }); + + it('should update lastModeSwitchTime after logging to prevent double counting', () => { + const time1 = 1000; + const time2 = 3000; + const time3 = 6000; + const performanceSpy = vi.spyOn(performance, 'now'); + + performanceSpy.mockReturnValueOnce(time1); + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + + performanceSpy.mockReturnValueOnce(time2); + config.setApprovalMode(ApprovalMode.PLAN); + expect(logApprovalModeDuration).toHaveBeenCalledWith( + config, + expect.objectContaining({ + mode: ApprovalMode.DEFAULT, + duration_ms: time2 - time1, + }), + ); + + vi.mocked(logApprovalModeDuration).mockClear(); + + performanceSpy.mockReturnValueOnce(time3); + config.setApprovalMode(ApprovalMode.YOLO); + expect(logApprovalModeDuration).toHaveBeenCalledWith( + config, + expect.objectContaining({ + mode: ApprovalMode.PLAN, + duration_ms: time3 - time2, + }), + ); + performanceSpy.mockRestore(); + }); + }); + describe('registerCoreTools', () => { beforeEach(() => { vi.clearAllMocks(); @@ -2501,7 +2636,7 @@ describe('Plans Directory Initialization', () => { await config.initialize(); - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.storage.getPlansDir(); expect(fs.promises.mkdir).toHaveBeenCalledWith(plansDir, { recursive: true, }); @@ -2518,7 +2653,7 @@ describe('Plans Directory Initialization', () => { await config.initialize(); - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.storage.getPlansDir(); expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, { recursive: true, }); @@ -2613,6 +2748,27 @@ describe('syncPlanModeTools', () => { expect(registeredTool).toBeUndefined(); }); + it('should NOT register EnterPlanModeTool when in YOLO mode, even if plan is enabled', async () => { + const config = new Config({ + ...baseParams, + approvalMode: ApprovalMode.YOLO, + plan: true, + }); + const registry = new ToolRegistry(config, config.getMessageBus()); + vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry); + + const registerSpy = vi.spyOn(registry, 'registerTool'); + vi.spyOn(registry, 'getTool').mockReturnValue(undefined); + + config.syncPlanModeTools(); + + const { EnterPlanModeTool } = await import('../tools/enter-plan-mode.js'); + const registeredTool = registerSpy.mock.calls.find( + (call) => call[0] instanceof EnterPlanModeTool, + ); + expect(registeredTool).toBeUndefined(); + }); + it('should call geminiClient.setTools if initialized', async () => { const config = new Config(baseParams); const registry = new ToolRegistry(config, config.getMessageBus()); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index cffaee8178..fda8c61adf 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -68,7 +68,12 @@ import { ideContextStore } from '../ide/ideContext.js'; import { WriteTodosTool } from '../tools/write-todos.js'; import type { FileSystemService } from '../services/fileSystemService.js'; import { StandardFileSystemService } from '../services/fileSystemService.js'; -import { logRipgrepFallback, logFlashFallback } from '../telemetry/loggers.js'; +import { + logRipgrepFallback, + logFlashFallback, + logApprovalModeSwitch, + logApprovalModeDuration, +} from '../telemetry/loggers.js'; import { RipgrepFallbackEvent, FlashFallbackEvent, @@ -103,9 +108,11 @@ import type { EventEmitter } from 'node:events'; import { PolicyEngine } from '../policy/policy-engine.js'; import { ApprovalMode, type PolicyEngineConfig } from '../policy/types.js'; import { HookSystem } from '../hooks/index.js'; -import type { UserTierId } from '../code_assist/types.js'; -import type { RetrieveUserQuotaResponse } from '../code_assist/types.js'; -import type { AdminControlsSettings } from '../code_assist/types.js'; +import type { + UserTierId, + RetrieveUserQuotaResponse, + AdminControlsSettings, +} from '../code_assist/types.js'; import type { HierarchicalMemory } from './memory.js'; import { getCodeAssistServer } from '../code_assist/codeAssist.js'; import type { Experiments } from '../code_assist/experiments/experiments.js'; @@ -119,13 +126,11 @@ import { debugLogger } from '../utils/debugLogger.js'; import { SkillManager, type SkillDefinition } from '../skills/skillManager.js'; import { startupProfiler } from '../telemetry/startupProfiler.js'; import type { AgentDefinition } from '../agents/types.js'; -import { - logApprovalModeSwitch, - logApprovalModeDuration, -} from '../telemetry/loggers.js'; import { fetchAdminControls } from '../code_assist/admin/admin_controls.js'; import { isSubpath } from '../utils/paths.js'; import { UserHintService } from './userHintService.js'; +import { WORKSPACE_POLICY_TIER } from '../policy/config.js'; +import { loadPoliciesFromToml } from '../policy/toml-loader.js'; export interface AccessibilitySettings { /** @deprecated Use ui.loadingPhrases instead. */ @@ -141,6 +146,10 @@ export interface SummarizeToolOutputSettings { tokenBudget?: number; } +export interface PlanSettings { + directory?: string; +} + export interface TelemetrySettings { enabled?: boolean; target?: TelemetryTarget; @@ -375,6 +384,13 @@ export interface McpEnablementCallbacks { isFileEnabled: (serverId: string) => Promise; } +export interface PolicyUpdateConfirmationRequest { + scope: string; + identifier: string; + policyDir: string; + newHash: string; +} + export interface ConfigParameters { sessionId: string; clientVersion?: string; @@ -455,6 +471,7 @@ export interface ConfigParameters { eventEmitter?: EventEmitter; useWriteTodos?: boolean; policyEngineConfig?: PolicyEngineConfig; + policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest; output?: OutputSettings; disableModelRouterForAuth?: AuthType[]; continueOnFailedApiCall?: boolean; @@ -483,6 +500,7 @@ export interface ConfigParameters { toolOutputMasking?: Partial; disableLLMCorrection?: boolean; plan?: boolean; + planSettings?: PlanSettings; modelSteering?: boolean; useAgentFactoryAll?: boolean; useAgentFactorySdk?: boolean; @@ -636,6 +654,9 @@ export class Config { private readonly useWriteTodos: boolean; private readonly messageBus: MessageBus; private readonly policyEngine: PolicyEngine; + private policyUpdateConfirmationRequest: + | PolicyUpdateConfirmationRequest + | undefined; private readonly outputSettings: OutputSettings; private readonly continueOnFailedApiCall: boolean; private readonly retryFetchErrors: boolean; @@ -682,7 +703,7 @@ export class Config { private terminalBackground: string | undefined = undefined; private remoteAdminSettings: AdminControlsSettings | undefined; private latestApiRequest: GenerateContentParameters | undefined; - private lastModeSwitchTime: number = Date.now(); + private lastModeSwitchTime: number = performance.now(); readonly userHintService: UserHintService; private approvedPlanPath: string | undefined; @@ -851,6 +872,7 @@ export class Config { this.extensionManagement = params.extensionManagement ?? true; this.enableExtensionReloading = params.enableExtensionReloading ?? false; this.storage = new Storage(this.targetDir, this.sessionId); + this.storage.setCustomPlansDir(params.planSettings?.directory); this.fakeResponses = params.fakeResponses; this.recordResponses = params.recordResponses; @@ -862,6 +884,8 @@ export class Config { approvalMode: params.approvalMode ?? params.policyEngineConfig?.approvalMode, }); + this.policyUpdateConfirmationRequest = + params.policyUpdateConfirmationRequest; this.messageBus = new MessageBus(this.policyEngine, this.debugMode); this.acknowledgedAgentsService = new AcknowledgedAgentsService(); this.skillManager = new SkillManager(); @@ -964,7 +988,7 @@ export class Config { // Add plans directory to workspace context for plan file storage if (this.planEnabled) { - const plansDir = this.storage.getProjectTempPlansDir(); + const plansDir = this.storage.getPlansDir(); await fs.promises.mkdir(plansDir, { recursive: true }); this.workspaceContext.addDirectory(plansDir); } @@ -1065,6 +1089,12 @@ export class Config { // Reset availability status when switching auth (e.g. from limited key to OAuth) this.modelAvailabilityService.reset(); + // Clear stale authType to ensure getGemini31LaunchedSync doesn't return stale results + // during the transition. + if (this.contentGeneratorConfig) { + this.contentGeneratorConfig.authType = undefined; + } + const newContentGeneratorConfig = await createContentGeneratorConfig( this, authMethod, @@ -1344,7 +1374,10 @@ export class Config { if (pooled.remaining !== undefined) { return pooled.remaining; } - const primaryModel = resolveModel(this.getModel()); + const primaryModel = resolveModel( + this.getModel(), + this.getGemini31LaunchedSync(), + ); return this.modelQuotas.get(primaryModel)?.remaining; } @@ -1353,7 +1386,10 @@ export class Config { if (pooled.limit !== undefined) { return pooled.limit; } - const primaryModel = resolveModel(this.getModel()); + const primaryModel = resolveModel( + this.getModel(), + this.getGemini31LaunchedSync(), + ); return this.modelQuotas.get(primaryModel)?.limit; } @@ -1362,7 +1398,10 @@ export class Config { if (pooled.resetTime !== undefined) { return pooled.resetTime; } - const primaryModel = resolveModel(this.getModel()); + const primaryModel = resolveModel( + this.getModel(), + this.getGemini31LaunchedSync(), + ); return this.modelQuotas.get(primaryModel)?.resetTime; } @@ -1751,6 +1790,41 @@ export class Config { return this.policyEngine.getApprovalMode(); } + getPolicyUpdateConfirmationRequest(): + | PolicyUpdateConfirmationRequest + | undefined { + return this.policyUpdateConfirmationRequest; + } + + /** + * Hot-loads workspace policies from the specified directory into the active policy engine. + * This allows applying newly accepted policies without requiring an application restart. + * + * @param policyDir The directory containing the workspace policy TOML files. + */ + async loadWorkspacePolicies(policyDir: string): Promise { + const { rules, checkers } = await loadPoliciesFromToml( + [policyDir], + () => WORKSPACE_POLICY_TIER, + ); + + // Clear existing workspace policies to prevent duplicates/stale rules + this.policyEngine.removeRulesByTier(WORKSPACE_POLICY_TIER); + this.policyEngine.removeCheckersByTier(WORKSPACE_POLICY_TIER); + + for (const rule of rules) { + this.policyEngine.addRule(rule); + } + + for (const checker of checkers) { + this.policyEngine.addChecker(checker); + } + + this.policyUpdateConfirmationRequest = undefined; + + debugLogger.debug(`Workspace policies loaded from: ${policyDir}`); + } + setApprovalMode(mode: ApprovalMode): void { if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) { throw new Error( @@ -1760,12 +1834,11 @@ export class Config { const currentMode = this.getApprovalMode(); if (currentMode !== mode) { - this.logCurrentModeDuration(this.getApprovalMode()); + this.logCurrentModeDuration(currentMode); logApprovalModeSwitch( this, new ApprovalModeSwitchEvent(currentMode, mode), ); - this.lastModeSwitchTime = Date.now(); } this.policyEngine.setApprovalMode(mode); @@ -1773,7 +1846,11 @@ export class Config { const isPlanModeTransition = currentMode !== mode && (currentMode === ApprovalMode.PLAN || mode === ApprovalMode.PLAN); - if (isPlanModeTransition) { + const isYoloModeTransition = + currentMode !== mode && + (currentMode === ApprovalMode.YOLO || mode === ApprovalMode.YOLO); + + if (isPlanModeTransition || isYoloModeTransition) { this.syncPlanModeTools(); this.updateSystemInstructionIfInitialized(); } @@ -1783,8 +1860,13 @@ export class Config { * Synchronizes enter/exit plan mode tools based on current mode. */ syncPlanModeTools(): void { - const isPlanMode = this.getApprovalMode() === ApprovalMode.PLAN; const registry = this.getToolRegistry(); + if (!registry) { + return; + } + const approvalMode = this.getApprovalMode(); + const isPlanMode = approvalMode === ApprovalMode.PLAN; + const isYoloMode = approvalMode === ApprovalMode.YOLO; if (isPlanMode) { if (registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) { @@ -1797,7 +1879,7 @@ export class Config { if (registry.getTool(EXIT_PLAN_MODE_TOOL_NAME)) { registry.unregisterTool(EXIT_PLAN_MODE_TOOL_NAME); } - if (this.planEnabled) { + if (this.planEnabled && !isYoloMode) { if (!registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) { registry.registerTool(new EnterPlanModeTool(this, this.messageBus)); } @@ -1819,12 +1901,15 @@ export class Config { * Logs the duration of the current approval mode. */ logCurrentModeDuration(mode: ApprovalMode): void { - const now = Date.now(); + const now = performance.now(); const duration = now - this.lastModeSwitchTime; - logApprovalModeDuration( - this, - new ApprovalModeDurationEvent(mode, duration), - ); + if (duration > 0) { + logApprovalModeDuration( + this, + new ApprovalModeDurationEvent(mode, duration), + ); + } + this.lastModeSwitchTime = now; } isYoloModeDisabled(): boolean { @@ -2224,6 +2309,36 @@ export class Config { ); } + /** + * Returns whether Gemini 3.1 has been launched. + * This method is async and ensures that experiments are loaded before returning the result. + */ + async getGemini31Launched(): Promise { + await this.ensureExperimentsLoaded(); + return this.getGemini31LaunchedSync(); + } + + /** + * Returns whether Gemini 3.1 has been launched. + * + * Note: This method should only be called after startup, once experiments have been loaded. + * If you need to call this during startup or from an async context, use + * getGemini31Launched instead. + */ + getGemini31LaunchedSync(): boolean { + const authType = this.contentGeneratorConfig?.authType; + if ( + authType === AuthType.USE_GEMINI || + authType === AuthType.USE_VERTEX_AI + ) { + return true; + } + return ( + this.experiments?.flags[ExperimentFlags.GEMINI_3_1_PRO_LAUNCHED] + ?.boolValue ?? false + ); + } + private async ensureExperimentsLoaded(): Promise { if (!this.experimentsPromise) { return; diff --git a/packages/core/src/config/models.test.ts b/packages/core/src/config/models.test.ts index 2b2ddb1041..c16cf49781 100644 --- a/packages/core/src/config/models.test.ts +++ b/packages/core/src/config/models.test.ts @@ -25,8 +25,41 @@ import { PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO, + isActiveModel, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, + isPreviewModel, + isProModel, } from './models.js'; +describe('isPreviewModel', () => { + it('should return true for preview models', () => { + expect(isPreviewModel(PREVIEW_GEMINI_MODEL)).toBe(true); + expect(isPreviewModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(true); + expect(isPreviewModel(PREVIEW_GEMINI_FLASH_MODEL)).toBe(true); + expect(isPreviewModel(PREVIEW_GEMINI_MODEL_AUTO)).toBe(true); + }); + + it('should return false for non-preview models', () => { + expect(isPreviewModel(DEFAULT_GEMINI_MODEL)).toBe(false); + expect(isPreviewModel('gemini-1.5-pro')).toBe(false); + }); +}); + +describe('isProModel', () => { + it('should return true for models containing "pro"', () => { + expect(isProModel('gemini-3-pro-preview')).toBe(true); + expect(isProModel('gemini-2.5-pro')).toBe(true); + expect(isProModel('pro')).toBe(true); + }); + + it('should return false for models without "pro"', () => { + expect(isProModel('gemini-3-flash-preview')).toBe(false); + expect(isProModel('gemini-2.5-flash')).toBe(false); + expect(isProModel('auto')).toBe(false); + }); +}); + describe('isCustomModel', () => { it('should return true for models not starting with gemini-', () => { expect(isCustomModel('testing')).toBe(true); @@ -115,6 +148,12 @@ describe('getDisplayString', () => { ); }); + it('should return PREVIEW_GEMINI_3_1_MODEL for PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL', () => { + expect(getDisplayString(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL)).toBe( + PREVIEW_GEMINI_3_1_MODEL, + ); + }); + it('should return the model name as is for other models', () => { expect(getDisplayString('custom-model')).toBe('custom-model'); expect(getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL)).toBe( @@ -146,6 +185,16 @@ describe('resolveModel', () => { expect(model).toBe(PREVIEW_GEMINI_MODEL); }); + it('should return Gemini 3.1 Pro when auto-gemini-3 is requested and useGemini3_1 is true', () => { + const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true); + expect(model).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + + it('should return Gemini 3.1 Pro Custom Tools when auto-gemini-3 is requested, useGemini3_1 is true, and useCustomToolModel is true', () => { + const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, true); + expect(model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); + }); + it('should return the Default Pro model when auto-gemini-2.5 is requested', () => { const model = resolveModel(DEFAULT_GEMINI_MODEL_AUTO); expect(model).toBe(DEFAULT_GEMINI_MODEL); @@ -239,4 +288,71 @@ describe('resolveClassifierModel', () => { resolveClassifierModel(PREVIEW_GEMINI_MODEL_AUTO, GEMINI_MODEL_ALIAS_PRO), ).toBe(PREVIEW_GEMINI_MODEL); }); + + it('should return Gemini 3.1 Pro when alias is pro and useGemini3_1 is true', () => { + expect( + resolveClassifierModel( + PREVIEW_GEMINI_MODEL_AUTO, + GEMINI_MODEL_ALIAS_PRO, + true, + ), + ).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + + it('should return Gemini 3.1 Pro Custom Tools when alias is pro, useGemini3_1 is true, and useCustomToolModel is true', () => { + expect( + resolveClassifierModel( + PREVIEW_GEMINI_MODEL_AUTO, + GEMINI_MODEL_ALIAS_PRO, + true, + true, + ), + ).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); + }); +}); + +describe('isActiveModel', () => { + it('should return true for valid models when useGemini3_1 is false', () => { + expect(isActiveModel(DEFAULT_GEMINI_MODEL)).toBe(true); + expect(isActiveModel(PREVIEW_GEMINI_MODEL)).toBe(true); + expect(isActiveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true); + }); + + it('should return true for unknown models and aliases', () => { + expect(isActiveModel('invalid-model')).toBe(false); + expect(isActiveModel(GEMINI_MODEL_ALIAS_AUTO)).toBe(false); + }); + + it('should return false for PREVIEW_GEMINI_MODEL when useGemini3_1 is true', () => { + expect(isActiveModel(PREVIEW_GEMINI_MODEL, true)).toBe(false); + }); + + it('should return true for other valid models when useGemini3_1 is true', () => { + expect(isActiveModel(DEFAULT_GEMINI_MODEL, true)).toBe(true); + }); + + it('should correctly filter Gemini 3.1 models based on useCustomToolModel when useGemini3_1 is true', () => { + // When custom tools are preferred, standard 3.1 should be inactive + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, true)).toBe(false); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, true), + ).toBe(true); + + // When custom tools are NOT preferred, custom tools 3.1 should be inactive + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false)).toBe(true); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false), + ).toBe(false); + }); + + it('should return false for both Gemini 3.1 models when useGemini3_1 is false', () => { + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, true)).toBe(false); + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, false)).toBe(false); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, true), + ).toBe(false); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, false), + ).toBe(false); + }); }); diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index 9f12944333..d0ec49f005 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -5,6 +5,9 @@ */ export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview'; +export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview'; +export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL = + 'gemini-3.1-pro-preview-customtools'; export const PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview'; export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro'; export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash'; @@ -12,6 +15,8 @@ export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite'; export const VALID_GEMINI_MODELS = new Set([ PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL, @@ -37,20 +42,29 @@ export const DEFAULT_THINKING_MODE = 8192; * to a concrete model name. * * @param requestedModel The model alias or concrete model name requested by the user. + * @param useGemini3_1 Whether to use Gemini 3.1 Pro Preview for auto/pro aliases. * @returns The resolved concrete model name. */ -export function resolveModel(requestedModel: string): string { +export function resolveModel( + requestedModel: string, + useGemini3_1: boolean = false, + useCustomToolModel: boolean = false, +): string { switch (requestedModel) { - case PREVIEW_GEMINI_MODEL_AUTO: { + case PREVIEW_GEMINI_MODEL: + case PREVIEW_GEMINI_MODEL_AUTO: + case GEMINI_MODEL_ALIAS_AUTO: + case GEMINI_MODEL_ALIAS_PRO: { + if (useGemini3_1) { + return useCustomToolModel + ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL + : PREVIEW_GEMINI_3_1_MODEL; + } return PREVIEW_GEMINI_MODEL; } case DEFAULT_GEMINI_MODEL_AUTO: { return DEFAULT_GEMINI_MODEL; } - case GEMINI_MODEL_ALIAS_AUTO: - case GEMINI_MODEL_ALIAS_PRO: { - return PREVIEW_GEMINI_MODEL; - } case GEMINI_MODEL_ALIAS_FLASH: { return PREVIEW_GEMINI_FLASH_MODEL; } @@ -73,6 +87,8 @@ export function resolveModel(requestedModel: string): string { export function resolveClassifierModel( requestedModel: string, modelAlias: string, + useGemini3_1: boolean = false, + useCustomToolModel: boolean = false, ): string { if (modelAlias === GEMINI_MODEL_ALIAS_FLASH) { if ( @@ -89,7 +105,7 @@ export function resolveClassifierModel( } return resolveModel(GEMINI_MODEL_ALIAS_FLASH); } - return resolveModel(requestedModel); + return resolveModel(requestedModel, useGemini3_1, useCustomToolModel); } export function getDisplayString(model: string) { switch (model) { @@ -101,6 +117,8 @@ export function getDisplayString(model: string) { return PREVIEW_GEMINI_MODEL; case GEMINI_MODEL_ALIAS_FLASH: return PREVIEW_GEMINI_FLASH_MODEL; + case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL: + return PREVIEW_GEMINI_3_1_MODEL; default: return model; } @@ -115,11 +133,22 @@ export function getDisplayString(model: string) { export function isPreviewModel(model: string): boolean { return ( model === PREVIEW_GEMINI_MODEL || + model === PREVIEW_GEMINI_3_1_MODEL || model === PREVIEW_GEMINI_FLASH_MODEL || model === PREVIEW_GEMINI_MODEL_AUTO ); } +/** + * Checks if the model is a Pro model. + * + * @param model The model name to check. + * @returns True if the model is a Pro model. + */ +export function isProModel(model: string): boolean { + return model.toLowerCase().includes('pro'); +} + /** * Checks if the model is a Gemini 3 model. * @@ -188,3 +217,35 @@ export function isAutoModel(model: string): boolean { export function supportsMultimodalFunctionResponse(model: string): boolean { return model.startsWith('gemini-3-'); } + +/** + * Checks if the given model is considered active based on the current configuration. + * + * @param model The model name to check. + * @param useGemini3_1 Whether Gemini 3.1 Pro Preview is enabled. + * @returns True if the model is active. + */ +export function isActiveModel( + model: string, + useGemini3_1: boolean = false, + useCustomToolModel: boolean = false, +): boolean { + if (!VALID_GEMINI_MODELS.has(model)) { + return false; + } + if (useGemini3_1) { + if (model === PREVIEW_GEMINI_MODEL) { + return false; + } + if (useCustomToolModel) { + return model !== PREVIEW_GEMINI_3_1_MODEL; + } else { + return model !== PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL; + } + } else { + return ( + model !== PREVIEW_GEMINI_3_1_MODEL && + model !== PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL + ); + } +} diff --git a/packages/core/src/config/projectRegistry.ts b/packages/core/src/config/projectRegistry.ts index 225faedf9b..725ea081f9 100644 --- a/packages/core/src/config/projectRegistry.ts +++ b/packages/core/src/config/projectRegistry.ts @@ -59,6 +59,7 @@ export class ProjectRegistry { try { const content = await fs.promises.readFile(this.registryPath, 'utf8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(content); } catch (e) { debugLogger.debug('Failed to load registry: ', e); diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 8d91ca1a3e..15b49d12f1 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -12,12 +12,14 @@ vi.unmock('./storageMigration.js'); import * as os from 'node:os'; import * as path from 'node:path'; +import * as fs from 'node:fs'; vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, mkdirSync: vi.fn(), + realpathSync: vi.fn(actual.realpathSync), }; }); @@ -61,12 +63,11 @@ describe('Storage – initialize', () => { ).toHaveBeenCalledWith(projectRoot); // Verify migration calls - const shortId = 'project-slug'; // We can't easily get the hash here without repeating logic, but we can verify it's called twice expect(StorageMigration.migrateDirectory).toHaveBeenCalledTimes(2); // Verify identifier is set by checking a path - expect(storage.getProjectTempDir()).toContain(shortId); + expect(storage.getProjectTempDir()).toContain(PROJECT_SLUG); }); }); @@ -105,6 +106,12 @@ describe('Storage – additional helpers', () => { const projectRoot = '/tmp/project'; const storage = new Storage(projectRoot); + beforeEach(() => { + ProjectRegistry.prototype.getShortId = vi + .fn() + .mockReturnValue(PROJECT_SLUG); + }); + it('getWorkspaceSettingsPath returns project/.gemini/settings.json', () => { const expected = path.join(projectRoot, GEMINI_DIR, 'settings.json'); expect(storage.getWorkspaceSettingsPath()).toBe(expected); @@ -172,6 +179,181 @@ describe('Storage – additional helpers', () => { const expected = path.join(tempDir, sessionId, 'plans'); expect(storageWithSession.getProjectTempPlansDir()).toBe(expected); }); + + describe('Session and JSON Loading', () => { + beforeEach(async () => { + await storage.initialize(); + }); + + it('listProjectChatFiles returns sorted sessions from chats directory', async () => { + const readdirSpy = vi + .spyOn(fs.promises, 'readdir') + /* eslint-disable @typescript-eslint/no-explicit-any */ + .mockResolvedValue([ + 'session-1.json', + 'session-2.json', + 'not-a-session.txt', + ] as any); + + const statSpy = vi + .spyOn(fs.promises, 'stat') + .mockImplementation(async (p: any) => { + if (p.toString().endsWith('session-1.json')) { + return { + mtime: new Date('2026-02-01'), + mtimeMs: 1000, + } as any; + } + return { + mtime: new Date('2026-02-02'), + mtimeMs: 2000, + } as any; + }); + /* eslint-enable @typescript-eslint/no-explicit-any */ + + const sessions = await storage.listProjectChatFiles(); + + expect(readdirSpy).toHaveBeenCalledWith(expect.stringContaining('chats')); + expect(sessions).toHaveLength(2); + // Sorted by mtime desc + expect(sessions[0].filePath).toBe(path.join('chats', 'session-2.json')); + expect(sessions[1].filePath).toBe(path.join('chats', 'session-1.json')); + expect(sessions[0].lastUpdated).toBe( + new Date('2026-02-02').toISOString(), + ); + + readdirSpy.mockRestore(); + statSpy.mockRestore(); + }); + + it('loadProjectTempFile loads and parses JSON from relative path', async () => { + const readFileSpy = vi + .spyOn(fs.promises, 'readFile') + .mockResolvedValue(JSON.stringify({ hello: 'world' })); + + const result = await storage.loadProjectTempFile<{ hello: string }>( + 'some/file.json', + ); + + expect(readFileSpy).toHaveBeenCalledWith( + expect.stringContaining(path.join(PROJECT_SLUG, 'some/file.json')), + 'utf8', + ); + expect(result).toEqual({ hello: 'world' }); + + readFileSpy.mockRestore(); + }); + + it('loadProjectTempFile returns null if file does not exist', async () => { + const error = new Error('File not found'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (error as any).code = 'ENOENT'; + const readFileSpy = vi + .spyOn(fs.promises, 'readFile') + .mockRejectedValue(error); + + const result = await storage.loadProjectTempFile('missing.json'); + + expect(result).toBeNull(); + + readFileSpy.mockRestore(); + }); + }); + + describe('getPlansDir', () => { + interface TestCase { + name: string; + customDir: string | undefined; + expected: string | (() => string); + expectedError?: string; + setup?: () => () => void; + } + + const testCases: TestCase[] = [ + { + name: 'custom relative path', + customDir: '.my-plans', + expected: path.resolve(projectRoot, '.my-plans'), + }, + { + name: 'custom absolute path outside throws', + customDir: '/absolute/path/to/plans', + expected: '', + expectedError: + "Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root '/tmp/project'.", + }, + { + name: 'absolute path that happens to be inside project root', + customDir: path.join(projectRoot, 'internal-plans'), + expected: path.join(projectRoot, 'internal-plans'), + }, + { + name: 'relative path that stays within project root', + customDir: 'subdir/../plans', + expected: path.resolve(projectRoot, 'plans'), + }, + { + name: 'dot path', + customDir: '.', + expected: projectRoot, + }, + { + name: 'default behavior when customDir is undefined', + customDir: undefined, + expected: () => storage.getProjectTempPlansDir(), + }, + { + name: 'escaping relative path throws', + customDir: '../escaped-plans', + expected: '', + expectedError: + "Custom plans directory '../escaped-plans' resolves to '/tmp/escaped-plans', which is outside the project root '/tmp/project'.", + }, + { + name: 'hidden directory starting with ..', + customDir: '..plans', + expected: path.resolve(projectRoot, '..plans'), + }, + { + name: 'security escape via symbolic link throws', + customDir: 'symlink-to-outside', + setup: () => { + vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => { + if (p.toString().includes('symlink-to-outside')) { + return '/outside/project/root'; + } + return p.toString(); + }); + return () => vi.mocked(fs.realpathSync).mockRestore(); + }, + expected: '', + expectedError: + "Custom plans directory 'symlink-to-outside' resolves to '/outside/project/root', which is outside the project root '/tmp/project'.", + }, + ]; + + testCases.forEach(({ name, customDir, expected, expectedError, setup }) => { + it(`should handle ${name}`, async () => { + const cleanup = setup?.(); + try { + if (name.includes('default behavior')) { + await storage.initialize(); + } + + storage.setCustomPlansDir(customDir); + if (expectedError) { + expect(() => storage.getPlansDir()).toThrow(expectedError); + } else { + const expectedValue = + typeof expected === 'function' ? expected() : expected; + expect(storage.getPlansDir()).toBe(expectedValue); + } + } finally { + cleanup?.(); + } + }); + }); + }); }); describe('Storage - System Paths', () => { diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 2d7f5d8c2a..3099f39d1e 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -12,6 +12,8 @@ import { GEMINI_DIR, homedir, GOOGLE_ACCOUNTS_FILENAME, + isSubpath, + resolveToRealPath, } from '../utils/paths.js'; import { ProjectRegistry } from './projectRegistry.js'; import { StorageMigration } from './storageMigration.js'; @@ -21,17 +23,24 @@ const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; const AGENTS_DIR_NAME = '.agents'; +export const AUTO_SAVED_POLICY_FILENAME = 'auto-saved.toml'; + export class Storage { private readonly targetDir: string; private readonly sessionId: string | undefined; private projectIdentifier: string | undefined; private initPromise: Promise | undefined; + private customPlansDir: string | undefined; constructor(targetDir: string, sessionId?: string) { this.targetDir = targetDir; this.sessionId = sessionId; } + setCustomPlansDir(dir: string | undefined): void { + this.customPlansDir = dir; + } + static getGlobalGeminiDir(): string { const homeDir = homedir(); if (!homeDir) { @@ -96,6 +105,10 @@ export class Storage { ); } + static getPolicyIntegrityStoragePath(): string { + return path.join(Storage.getGlobalGeminiDir(), 'policy_integrity.json'); + } + private static getSystemConfigDir(): string { if (os.platform() === 'darwin') { return '/Library/Application Support/GeminiCli'; @@ -139,6 +152,17 @@ export class Storage { return path.join(tempDir, identifier); } + getWorkspacePoliciesDir(): string { + return path.join(this.getGeminiDir(), 'policies'); + } + + getAutoSavedPolicyPath(): string { + return path.join( + this.getWorkspacePoliciesDir(), + AUTO_SAVED_POLICY_FILENAME, + ); + } + ensureProjectTempDirExists(): void { fs.mkdirSync(this.getProjectTempDir(), { recursive: true }); } @@ -253,6 +277,26 @@ export class Storage { return path.join(this.getProjectTempDir(), 'plans'); } + getPlansDir(): string { + if (this.customPlansDir) { + const resolvedPath = path.resolve( + this.getProjectRoot(), + this.customPlansDir, + ); + const realProjectRoot = resolveToRealPath(this.getProjectRoot()); + const realResolvedPath = resolveToRealPath(resolvedPath); + + if (!isSubpath(realProjectRoot, realResolvedPath)) { + throw new Error( + `Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`, + ); + } + + return resolvedPath; + } + return this.getProjectTempPlansDir(); + } + getProjectTempTasksDir(): string { if (this.sessionId) { return path.join(this.getProjectTempDir(), this.sessionId, 'tasks'); @@ -260,6 +304,63 @@ export class Storage { return path.join(this.getProjectTempDir(), 'tasks'); } + async listProjectChatFiles(): Promise< + Array<{ filePath: string; lastUpdated: string }> + > { + const chatsDir = path.join(this.getProjectTempDir(), 'chats'); + try { + const files = await fs.promises.readdir(chatsDir); + const jsonFiles = files.filter((f) => f.endsWith('.json')); + + const sessions = await Promise.all( + jsonFiles.map(async (file) => { + const absolutePath = path.join(chatsDir, file); + const stats = await fs.promises.stat(absolutePath); + return { + filePath: path.join('chats', file), + lastUpdated: stats.mtime.toISOString(), + mtimeMs: stats.mtimeMs, + }; + }), + ); + + return sessions + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .map(({ filePath, lastUpdated }) => ({ filePath, lastUpdated })); + } catch (e) { + // If directory doesn't exist, return empty + if ( + e instanceof Error && + 'code' in e && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (e as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return []; + } + throw e; + } + } + + async loadProjectTempFile(filePath: string): Promise { + const absolutePath = path.join(this.getProjectTempDir(), filePath); + try { + const content = await fs.promises.readFile(absolutePath, 'utf8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return JSON.parse(content) as T; + } catch (e) { + // If file doesn't exist, return null + if ( + e instanceof Error && + 'code' in e && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (e as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return null; + } + throw e; + } + } + getExtensionsDir(): string { return path.join(this.getGeminiDir(), 'extensions'); } diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index bf73399537..0028a052de 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -549,7 +549,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -705,7 +705,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -827,7 +827,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -1422,7 +1422,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -1574,7 +1574,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -1717,7 +1717,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -1860,7 +1860,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -1999,7 +1999,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -2138,7 +2138,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -2276,7 +2276,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -2656,7 +2656,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -2795,7 +2795,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -3046,7 +3046,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -3185,7 +3185,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. diff --git a/packages/core/src/core/baseLlmClient.test.ts b/packages/core/src/core/baseLlmClient.test.ts index 4d09a1edd9..d067ec49ef 100644 --- a/packages/core/src/core/baseLlmClient.test.ts +++ b/packages/core/src/core/baseLlmClient.test.ts @@ -15,22 +15,24 @@ import { type Mock, } from 'vitest'; -import { BaseLlmClient, type GenerateJsonOptions } from './baseLlmClient.js'; +import type { + GenerateContentOptions, + GenerateJsonOptions, +} from './baseLlmClient.js'; +import { BaseLlmClient } from './baseLlmClient.js'; import type { ContentGenerator } from './contentGenerator.js'; import type { ModelAvailabilityService } from '../availability/modelAvailabilityService.js'; import { createAvailabilityServiceMock } from '../availability/testUtils.js'; -import type { GenerateContentOptions } from './baseLlmClient.js'; import type { GenerateContentResponse } from '@google/genai'; import type { Config } from '../config/config.js'; import { AuthType } from './contentGenerator.js'; import { reportError } from '../utils/errorReporting.js'; import { logMalformedJsonResponse } from '../telemetry/loggers.js'; import { retryWithBackoff } from '../utils/retry.js'; -import { MalformedJsonResponseEvent } from '../telemetry/types.js'; +import { MalformedJsonResponseEvent, LlmRole } from '../telemetry/types.js'; import { getErrorMessage } from '../utils/errors.js'; import type { ModelConfigService } from '../services/modelConfigService.js'; import { makeResolvedModelConfig } from '../services/modelConfigServiceTestUtils.js'; -import { LlmRole } from '../telemetry/types.js'; vi.mock('../utils/errorReporting.js'); vi.mock('../telemetry/loggers.js'); diff --git a/packages/core/src/core/baseLlmClient.ts b/packages/core/src/core/baseLlmClient.ts index 64730ff74c..64442ac86e 100644 --- a/packages/core/src/core/baseLlmClient.ts +++ b/packages/core/src/core/baseLlmClient.ts @@ -13,21 +13,19 @@ import type { GenerateContentConfig, } from '@google/genai'; import type { Config } from '../config/config.js'; -import type { ContentGenerator } from './contentGenerator.js'; -import type { AuthType } from './contentGenerator.js'; +import type { ContentGenerator, AuthType } from './contentGenerator.js'; import { handleFallback } from '../fallback/handler.js'; import { getResponseText } from '../utils/partUtils.js'; import { reportError } from '../utils/errorReporting.js'; import { getErrorMessage } from '../utils/errors.js'; import { logMalformedJsonResponse } from '../telemetry/loggers.js'; -import { MalformedJsonResponseEvent } from '../telemetry/types.js'; +import { MalformedJsonResponseEvent, LlmRole } from '../telemetry/types.js'; import { retryWithBackoff } from '../utils/retry.js'; import type { ModelConfigKey } from '../services/modelConfigService.js'; import { applyModelSelection, createAvailabilityContextProvider, } from '../availability/policyHelpers.js'; -import { LlmRole } from '../telemetry/types.js'; const DEFAULT_MAX_ATTEMPTS = 5; @@ -164,6 +162,7 @@ export class BaseLlmClient { ); // If we are here, the content is valid (not empty and parsable). + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse( this.cleanJsonResponse(getResponseText(result)!.trim(), model), ); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 0951eb397b..56447468bd 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -17,8 +17,7 @@ import { getInitialChatHistory, } from '../utils/environmentContext.js'; import type { ServerGeminiStreamEvent, ChatCompressionInfo } from './turn.js'; -import { CompressionStatus } from './turn.js'; -import { Turn, GeminiEventType } from './turn.js'; +import { CompressionStatus, Turn, GeminiEventType } from './turn.js'; import type { Config } from '../config/config.js'; import { getCoreSystemPrompt } from './prompts.js'; import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js'; @@ -542,7 +541,10 @@ export class GeminiClient { // Availability logic: The configured model is the source of truth, // including any permanent fallbacks (config.setModel) or manual overrides. - return resolveModel(this.config.getActiveModel()); + return resolveModel( + this.config.getActiveModel(), + this.config.getGemini31LaunchedSync?.() ?? false, + ); } private async *processTurn( diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index bfd8221f75..7adae874aa 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -146,7 +146,12 @@ export async function createContentGenerator( return new LoggingContentGenerator(fakeGenerator, gcConfig); } const version = await getVersion(); - const model = resolveModel(gcConfig.getModel()); + const model = resolveModel( + gcConfig.getModel(), + config.authType === AuthType.USE_GEMINI || + config.authType === AuthType.USE_VERTEX_AI || + ((await gcConfig.getGemini31Launched?.()) ?? false), + ); const customHeadersEnv = process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined; const userAgent = `GeminiCLI/${version}/${model} (${process.platform}; ${process.arch})`; diff --git a/packages/core/src/core/coreToolHookTriggers.ts b/packages/core/src/core/coreToolHookTriggers.ts index 0ed947623c..cb98d3af20 100644 --- a/packages/core/src/core/coreToolHookTriggers.ts +++ b/packages/core/src/core/coreToolHookTriggers.ts @@ -6,11 +6,14 @@ import { type McpToolContext, BeforeToolHookOutput } from '../hooks/types.js'; import type { Config } from '../config/config.js'; -import type { ToolResult, AnyDeclarativeTool } from '../tools/tools.js'; +import type { + ToolResult, + AnyDeclarativeTool, + AnyToolInvocation, +} from '../tools/tools.js'; import { ToolErrorType } from '../tools/tool-error.js'; import { debugLogger } from '../utils/debugLogger.js'; import type { AnsiOutput, ShellExecutionConfig } from '../index.js'; -import type { AnyToolInvocation } from '../tools/tools.js'; import { ShellToolInvocation } from '../tools/shell.js'; import { DiscoveredMCPToolInvocation } from '../tools/mcp-tool.js'; diff --git a/packages/core/src/core/fakeContentGenerator.ts b/packages/core/src/core/fakeContentGenerator.ts index 5bedc2d187..c765bde087 100644 --- a/packages/core/src/core/fakeContentGenerator.ts +++ b/packages/core/src/core/fakeContentGenerator.ts @@ -83,6 +83,7 @@ export class FakeContentGenerator implements ContentGenerator { // eslint-disable-next-line @typescript-eslint/no-unused-vars role: LlmRole, ): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return Object.setPrototypeOf( this.getNextResponse('generateContent', request), GenerateContentResponse.prototype, @@ -116,6 +117,7 @@ export class FakeContentGenerator implements ContentGenerator { async embedContent( request: EmbedContentParameters, ): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return Object.setPrototypeOf( this.getNextResponse('embedContent', request), EmbedContentResponse.prototype, diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 6b1ede738c..c9cb6cf8f2 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -249,10 +249,11 @@ export class GeminiChat { private history: Content[] = [], resumedSessionData?: ResumedSessionData, private readonly onModelChanged?: (modelId: string) => Promise, + kind: 'main' | 'subagent' = 'main', ) { validateHistory(history); this.chatRecordingService = new ChatRecordingService(config); - this.chatRecordingService.initialize(resumedSessionData); + this.chatRecordingService.initialize(resumedSessionData, kind); this.lastPromptTokenCount = estimateTokenCountSync( this.history.flatMap((c) => c.parts || []), ); @@ -496,13 +497,14 @@ export class GeminiChat { const initialActiveModel = this.config.getActiveModel(); const apiCall = async () => { + const useGemini3_1 = (await this.config.getGemini31Launched?.()) ?? false; // Default to the last used model (which respects arguments/availability selection) - let modelToUse = resolveModel(lastModelToUse); + let modelToUse = resolveModel(lastModelToUse, useGemini3_1); // If the active model has changed (e.g. due to a fallback updating the config), // we switch to the new active model. if (this.config.getActiveModel() !== initialActiveModel) { - modelToUse = resolveModel(this.config.getActiveModel()); + modelToUse = resolveModel(this.config.getActiveModel(), useGemini3_1); } if (modelToUse !== lastModelToUse) { diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts index 83f4183ce4..362601f895 100644 --- a/packages/core/src/core/logger.ts +++ b/packages/core/src/core/logger.ts @@ -88,6 +88,7 @@ export class Logger { } try { const fileContent = await fs.readFile(this.logFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedLogs = JSON.parse(fileContent); if (!Array.isArray(parsedLogs)) { debugLogger.debug( @@ -352,6 +353,7 @@ export class Logger { const path = await this._getCheckpointPath(tag); try { const fileContent = await fs.readFile(path, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedContent = JSON.parse(fileContent); // Handle legacy format (just an array of Content) diff --git a/packages/core/src/core/loggingContentGenerator.test.ts b/packages/core/src/core/loggingContentGenerator.test.ts index dd354fa16f..45e5028553 100644 --- a/packages/core/src/core/loggingContentGenerator.test.ts +++ b/packages/core/src/core/loggingContentGenerator.test.ts @@ -32,6 +32,7 @@ import { LoggingContentGenerator } from './loggingContentGenerator.js'; import type { Config } from '../config/config.js'; import { UserTierId } from '../code_assist/types.js'; import { ApiRequestEvent, LlmRole } from '../telemetry/types.js'; +import { FatalAuthenticationError } from '../utils/errors.js'; describe('LoggingContentGenerator', () => { let wrapped: ContentGenerator; @@ -137,6 +138,19 @@ describe('LoggingContentGenerator', () => { const errorEvent = vi.mocked(logApiError).mock.calls[0][1]; expect(errorEvent.duration_ms).toBe(1000); }); + + describe('error type extraction', () => { + it('should extract error type correctly', async () => { + const req = { contents: [], model: 'm' }; + const error = new FatalAuthenticationError('test'); + vi.mocked(wrapped.generateContent).mockRejectedValue(error); + await expect( + loggingContentGenerator.generateContent(req, 'id', LlmRole.MAIN), + ).rejects.toThrow(); + const errorEvent = vi.mocked(logApiError).mock.calls[0][1]; + expect(errorEvent.error_type).toBe('FatalAuthenticationError'); + }); + }); }); describe('generateContentStream', () => { diff --git a/packages/core/src/core/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator.ts index 12a1722475..1544087ae0 100644 --- a/packages/core/src/core/loggingContentGenerator.ts +++ b/packages/core/src/core/loggingContentGenerator.ts @@ -36,6 +36,7 @@ import { toContents } from '../code_assist/converter.js'; import { isStructuredError } from '../utils/quotaErrorDetection.js'; import { runInDevTraceSpan, type SpanMetadata } from '../telemetry/trace.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { getErrorType } from '../utils/errors.js'; interface StructuredError { status: number; @@ -167,7 +168,7 @@ export class LoggingContentGenerator implements ContentGenerator { serverDetails?: ServerDetails, ): void { const errorMessage = error instanceof Error ? error.message : String(error); - const errorType = error instanceof Error ? error.name : 'unknown'; + const errorType = getErrorType(error); logApiError( this.config, diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index ce6f383009..0cee2f8ae4 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -89,9 +89,7 @@ describe('Core System Prompt (prompts.ts)', () => { getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), - getProjectTempPlansDir: vi - .fn() - .mockReturnValue('/tmp/project-temp/plans'), + getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'), }, isInteractive: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true), @@ -509,9 +507,7 @@ describe('Core System Prompt (prompts.ts)', () => { vi.mocked(mockConfig.getApprovalMode).mockReturnValue( ApprovalMode.PLAN, ); - vi.mocked(mockConfig.storage.getProjectTempPlansDir).mockReturnValue( - '/tmp/plans', - ); + vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue('/tmp/plans'); }); it('should include approved plan path when set in config', () => { diff --git a/packages/core/src/hooks/hookAggregator.ts b/packages/core/src/hooks/hookAggregator.ts index b8a280cca1..5cd53e8c6e 100644 --- a/packages/core/src/hooks/hookAggregator.ts +++ b/packages/core/src/hooks/hookAggregator.ts @@ -17,8 +17,8 @@ import { BeforeToolSelectionHookOutput, AfterModelHookOutput, AfterAgentHookOutput, + HookEventName, } from './types.js'; -import { HookEventName } from './types.js'; /** * Aggregated hook result diff --git a/packages/core/src/hooks/hookEventHandler.test.ts b/packages/core/src/hooks/hookEventHandler.test.ts index b9ae878e76..9a07d39672 100644 --- a/packages/core/src/hooks/hookEventHandler.test.ts +++ b/packages/core/src/hooks/hookEventHandler.test.ts @@ -11,16 +11,16 @@ import type { import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HookEventHandler } from './hookEventHandler.js'; import type { Config } from '../config/config.js'; -import type { HookConfig } from './types.js'; -import type { HookPlanner } from './hookPlanner.js'; -import type { HookRunner } from './hookRunner.js'; -import type { HookAggregator } from './hookAggregator.js'; -import { HookEventName, HookType } from './types.js'; +import type { HookConfig, HookExecutionResult } from './types.js'; import { NotificationType, SessionStartSource, - type HookExecutionResult, + HookEventName, + HookType, } from './types.js'; +import type { HookPlanner } from './hookPlanner.js'; +import type { HookRunner } from './hookRunner.js'; +import type { HookAggregator } from './hookAggregator.js'; // Mock debugLogger const mockDebugLogger = vi.hoisted(() => ({ diff --git a/packages/core/src/hooks/hookPlanner.ts b/packages/core/src/hooks/hookPlanner.ts index 92701c4a42..3e016efe23 100644 --- a/packages/core/src/hooks/hookPlanner.ts +++ b/packages/core/src/hooks/hookPlanner.ts @@ -5,8 +5,8 @@ */ import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js'; -import type { HookExecutionPlan } from './types.js'; -import { getHookKey, type HookEventName } from './types.js'; +import type { HookExecutionPlan, HookEventName } from './types.js'; +import { getHookKey } from './types.js'; import { debugLogger } from '../utils/debugLogger.js'; /** diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index 5bc671b088..ca88b9411e 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -8,8 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { HookRunner } from './hookRunner.js'; import { HookEventName, HookType, ConfigSource } from './types.js'; -import type { HookConfig } from './types.js'; -import type { HookInput } from './types.js'; +import type { HookConfig, HookInput } from './types.js'; import type { Readable, Writable } from 'node:stream'; import type { Config } from '../config/config.js'; diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 47706b0eb4..05f81d1983 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -5,10 +5,8 @@ */ import { spawn } from 'node:child_process'; -import type { HookConfig } from './types.js'; -import { HookEventName, ConfigSource } from './types.js'; -import type { Config } from '../config/config.js'; import type { + HookConfig, HookInput, HookOutput, HookExecutionResult, @@ -17,6 +15,8 @@ import type { BeforeModelOutput, BeforeToolInput, } from './types.js'; +import { HookEventName, ConfigSource } from './types.js'; +import type { Config } from '../config/config.js'; import type { LLMRequest } from './hookTranslator.js'; import { debugLogger } from '../utils/debugLogger.js'; import { sanitizeEnvironment } from '../services/environmentSanitization.js'; @@ -356,8 +356,10 @@ export class HookRunner { const textToParse = stdout.trim() || stderr.trim(); if (textToParse) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment let parsed = JSON.parse(textToParse); if (typeof parsed === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment parsed = JSON.parse(parsed); } if (parsed && typeof parsed === 'object') { diff --git a/packages/core/src/hooks/trustedHooks.ts b/packages/core/src/hooks/trustedHooks.ts index 1c9b5b5f18..562f0e76bb 100644 --- a/packages/core/src/hooks/trustedHooks.ts +++ b/packages/core/src/hooks/trustedHooks.ts @@ -34,6 +34,7 @@ export class TrustedHooksManager { try { if (fs.existsSync(this.configPath)) { const content = fs.readFileSync(this.configPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this.trustedHooks = JSON.parse(content); } } catch (error) { diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 58067d70ab..373df31f5f 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -16,8 +16,10 @@ import { getIdeProcessInfo } from './process-utils.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; -import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolResultSchema, + ListToolsResultSchema, +} from '@modelcontextprotocol/sdk/types.js'; import { IDE_REQUEST_TIMEOUT_MS } from './constants.js'; import { debugLogger } from '../utils/debugLogger.js'; import { @@ -343,8 +345,10 @@ export class IdeClient { if (textPart?.text) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedJson = JSON.parse(textPart.text); if (parsedJson && typeof parsedJson.content === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return parsedJson.content; } if (parsedJson && parsedJson.content === null) { diff --git a/packages/core/src/ide/ide-connection-utils.ts b/packages/core/src/ide/ide-connection-utils.ts index ce01b5997c..c9776e1509 100644 --- a/packages/core/src/ide/ide-connection-utils.ts +++ b/packages/core/src/ide/ide-connection-utils.ts @@ -89,8 +89,10 @@ export function getStdioConfigFromEnv(): StdioConfig | undefined { let args: string[] = []; if (argsStr) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedArgs = JSON.parse(argsStr); if (Array.isArray(parsedArgs)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment args = parsedArgs; } else { logger.error( @@ -121,6 +123,7 @@ export async function getConnectionConfigFromFile( `gemini-ide-server-${pid}.json`, ); const portFileContents = await fs.promises.readFile(portFile, 'utf8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(portFileContents); } catch (_) { // For newer extension versions, the file name matches the pattern @@ -165,6 +168,7 @@ export async function getConnectionConfigFromFile( } const parsedContents = fileContents.map((content) => { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(content); } catch (e) { logger.debug('Failed to parse JSON from config file: ', e); @@ -188,11 +192,13 @@ export async function getConnectionConfigFromFile( } if (validWorkspaces.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const selected = validWorkspaces[0]; const fileIndex = parsedContents.indexOf(selected); if (fileIndex !== -1) { logger.debug(`Selected IDE connection file: ${matchingFiles[fileIndex]}`); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return selected; } @@ -202,6 +208,7 @@ export async function getConnectionConfigFromFile( (content) => String(content.port) === portFromEnv, ); if (matchingPortIndex !== -1) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const selected = validWorkspaces[matchingPortIndex]; const fileIndex = parsedContents.indexOf(selected); if (fileIndex !== -1) { @@ -209,10 +216,12 @@ export async function getConnectionConfigFromFile( `Selected IDE connection file (matched port from env): ${matchingFiles[fileIndex]}`, ); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return selected; } } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const selected = validWorkspaces[0]; const fileIndex = parsedContents.indexOf(selected); if (fileIndex !== -1) { @@ -220,6 +229,7 @@ export async function getConnectionConfigFromFile( `Selected first valid IDE connection file: ${matchingFiles[fileIndex]}`, ); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return selected; } diff --git a/packages/core/src/ide/ide-installer.test.ts b/packages/core/src/ide/ide-installer.test.ts index e35cb3280f..0347fd892f 100644 --- a/packages/core/src/ide/ide-installer.test.ts +++ b/packages/core/src/ide/ide-installer.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); @@ -24,7 +24,6 @@ vi.mock('../utils/paths.js', async (importOriginal) => { }; }); -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { getIdeInstaller } from './ide-installer.js'; import * as child_process from 'node:child_process'; import * as fs from 'node:fs'; diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index 5c1ca570a6..4b4680df51 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -47,6 +47,7 @@ async function getProcessTableWindows(): Promise> { let processes: RawProcessInfo | RawProcessInfo[]; try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment processes = JSON.parse(stdout); } catch (_e) { return processMap; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 159b8e3e9f..b353a4eb6a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,7 @@ export * from './policy/types.js'; export * from './policy/policy-engine.js'; export * from './policy/toml-loader.js'; export * from './policy/config.js'; +export * from './policy/integrity.js'; export * from './confirmation-bus/types.js'; export * from './confirmation-bus/message-bus.js'; @@ -111,6 +112,7 @@ export * from './utils/sessionUtils.js'; // Export services export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; +export * from './services/FolderTrustDiscoveryService.js'; export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; export * from './services/sessionSummaryUtils.js'; @@ -181,7 +183,7 @@ export { OAuthUtils } from './mcp/oauth-utils.js'; // Export telemetry functions export * from './telemetry/index.js'; -export { sessionId } from './utils/session.js'; +export { sessionId, createSessionId } from './utils/session.js'; export * from './utils/compatibility.js'; export * from './utils/browser.js'; export { Storage } from './config/storage.js'; diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 5aa90292aa..77c46305a6 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; // Mock dependencies AT THE TOP const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn()); @@ -36,8 +36,24 @@ vi.mock('../utils/events.js', () => ({ vi.mock('../utils/authConsent.js', () => ({ getConsentForOauth: vi.fn(() => Promise.resolve(true)), })); +vi.mock('../utils/headless.js', () => ({ + isHeadlessMode: vi.fn(() => false), +})); +vi.mock('node:readline', () => ({ + default: { + createInterface: vi.fn(() => ({ + question: vi.fn((_query, callback) => callback('')), + close: vi.fn(), + on: vi.fn(), + })), + }, + createInterface: vi.fn(() => ({ + question: vi.fn((_query, callback) => callback('')), + close: vi.fn(), + on: vi.fn(), + })), +})); -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as http from 'node:http'; import * as crypto from 'node:crypto'; import type { diff --git a/packages/core/src/mcp/oauth-utils.ts b/packages/core/src/mcp/oauth-utils.ts index 815ed7c089..320c3b9685 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -409,6 +409,7 @@ export class OAuthUtils { */ static parseTokenExpiry(idToken: string): number | undefined { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const payload = JSON.parse( Buffer.from(idToken.split('.')[1], 'base64').toString(), ); diff --git a/packages/core/src/mcp/token-storage/keychain-token-storage.ts b/packages/core/src/mcp/token-storage/keychain-token-storage.ts index 8936823196..4be0d082e5 100644 --- a/packages/core/src/mcp/token-storage/keychain-token-storage.ts +++ b/packages/core/src/mcp/token-storage/keychain-token-storage.ts @@ -45,7 +45,9 @@ export class KeychainTokenStorage try { // Try to import keytar without any timeout - let the OS handle it const moduleName = 'keytar'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const module = await import(moduleName); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this.keytarModule = module.default || module; } catch (_) { //Keytar is optional so we shouldn't raise an error of log anything. diff --git a/packages/core/src/policy/config.test.ts b/packages/core/src/policy/config.test.ts index 32a5287113..a9fae7a1fa 100644 --- a/packages/core/src/policy/config.test.ts +++ b/packages/core/src/policy/config.test.ts @@ -169,7 +169,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow + expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow }); it('should deny tools in tools.exclude', async () => { @@ -188,7 +188,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.DENY, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(2.4, 5); // Command line exclude + expect(rule?.priority).toBeCloseTo(3.4, 5); // Command line exclude }); it('should allow tools from allowed MCP servers', async () => { @@ -206,7 +206,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(2.1); // MCP allowed server + expect(rule?.priority).toBe(3.1); // MCP allowed server }); it('should deny tools from excluded MCP servers', async () => { @@ -224,7 +224,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(2.9); // MCP excluded server + expect(rule?.priority).toBe(3.9); // MCP excluded server }); it('should allow tools from trusted MCP servers', async () => { @@ -251,7 +251,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(2.2); // MCP trusted server + expect(trustedRule?.priority).toBe(3.2); // MCP trusted server // Untrusted server should not have an allow rule const untrustedRule = config.rules?.find( @@ -288,7 +288,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(allowedRule).toBeDefined(); - expect(allowedRule?.priority).toBe(2.1); // MCP allowed server + expect(allowedRule?.priority).toBe(3.1); // MCP allowed server // Check trusted server const trustedRule = config.rules?.find( @@ -297,7 +297,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(2.2); // MCP trusted server + expect(trustedRule?.priority).toBe(3.2); // MCP trusted server // Check excluded server const excludedRule = config.rules?.find( @@ -306,7 +306,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.DENY, ); expect(excludedRule).toBeDefined(); - expect(excludedRule?.priority).toBe(2.9); // MCP excluded server + expect(excludedRule?.priority).toBe(3.9); // MCP excluded server }); it('should allow all tools in YOLO mode', async () => { @@ -387,11 +387,11 @@ describe('createPolicyEngineConfig', () => { ); expect(serverDenyRule).toBeDefined(); - expect(serverDenyRule?.priority).toBe(2.9); // MCP excluded server + expect(serverDenyRule?.priority).toBe(3.9); // MCP excluded server expect(toolAllowRule).toBeDefined(); - expect(toolAllowRule?.priority).toBeCloseTo(2.3, 5); // Command line allow + expect(toolAllowRule?.priority).toBeCloseTo(3.3, 5); // Command line allow - // Server deny (2.9) has higher priority than tool allow (2.3), + // Server deny (3.9) has higher priority than tool allow (3.3), // so server deny wins (this is expected behavior - server-level blocks are security critical) }); @@ -424,7 +424,7 @@ describe('createPolicyEngineConfig', () => { expect(serverAllowRule).toBeDefined(); expect(toolDenyRule).toBeDefined(); - // Command line exclude (2.4) has higher priority than MCP server trust (2.2) + // Command line exclude (3.4) has higher priority than MCP server trust (3.2) // This is the correct behavior - specific exclusions should beat general server trust expect(toolDenyRule!.priority).toBeGreaterThan(serverAllowRule!.priority!); }); @@ -432,16 +432,16 @@ describe('createPolicyEngineConfig', () => { it('should handle complex priority scenarios correctly', async () => { const settings: PolicySettings = { tools: { - allowed: ['my-server__tool1', 'other-tool'], // Priority 2.3 - exclude: ['my-server__tool2', 'glob'], // Priority 2.4 + allowed: ['my-server__tool1', 'other-tool'], // Priority 3.3 + exclude: ['my-server__tool2', 'glob'], // Priority 3.4 }, mcp: { - allowed: ['allowed-server'], // Priority 2.1 - excluded: ['excluded-server'], // Priority 2.9 + allowed: ['allowed-server'], // Priority 3.1 + excluded: ['excluded-server'], // Priority 3.9 }, mcpServers: { 'trusted-server': { - trust: true, // Priority 90 -> 2.2 + trust: true, // Priority 90 -> 3.2 }, }, }; @@ -517,7 +517,7 @@ describe('createPolicyEngineConfig', () => { expect(globDenyRule).toBeDefined(); expect(globAllowRule).toBeDefined(); // Deny from settings (user tier) - expect(globDenyRule!.priority).toBeCloseTo(2.4, 5); // Command line exclude + expect(globDenyRule!.priority).toBeCloseTo(3.4, 5); // Command line exclude // Allow from default TOML: 1 + 50/1000 = 1.05 expect(globAllowRule!.priority).toBeCloseTo(1.05, 5); @@ -530,11 +530,11 @@ describe('createPolicyEngineConfig', () => { })) .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); - // Check that the highest priority items are the excludes (user tier: 2.4 and 2.9) + // Check that the highest priority items are the excludes (user tier: 3.4 and 3.9) const highestPriorityExcludes = priorities?.filter( (p) => - Math.abs(p.priority! - 2.4) < 0.01 || - Math.abs(p.priority! - 2.9) < 0.01, + Math.abs(p.priority! - 3.4) < 0.01 || + Math.abs(p.priority! - 3.9) < 0.01, ); expect( highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY), @@ -626,7 +626,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY, ); expect(excludeRule).toBeDefined(); - expect(excludeRule?.priority).toBeCloseTo(2.4, 5); // Command line exclude + expect(excludeRule?.priority).toBeCloseTo(3.4, 5); // Command line exclude }); it('should support argsPattern in policy rules', async () => { @@ -733,8 +733,8 @@ priority = 150 r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - // Priority 150 in user tier → 2.150 - expect(rule?.priority).toBeCloseTo(2.15, 5); + // Priority 150 in user tier → 3.150 + expect(rule?.priority).toBeCloseTo(3.15, 5); expect(rule?.argsPattern).toBeInstanceOf(RegExp); expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true); expect(rule?.argsPattern?.test('{"command":"git diff"}')).toBe(true); @@ -1046,7 +1046,7 @@ name = "invalid-name" r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow + expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow vi.doUnmock('node:fs/promises'); }); @@ -1188,7 +1188,7 @@ modes = ["plan"] r.modes?.includes(ApprovalMode.PLAN), ); expect(subagentRule).toBeDefined(); - expect(subagentRule?.priority).toBeCloseTo(2.1, 5); + expect(subagentRule?.priority).toBeCloseTo(3.1, 5); vi.doUnmock('node:fs/promises'); }); diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index efa5083504..7de415cb37 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -29,6 +29,7 @@ import { coreEvents } from '../utils/events.js'; import { debugLogger } from '../utils/debugLogger.js'; import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js'; import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; +import { isNodeError } from '../utils/errors.js'; import { isDirectorySecure } from '../utils/security.js'; @@ -38,47 +39,69 @@ export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies'); // Policy tier constants for priority calculation export const DEFAULT_POLICY_TIER = 1; -export const USER_POLICY_TIER = 2; -export const ADMIN_POLICY_TIER = 3; +export const WORKSPACE_POLICY_TIER = 2; +export const USER_POLICY_TIER = 3; +export const ADMIN_POLICY_TIER = 4; + +// Specific priority offsets and derived priorities for dynamic/settings rules. +// These are added to the tier base (e.g., USER_POLICY_TIER). + +// Workspace tier (2) + high priority (950/1000) = ALWAYS_ALLOW_PRIORITY +// This ensures user "always allow" selections are high priority +// within the workspace tier but still lose to user/admin policies. +export const ALWAYS_ALLOW_PRIORITY = WORKSPACE_POLICY_TIER + 0.95; + +export const MCP_EXCLUDED_PRIORITY = USER_POLICY_TIER + 0.9; +export const EXCLUDE_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.4; +export const ALLOWED_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.3; +export const TRUSTED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.2; +export const ALLOWED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.1; /** - * Gets the list of directories to search for policy files, in order of decreasing priority - * (Admin -> User -> Default). + * Gets the list of directories to search for policy files, in order of increasing priority + * (Default -> User -> Project -> Admin). * * @param defaultPoliciesDir Optional path to a directory containing default policies. * @param policyPaths Optional user-provided policy paths (from --policy flag). * When provided, these replace the default user policies directory. + * @param workspacePoliciesDir Optional path to a directory containing workspace policies. */ export function getPolicyDirectories( defaultPoliciesDir?: string, policyPaths?: string[], + workspacePoliciesDir?: string, ): string[] { - const dirs: string[] = []; + const dirs = []; - // Default tier (lowest priority) - dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR); + // Admin tier (highest priority) + dirs.push(Storage.getSystemPoliciesDir()); - // User tier (middle priority) + // User tier (second highest priority) if (policyPaths && policyPaths.length > 0) { dirs.push(...policyPaths); } else { dirs.push(Storage.getUserPoliciesDir()); } - // Admin tier (highest priority) - dirs.push(Storage.getSystemPoliciesDir()); + // Workspace Tier (third highest) + if (workspacePoliciesDir) { + dirs.push(workspacePoliciesDir); + } - // Reverse so highest priority (Admin) is first - return dirs.reverse(); + // Default tier (lowest priority) + dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR); + + return dirs; } /** - * Determines the policy tier (1=default, 2=user, 3=admin) for a given directory. + * Determines the policy tier (1=default, 2=user, 3=workspace, 4=admin) for a given directory. * This is used by the TOML loader to assign priority bands. */ export function getPolicyTier( dir: string, defaultPoliciesDir?: string, + workspacePoliciesDir?: string, ): number { const USER_POLICIES_DIR = Storage.getUserPoliciesDir(); const ADMIN_POLICIES_DIR = Storage.getSystemPoliciesDir(); @@ -99,6 +122,12 @@ export function getPolicyTier( if (normalizedDir === normalizedUser) { return USER_POLICY_TIER; } + if ( + workspacePoliciesDir && + normalizedDir === path.resolve(workspacePoliciesDir) + ) { + return WORKSPACE_POLICY_TIER; + } if (normalizedDir === normalizedAdmin) { return ADMIN_POLICY_TIER; } @@ -157,8 +186,8 @@ export async function createPolicyEngineConfig( const policyDirs = getPolicyDirectories( defaultPoliciesDir, settings.policyPaths, + settings.workspacePoliciesDir, ); - const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs); const normalizedAdminPoliciesDir = path.resolve( @@ -171,7 +200,11 @@ export async function createPolicyEngineConfig( checkers: tomlCheckers, errors, } = await loadPoliciesFromToml(securePolicyDirs, (p) => { - const tier = getPolicyTier(p, defaultPoliciesDir); + const tier = getPolicyTier( + p, + defaultPoliciesDir, + settings.workspacePoliciesDir, + ); // If it's a user-provided path that isn't already categorized as ADMIN, // treat it as USER tier. @@ -207,19 +240,21 @@ export async function createPolicyEngineConfig( // // Priority bands (tiers): // - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) - // - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) - // - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) + // - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) + // - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) + // - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) // - // This ensures Admin > User > Default hierarchy is always preserved, + // This ensures Admin > User > Workspace > Default hierarchy is always preserved, // while allowing user-specified priorities to work within each tier. // - // Settings-based and dynamic rules (all in user tier 2.x): - // 2.95: Tools that the user has selected as "Always Allow" in the interactive UI - // 2.9: MCP servers excluded list (security: persistent server blocks) - // 2.4: Command line flag --exclude-tools (explicit temporary blocks) - // 2.3: Command line flag --allowed-tools (explicit temporary allows) - // 2.2: MCP servers with trust=true (persistent trusted servers) - // 2.1: MCP servers allowed list (persistent general server allows) + // Settings-based and dynamic rules (mixed tiers): + // MCP_EXCLUDED_PRIORITY: MCP servers excluded list (security: persistent server blocks) + // EXCLUDE_TOOLS_FLAG_PRIORITY: Command line flag --exclude-tools (explicit temporary blocks) + // ALLOWED_TOOLS_FLAG_PRIORITY: Command line flag --allowed-tools (explicit temporary allows) + // TRUSTED_MCP_SERVER_PRIORITY: MCP servers with trust=true (persistent trusted servers) + // ALLOWED_MCP_SERVER_PRIORITY: MCP servers allowed list (persistent general server allows) + // ALWAYS_ALLOW_PRIORITY: Tools that the user has selected as "Always Allow" in the interactive UI + // (Workspace tier 2.x - scoped to the project) // // TOML policy priorities (before transformation): // 10: Write tools default to ASK_USER (becomes 1.010 in default tier) @@ -230,33 +265,33 @@ export async function createPolicyEngineConfig( // 999: YOLO mode allow-all (becomes 1.999 in default tier) // MCP servers that are explicitly excluded in settings.mcp.excluded - // Priority: 2.9 (highest in user tier for security - persistent server blocks) + // Priority: MCP_EXCLUDED_PRIORITY (highest in user tier for security - persistent server blocks) if (settings.mcp?.excluded) { for (const serverName of settings.mcp.excluded) { rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.DENY, - priority: 2.9, + priority: MCP_EXCLUDED_PRIORITY, source: 'Settings (MCP Excluded)', }); } } // Tools that are explicitly excluded in the settings. - // Priority: 2.4 (user tier - explicit temporary blocks) + // Priority: EXCLUDE_TOOLS_FLAG_PRIORITY (user tier - explicit temporary blocks) if (settings.tools?.exclude) { for (const tool of settings.tools.exclude) { rules.push({ toolName: tool, decision: PolicyDecision.DENY, - priority: 2.4, + priority: EXCLUDE_TOOLS_FLAG_PRIORITY, source: 'Settings (Tools Excluded)', }); } } // Tools that are explicitly allowed in the settings. - // Priority: 2.3 (user tier - explicit temporary allows) + // Priority: ALLOWED_TOOLS_FLAG_PRIORITY (user tier - explicit temporary allows) if (settings.tools?.allowed) { for (const tool of settings.tools.allowed) { // Check for legacy format: toolName(args) @@ -276,7 +311,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 2.3, + priority: ALLOWED_TOOLS_FLAG_PRIORITY, argsPattern: new RegExp(pattern), source: 'Settings (Tools Allowed)', }); @@ -288,7 +323,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 2.3, + priority: ALLOWED_TOOLS_FLAG_PRIORITY, source: 'Settings (Tools Allowed)', }); } @@ -300,7 +335,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 2.3, + priority: ALLOWED_TOOLS_FLAG_PRIORITY, source: 'Settings (Tools Allowed)', }); } @@ -308,7 +343,7 @@ export async function createPolicyEngineConfig( } // MCP servers that are trusted in the settings. - // Priority: 2.2 (user tier - persistent trusted servers) + // Priority: TRUSTED_MCP_SERVER_PRIORITY (user tier - persistent trusted servers) if (settings.mcpServers) { for (const [serverName, serverConfig] of Object.entries( settings.mcpServers, @@ -319,7 +354,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.ALLOW, - priority: 2.2, + priority: TRUSTED_MCP_SERVER_PRIORITY, source: 'Settings (MCP Trusted)', }); } @@ -327,13 +362,13 @@ export async function createPolicyEngineConfig( } // MCP servers that are explicitly allowed in settings.mcp.allowed - // Priority: 2.1 (user tier - persistent general server allows) + // Priority: ALLOWED_MCP_SERVER_PRIORITY (user tier - persistent general server allows) if (settings.mcp?.allowed) { for (const serverName of settings.mcp.allowed) { rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.ALLOW, - priority: 2.1, + priority: ALLOWED_MCP_SERVER_PRIORITY, source: 'Settings (MCP Allowed)', }); } @@ -361,6 +396,7 @@ interface TomlRule { export function createPolicyUpdater( policyEngine: PolicyEngine, messageBus: MessageBus, + storage: Storage, ) { // Use a sequential queue for persistence to avoid lost updates from concurrent events. let persistenceQueue = Promise.resolve(); @@ -380,10 +416,7 @@ export function createPolicyUpdater( policyEngine.addRule({ toolName, decision: PolicyDecision.ALLOW, - // User tier (2) + high priority (950/1000) = 2.95 - // This ensures user "always allow" selections are high priority - // but still lose to admin policies (3.xxx) and settings excludes (200) - priority: 2.95, + priority: ALWAYS_ALLOW_PRIORITY, argsPattern: new RegExp(pattern), source: 'Dynamic (Confirmed)', }); @@ -405,10 +438,7 @@ export function createPolicyUpdater( policyEngine.addRule({ toolName, decision: PolicyDecision.ALLOW, - // User tier (2) + high priority (950/1000) = 2.95 - // This ensures user "always allow" selections are high priority - // but still lose to admin policies (3.xxx) and settings excludes (200) - priority: 2.95, + priority: ALWAYS_ALLOW_PRIORITY, argsPattern, source: 'Dynamic (Confirmed)', }); @@ -417,18 +447,24 @@ export function createPolicyUpdater( if (message.persist) { persistenceQueue = persistenceQueue.then(async () => { try { - const userPoliciesDir = Storage.getUserPoliciesDir(); - await fs.mkdir(userPoliciesDir, { recursive: true }); - const policyFile = path.join(userPoliciesDir, 'auto-saved.toml'); + const workspacePoliciesDir = storage.getWorkspacePoliciesDir(); + await fs.mkdir(workspacePoliciesDir, { recursive: true }); + const policyFile = storage.getAutoSavedPolicyPath(); // Read existing file let existingData: { rule?: TomlRule[] } = {}; try { const fileContent = await fs.readFile(policyFile, 'utf-8'); - existingData = toml.parse(fileContent) as { rule?: TomlRule[] }; + const parsed = toml.parse(fileContent); + if ( + typeof parsed === 'object' && + parsed !== null && + (!('rule' in parsed) || Array.isArray(parsed['rule'])) + ) { + existingData = parsed as { rule?: TomlRule[] }; + } } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + if (!isNodeError(error) || error.code !== 'ENOENT') { debugLogger.warn( `Failed to parse ${policyFile}, overwriting with new policy.`, error, diff --git a/packages/core/src/policy/integrity.test.ts b/packages/core/src/policy/integrity.test.ts new file mode 100644 index 0000000000..32ebf56058 --- /dev/null +++ b/packages/core/src/policy/integrity.test.ts @@ -0,0 +1,249 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { PolicyIntegrityManager, IntegrityStatus } from './integrity.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { Storage } from '../config/storage.js'; + +describe('PolicyIntegrityManager', () => { + let integrityManager: PolicyIntegrityManager; + let tempDir: string; + let integrityStoragePath: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-cli-test-')); + integrityStoragePath = path.join(tempDir, 'policy_integrity.json'); + + vi.spyOn(Storage, 'getPolicyIntegrityStoragePath').mockReturnValue( + integrityStoragePath, + ); + + integrityManager = new PolicyIntegrityManager(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe('checkIntegrity', () => { + it('should return NEW if no stored hash', async () => { + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + + const result = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + expect(result.status).toBe(IntegrityStatus.NEW); + expect(result.hash).toBeDefined(); + expect(result.hash).toHaveLength(64); + expect(result.fileCount).toBe(1); + }); + + it('should return MATCH if stored hash matches', async () => { + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + + // First run to get the hash + const resultNew = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + const currentHash = resultNew.hash; + + // Save the hash to mock storage + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ 'workspace:id': currentHash }), + ); + + const result = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + expect(result.status).toBe(IntegrityStatus.MATCH); + expect(result.hash).toBe(currentHash); + }); + + it('should return MISMATCH if stored hash differs', async () => { + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + + const resultNew = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + const currentHash = resultNew.hash; + + // Save a different hash + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ 'workspace:id': 'different_hash' }), + ); + + const result = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + expect(result.status).toBe(IntegrityStatus.MISMATCH); + expect(result.hash).toBe(currentHash); + }); + + it('should result in different hash if filename changes', async () => { + const policyDir1 = path.join(tempDir, 'policies1'); + await fs.mkdir(policyDir1); + await fs.writeFile(path.join(policyDir1, 'a.toml'), 'contentA'); + + const result1 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir1, + ); + + const policyDir2 = path.join(tempDir, 'policies2'); + await fs.mkdir(policyDir2); + await fs.writeFile(path.join(policyDir2, 'b.toml'), 'contentA'); + + const result2 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir2, + ); + + expect(result1.hash).not.toBe(result2.hash); + }); + + it('should result in different hash if content changes', async () => { + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + const result1 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentB'); + const result2 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + + expect(result1.hash).not.toBe(result2.hash); + }); + + it('should be deterministic (sort order)', async () => { + const policyDir1 = path.join(tempDir, 'policies1'); + await fs.mkdir(policyDir1); + await fs.writeFile(path.join(policyDir1, 'a.toml'), 'contentA'); + await fs.writeFile(path.join(policyDir1, 'b.toml'), 'contentB'); + + const result1 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir1, + ); + + // Re-read with same files but they might be in different order in readdir + // PolicyIntegrityManager should sort them. + const result2 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir1, + ); + + expect(result1.hash).toBe(result2.hash); + }); + + it('should handle multiple projects correctly', async () => { + const dirA = path.join(tempDir, 'dirA'); + await fs.mkdir(dirA); + await fs.writeFile(path.join(dirA, 'p.toml'), 'contentA'); + + const dirB = path.join(tempDir, 'dirB'); + await fs.mkdir(dirB); + await fs.writeFile(path.join(dirB, 'p.toml'), 'contentB'); + + const { hash: hashA } = await integrityManager.checkIntegrity( + 'workspace', + 'idA', + dirA, + ); + const { hash: hashB } = await integrityManager.checkIntegrity( + 'workspace', + 'idB', + dirB, + ); + + // Save to storage + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ + 'workspace:idA': hashA, + 'workspace:idB': 'oldHashB', + }), + ); + + // Project A should match + const resultA = await integrityManager.checkIntegrity( + 'workspace', + 'idA', + dirA, + ); + expect(resultA.status).toBe(IntegrityStatus.MATCH); + expect(resultA.hash).toBe(hashA); + + // Project B should mismatch + const resultB = await integrityManager.checkIntegrity( + 'workspace', + 'idB', + dirB, + ); + expect(resultB.status).toBe(IntegrityStatus.MISMATCH); + expect(resultB.hash).toBe(hashB); + }); + }); + + describe('acceptIntegrity', () => { + it('should save the hash to storage', async () => { + await integrityManager.acceptIntegrity('workspace', 'id', 'hash123'); + + const stored = JSON.parse( + await fs.readFile(integrityStoragePath, 'utf-8'), + ); + expect(stored['workspace:id']).toBe('hash123'); + }); + + it('should update existing hash', async () => { + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ 'other:id': 'otherhash' }), + ); + + await integrityManager.acceptIntegrity('workspace', 'id', 'hash123'); + + const stored = JSON.parse( + await fs.readFile(integrityStoragePath, 'utf-8'), + ); + expect(stored['other:id']).toBe('otherhash'); + expect(stored['workspace:id']).toBe('hash123'); + }); + }); +}); diff --git a/packages/core/src/policy/integrity.ts b/packages/core/src/policy/integrity.ts new file mode 100644 index 0000000000..e8716ed438 --- /dev/null +++ b/packages/core/src/policy/integrity.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { Storage } from '../config/storage.js'; +import { readPolicyFiles } from './toml-loader.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { isNodeError } from '../utils/errors.js'; + +export enum IntegrityStatus { + MATCH = 'MATCH', + MISMATCH = 'MISMATCH', + NEW = 'NEW', +} + +export interface IntegrityResult { + status: IntegrityStatus; + hash: string; + fileCount: number; +} + +interface StoredIntegrityData { + [key: string]: string; // key = scope:identifier, value = hash +} + +export class PolicyIntegrityManager { + /** + * Checks the integrity of policies in a given directory against the stored hash. + * + * @param scope The scope of the policy (e.g., 'project', 'user'). + * @param identifier A unique identifier for the policy scope (e.g., project path). + * @param policyDir The directory containing the policy files. + * @returns IntegrityResult indicating if the current policies match the stored hash. + */ + async checkIntegrity( + scope: string, + identifier: string, + policyDir: string, + ): Promise { + const { hash: currentHash, fileCount } = + await PolicyIntegrityManager.calculateIntegrityHash(policyDir); + const storedData = await this.loadIntegrityData(); + const key = this.getIntegrityKey(scope, identifier); + const storedHash = storedData[key]; + + if (!storedHash) { + return { status: IntegrityStatus.NEW, hash: currentHash, fileCount }; + } + + if (storedHash === currentHash) { + return { status: IntegrityStatus.MATCH, hash: currentHash, fileCount }; + } + + return { status: IntegrityStatus.MISMATCH, hash: currentHash, fileCount }; + } + + /** + * Accepts and persists the current integrity hash for a given policy scope. + * + * @param scope The scope of the policy. + * @param identifier A unique identifier for the policy scope (e.g., project path). + * @param hash The hash to persist. + */ + async acceptIntegrity( + scope: string, + identifier: string, + hash: string, + ): Promise { + const storedData = await this.loadIntegrityData(); + const key = this.getIntegrityKey(scope, identifier); + storedData[key] = hash; + await this.saveIntegrityData(storedData); + } + + /** + * Calculates a SHA-256 hash of all policy files in the directory. + * The hash includes the relative file path and content to detect renames and modifications. + * + * @param policyDir The directory containing the policy files. + * @returns The calculated hash and file count + */ + private static async calculateIntegrityHash( + policyDir: string, + ): Promise<{ hash: string; fileCount: number }> { + try { + const files = await readPolicyFiles(policyDir); + + // Sort files by path to ensure deterministic hashing + files.sort((a, b) => a.path.localeCompare(b.path)); + + const hash = crypto.createHash('sha256'); + + for (const file of files) { + const relativePath = path.relative(policyDir, file.path); + // Include relative path and content in the hash + hash.update(relativePath); + hash.update('\0'); // Separator + hash.update(file.content); + hash.update('\0'); // Separator + } + + return { hash: hash.digest('hex'), fileCount: files.length }; + } catch (error) { + debugLogger.error('Failed to calculate policy integrity hash', error); + // Return a unique hash (random) to force a mismatch if calculation fails? + // Or throw? Throwing is better so we don't accidentally accept/deny corrupted state. + throw error; + } + } + + private getIntegrityKey(scope: string, identifier: string): string { + return `${scope}:${identifier}`; + } + + private async loadIntegrityData(): Promise { + const storagePath = Storage.getPolicyIntegrityStoragePath(); + try { + const content = await fs.readFile(storagePath, 'utf-8'); + const parsed: unknown = JSON.parse(content); + if ( + typeof parsed === 'object' && + parsed !== null && + Object.values(parsed).every((v) => typeof v === 'string') + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return parsed as StoredIntegrityData; + } + debugLogger.warn('Invalid policy integrity data format'); + return {}; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return {}; + } + debugLogger.error('Failed to load policy integrity data', error); + return {}; + } + } + + private async saveIntegrityData(data: StoredIntegrityData): Promise { + const storagePath = Storage.getPolicyIntegrityStoragePath(); + try { + await fs.mkdir(path.dirname(storagePath), { recursive: true }); + await fs.writeFile(storagePath, JSON.stringify(data, null, 2), 'utf-8'); + } catch (error) { + debugLogger.error('Failed to save policy integrity data', error); + throw error; + } + } +} diff --git a/packages/core/src/policy/persistence.test.ts b/packages/core/src/policy/persistence.test.ts index 7d80b41893..43f52a956d 100644 --- a/packages/core/src/policy/persistence.test.ts +++ b/packages/core/src/policy/persistence.test.ts @@ -15,11 +15,11 @@ import { } from 'vitest'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { createPolicyUpdater } from './config.js'; +import { createPolicyUpdater, ALWAYS_ALLOW_PRIORITY } from './config.js'; import { PolicyEngine } from './policy-engine.js'; import { MessageBus } from '../confirmation-bus/message-bus.js'; import { MessageBusType } from '../confirmation-bus/types.js'; -import { Storage } from '../config/storage.js'; +import { Storage, AUTO_SAVED_POLICY_FILENAME } from '../config/storage.js'; import { ApprovalMode } from './types.js'; vi.mock('node:fs/promises'); @@ -28,6 +28,7 @@ vi.mock('../config/storage.js'); describe('createPolicyUpdater', () => { let policyEngine: PolicyEngine; let messageBus: MessageBus; + let mockStorage: Storage; beforeEach(() => { policyEngine = new PolicyEngine({ @@ -36,6 +37,7 @@ describe('createPolicyUpdater', () => { approvalMode: ApprovalMode.DEFAULT, }); messageBus = new MessageBus(policyEngine); + mockStorage = new Storage('/mock/project'); vi.clearAllMocks(); }); @@ -44,10 +46,17 @@ describe('createPolicyUpdater', () => { }); it('should persist policy when persist flag is true', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); - const userPoliciesDir = '/mock/user/policies'; - vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(userPoliciesDir); + const workspacePoliciesDir = '/mock/project/.gemini/policies'; + const policyFile = path.join( + workspacePoliciesDir, + AUTO_SAVED_POLICY_FILENAME, + ); + vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( + workspacePoliciesDir, + ); + vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); (fs.mkdir as unknown as Mock).mockResolvedValue(undefined); (fs.readFile as unknown as Mock).mockRejectedValue( new Error('File not found'), @@ -70,8 +79,8 @@ describe('createPolicyUpdater', () => { // Wait for async operations (microtasks) await new Promise((resolve) => setTimeout(resolve, 0)); - expect(Storage.getUserPoliciesDir).toHaveBeenCalled(); - expect(fs.mkdir).toHaveBeenCalledWith(userPoliciesDir, { + expect(mockStorage.getWorkspacePoliciesDir).toHaveBeenCalled(); + expect(fs.mkdir).toHaveBeenCalledWith(workspacePoliciesDir, { recursive: true, }); @@ -85,12 +94,12 @@ describe('createPolicyUpdater', () => { ); expect(fs.rename).toHaveBeenCalledWith( expect.stringMatching(/\.tmp$/), - path.join(userPoliciesDir, 'auto-saved.toml'), + policyFile, ); }); it('should not persist policy when persist flag is false or undefined', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); await messageBus.publish({ type: MessageBusType.UPDATE_POLICY, @@ -104,10 +113,17 @@ describe('createPolicyUpdater', () => { }); it('should persist policy with commandPrefix when provided', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); - const userPoliciesDir = '/mock/user/policies'; - vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(userPoliciesDir); + const workspacePoliciesDir = '/mock/project/.gemini/policies'; + const policyFile = path.join( + workspacePoliciesDir, + AUTO_SAVED_POLICY_FILENAME, + ); + vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( + workspacePoliciesDir, + ); + vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); (fs.mkdir as unknown as Mock).mockResolvedValue(undefined); (fs.readFile as unknown as Mock).mockRejectedValue( new Error('File not found'), @@ -136,7 +152,7 @@ describe('createPolicyUpdater', () => { const rules = policyEngine.getRules(); const addedRule = rules.find((r) => r.toolName === toolName); expect(addedRule).toBeDefined(); - expect(addedRule?.priority).toBe(2.95); + expect(addedRule?.priority).toBe(ALWAYS_ALLOW_PRIORITY); expect(addedRule?.argsPattern).toEqual( new RegExp(`"command":"git\\ status(?:[\\s"]|\\\\")`), ); @@ -150,10 +166,17 @@ describe('createPolicyUpdater', () => { }); it('should persist policy with mcpName and toolName when provided', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); - const userPoliciesDir = '/mock/user/policies'; - vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(userPoliciesDir); + const workspacePoliciesDir = '/mock/project/.gemini/policies'; + const policyFile = path.join( + workspacePoliciesDir, + AUTO_SAVED_POLICY_FILENAME, + ); + vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( + workspacePoliciesDir, + ); + vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); (fs.mkdir as unknown as Mock).mockResolvedValue(undefined); (fs.readFile as unknown as Mock).mockRejectedValue( new Error('File not found'), @@ -189,10 +212,17 @@ describe('createPolicyUpdater', () => { }); it('should escape special characters in toolName and mcpName', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); - const userPoliciesDir = '/mock/user/policies'; - vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(userPoliciesDir); + const workspacePoliciesDir = '/mock/project/.gemini/policies'; + const policyFile = path.join( + workspacePoliciesDir, + AUTO_SAVED_POLICY_FILENAME, + ); + vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( + workspacePoliciesDir, + ); + vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); (fs.mkdir as unknown as Mock).mockResolvedValue(undefined); (fs.readFile as unknown as Mock).mockRejectedValue( new Error('File not found'), diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 12648fec5f..6b963f72d2 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -5,19 +5,20 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # -# This ensures Admin > User > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) @@ -54,3 +55,11 @@ decision = "allow" priority = 70 modes = ["plan"] argsPattern = "\"file_path\":\"[^\"]+/\\.gemini/tmp/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+/plans/[a-zA-Z0-9_-]+\\.md\"" + +# Explicitly Deny other write operations in Plan mode with a clear message. +[[rule]] +toolName = ["write_file", "edit"] +decision = "deny" +priority = 65 +modes = ["plan"] +deny_message = "You are in Plan Mode and cannot modify source code. You may ONLY use write_file or replace to save plans to the designated plans directory as .md files." diff --git a/packages/core/src/policy/policies/read-only.toml b/packages/core/src/policy/policies/read-only.toml index b608a87904..1688d5108c 100644 --- a/packages/core/src/policy/policies/read-only.toml +++ b/packages/core/src/policy/policies/read-only.toml @@ -5,19 +5,20 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # -# This ensures Admin > User > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/write.toml b/packages/core/src/policy/policies/write.toml index 991424cebc..47cd9c98ae 100644 --- a/packages/core/src/policy/policies/write.toml +++ b/packages/core/src/policy/policies/write.toml @@ -5,19 +5,20 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # -# This ensures Admin > User > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/yolo.toml b/packages/core/src/policy/policies/yolo.toml index 95c3b411f1..332334db7c 100644 --- a/packages/core/src/policy/policies/yolo.toml +++ b/packages/core/src/policy/policies/yolo.toml @@ -5,19 +5,20 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # -# This ensures Admin > User > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 693ae3a4b2..11e8333f47 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -2373,4 +2373,89 @@ describe('PolicyEngine', () => { ); }); }); + + describe('removeRulesByTier', () => { + it('should remove rules matching a specific tier', () => { + engine.addRule({ + toolName: 'rule1', + decision: PolicyDecision.ALLOW, + priority: 1.1, + }); + engine.addRule({ + toolName: 'rule2', + decision: PolicyDecision.ALLOW, + priority: 1.5, + }); + engine.addRule({ + toolName: 'rule3', + decision: PolicyDecision.ALLOW, + priority: 2.1, + }); + engine.addRule({ + toolName: 'rule4', + decision: PolicyDecision.ALLOW, + priority: 0.5, + }); + engine.addRule({ toolName: 'rule5', decision: PolicyDecision.ALLOW }); // priority undefined -> 0 + + expect(engine.getRules()).toHaveLength(5); + + engine.removeRulesByTier(1); + + const rules = engine.getRules(); + expect(rules).toHaveLength(3); + expect(rules.some((r) => r.toolName === 'rule1')).toBe(false); + expect(rules.some((r) => r.toolName === 'rule2')).toBe(false); + expect(rules.some((r) => r.toolName === 'rule3')).toBe(true); + expect(rules.some((r) => r.toolName === 'rule4')).toBe(true); + expect(rules.some((r) => r.toolName === 'rule5')).toBe(true); + }); + + it('should handle removing tier 0 rules (including undefined priority)', () => { + engine.addRule({ + toolName: 'rule1', + decision: PolicyDecision.ALLOW, + priority: 0.5, + }); + engine.addRule({ toolName: 'rule2', decision: PolicyDecision.ALLOW }); // defaults to 0 + engine.addRule({ + toolName: 'rule3', + decision: PolicyDecision.ALLOW, + priority: 1.5, + }); + + expect(engine.getRules()).toHaveLength(3); + + engine.removeRulesByTier(0); + + const rules = engine.getRules(); + expect(rules).toHaveLength(1); + expect(rules[0].toolName).toBe('rule3'); + }); + }); + + describe('removeCheckersByTier', () => { + it('should remove checkers matching a specific tier', () => { + engine.addChecker({ + checker: { type: 'external', name: 'c1' }, + priority: 1.1, + }); + engine.addChecker({ + checker: { type: 'external', name: 'c2' }, + priority: 1.9, + }); + engine.addChecker({ + checker: { type: 'external', name: 'c3' }, + priority: 2.5, + }); + + expect(engine.getCheckers()).toHaveLength(3); + + engine.removeCheckersByTier(1); + + const checkers = engine.getCheckers(); + expect(checkers).toHaveLength(1); + expect(checkers[0].priority).toBe(2.5); + }); + }); }); diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 3f386edd8f..353cdae9c1 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -475,6 +475,24 @@ export class PolicyEngine { this.checkers.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); } + /** + * Remove rules matching a specific tier (priority band). + */ + removeRulesByTier(tier: number): void { + this.rules = this.rules.filter( + (rule) => Math.floor(rule.priority ?? 0) !== tier, + ); + } + + /** + * Remove checkers matching a specific tier (priority band). + */ + removeCheckersByTier(tier: number): void { + this.checkers = this.checkers.filter( + (checker) => Math.floor(checker.priority ?? 0) !== tier, + ); + } + /** * Remove rules for a specific tool. * If source is provided, only rules matching that source are removed. diff --git a/packages/core/src/policy/policy-updater.test.ts b/packages/core/src/policy/policy-updater.test.ts index 928d84408b..40780a1850 100644 --- a/packages/core/src/policy/policy-updater.test.ts +++ b/packages/core/src/policy/policy-updater.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs/promises'; -import { createPolicyUpdater } from './config.js'; +import { createPolicyUpdater, ALWAYS_ALLOW_PRIORITY } from './config.js'; import { PolicyEngine } from './policy-engine.js'; import { MessageBus } from '../confirmation-bus/message-bus.js'; import { MessageBusType } from '../confirmation-bus/types.js'; @@ -41,6 +41,7 @@ interface TestableShellToolInvocation { describe('createPolicyUpdater', () => { let policyEngine: PolicyEngine; let messageBus: MessageBus; + let mockStorage: Storage; beforeEach(() => { vi.resetAllMocks(); @@ -48,8 +49,9 @@ describe('createPolicyUpdater', () => { vi.spyOn(policyEngine, 'addRule'); messageBus = new MessageBus(policyEngine); - vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue( - '/mock/user/policies', + mockStorage = new Storage('/mock/project'); + vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( + '/mock/project/.gemini/policies', ); }); @@ -58,7 +60,7 @@ describe('createPolicyUpdater', () => { }); it('should add multiple rules when commandPrefix is an array', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); await messageBus.publish({ type: MessageBusType.UPDATE_POLICY, @@ -72,6 +74,7 @@ describe('createPolicyUpdater', () => { 1, expect.objectContaining({ toolName: 'run_shell_command', + priority: ALWAYS_ALLOW_PRIORITY, argsPattern: new RegExp('"command":"echo(?:[\\s"]|\\\\")'), }), ); @@ -79,13 +82,14 @@ describe('createPolicyUpdater', () => { 2, expect.objectContaining({ toolName: 'run_shell_command', + priority: ALWAYS_ALLOW_PRIORITY, argsPattern: new RegExp('"command":"ls(?:[\\s"]|\\\\")'), }), ); }); it('should add a single rule when commandPrefix is a string', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); await messageBus.publish({ type: MessageBusType.UPDATE_POLICY, @@ -98,13 +102,14 @@ describe('createPolicyUpdater', () => { expect(policyEngine.addRule).toHaveBeenCalledWith( expect.objectContaining({ toolName: 'run_shell_command', + priority: ALWAYS_ALLOW_PRIORITY, argsPattern: new RegExp('"command":"git(?:[\\s"]|\\\\")'), }), ); }); it('should persist multiple rules correctly to TOML', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' }); vi.mocked(fs.mkdir).mockResolvedValue(undefined); @@ -139,7 +144,7 @@ describe('createPolicyUpdater', () => { }); it('should reject unsafe regex patterns', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); await messageBus.publish({ type: MessageBusType.UPDATE_POLICY, diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index c627f6d049..e706b16bf7 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -228,14 +228,18 @@ modes = ["autoEdit"] `, ); - const getPolicyTier = (_dir: string) => 2; // Tier 2 - const result = await loadPoliciesFromToml([tempDir], getPolicyTier); + const getPolicyTier2 = (_dir: string) => 2; // Tier 2 + const result2 = await loadPoliciesFromToml([tempDir], getPolicyTier2); - expect(result.rules).toHaveLength(1); - expect(result.rules[0].toolName).toBe('tier2-tool'); - expect(result.rules[0].modes).toEqual(['autoEdit']); - expect(result.rules[0].source).toBe('User: tier2.toml'); - expect(result.errors).toHaveLength(0); + expect(result2.rules).toHaveLength(1); + expect(result2.rules[0].toolName).toBe('tier2-tool'); + expect(result2.rules[0].modes).toEqual(['autoEdit']); + expect(result2.rules[0].source).toBe('Workspace: tier2.toml'); + + const getPolicyTier3 = (_dir: string) => 3; // Tier 3 + const result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3); + expect(result3.rules[0].source).toBe('User: tier2.toml'); + expect(result3.errors).toHaveLength(0); }); it('should handle TOML parse errors', async () => { @@ -359,6 +363,21 @@ priority = -1 expect(result.errors[0].fileName).toBe('invalid.toml'); expect(result.errors[0].errorType).toBe('schema_validation'); }); + + it('should transform safety checker priorities based on tier', async () => { + const result = await runLoadPoliciesFromToml(` +[[safety_checker]] +toolName = "write_file" +priority = 100 +[safety_checker.checker] +type = "in-process" +name = "allowed-path" +`); + + expect(result.checkers).toHaveLength(1); + expect(result.checkers[0].priority).toBe(1.1); // tier 1 + 100/1000 + expect(result.checkers[0].source).toBe('Default: test.toml'); + }); }); describe('Negative Tests', () => { diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts index a627064d41..7be3fe27dc 100644 --- a/packages/core/src/policy/toml-loader.ts +++ b/packages/core/src/policy/toml-loader.ts @@ -17,6 +17,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import toml from '@iarna/toml'; import { z, type ZodError } from 'zod'; +import { isNodeError } from '../utils/errors.js'; /** * Schema for a single policy rule in the TOML file (before transformation). @@ -105,7 +106,7 @@ export type PolicyFileErrorType = export interface PolicyFileError { filePath: string; fileName: string; - tier: 'default' | 'user' | 'admin'; + tier: 'default' | 'user' | 'workspace' | 'admin'; ruleIndex?: number; errorType: PolicyFileErrorType; message: string; @@ -122,13 +123,59 @@ export interface PolicyLoadResult { errors: PolicyFileError[]; } +export interface PolicyFile { + path: string; + content: string; +} + +/** + * Reads policy files from a directory or a single file. + * + * @param policyPath Path to a directory or a .toml file. + * @returns Array of PolicyFile objects. + */ +export async function readPolicyFiles( + policyPath: string, +): Promise { + let filesToLoad: string[] = []; + let baseDir = ''; + + try { + const stats = await fs.stat(policyPath); + if (stats.isDirectory()) { + baseDir = policyPath; + const dirEntries = await fs.readdir(policyPath, { withFileTypes: true }); + filesToLoad = dirEntries + .filter((entry) => entry.isFile() && entry.name.endsWith('.toml')) + .map((entry) => entry.name); + } else if (stats.isFile() && policyPath.endsWith('.toml')) { + baseDir = path.dirname(policyPath); + filesToLoad = [path.basename(policyPath)]; + } + } catch (e) { + if (isNodeError(e) && e.code === 'ENOENT') { + return []; + } + throw e; + } + + const results: PolicyFile[] = []; + for (const file of filesToLoad) { + const filePath = path.join(baseDir, file); + const content = await fs.readFile(filePath, 'utf-8'); + results.push({ path: filePath, content }); + } + return results; +} + /** * Converts a tier number to a human-readable tier name. */ -function getTierName(tier: number): 'default' | 'user' | 'admin' { +function getTierName(tier: number): 'default' | 'user' | 'workspace' | 'admin' { if (tier === 1) return 'default'; - if (tier === 2) return 'user'; - if (tier === 3) return 'admin'; + if (tier === 2) return 'workspace'; + if (tier === 3) return 'user'; + if (tier === 4) return 'admin'; return 'default'; } @@ -211,7 +258,7 @@ function transformPriority(priority: number, tier: number): number { * 4. Collects detailed error information for any failures * * @param policyPaths Array of paths (directories or files) to scan for policy files - * @param getPolicyTier Function to determine tier (1-3) for a path + * @param getPolicyTier Function to determine tier (1-4) for a path * @returns Object containing successfully parsed rules and any errors encountered */ export async function loadPoliciesFromToml( @@ -226,48 +273,26 @@ export async function loadPoliciesFromToml( const tier = getPolicyTier(p); const tierName = getTierName(tier); - let filesToLoad: string[] = []; - let baseDir = ''; + let policyFiles: PolicyFile[] = []; try { - const stats = await fs.stat(p); - if (stats.isDirectory()) { - baseDir = p; - const dirEntries = await fs.readdir(p, { withFileTypes: true }); - filesToLoad = dirEntries - .filter((entry) => entry.isFile() && entry.name.endsWith('.toml')) - .map((entry) => entry.name); - } else if (stats.isFile() && p.endsWith('.toml')) { - baseDir = path.dirname(p); - filesToLoad = [path.basename(p)]; - } - // Other file types or non-.toml files are silently ignored - // for consistency with directory scanning behavior. + policyFiles = await readPolicyFiles(p); } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const error = e as NodeJS.ErrnoException; - if (error.code === 'ENOENT') { - // Path doesn't exist, skip it (not an error) - continue; - } errors.push({ filePath: p, fileName: path.basename(p), tier: tierName, errorType: 'file_read', message: `Failed to read policy path`, - details: error.message, + details: isNodeError(e) ? e.message : String(e), }); continue; } - for (const file of filesToLoad) { - const filePath = path.join(baseDir, file); + for (const { path: filePath, content: fileContent } of policyFiles) { + const file = path.basename(filePath); try { - // Read file - const fileContent = await fs.readFile(filePath, 'utf-8'); - // Parse TOML let parsed: unknown; try { @@ -438,10 +463,11 @@ export async function loadPoliciesFromToml( const safetyCheckerRule: SafetyCheckerRule = { toolName: effectiveToolName, - priority: checker.priority, + priority: transformPriority(checker.priority, tier), // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion checker: checker.checker as SafetyCheckerConfig, modes: checker.modes, + source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`, }; if (argsPattern) { @@ -485,17 +511,15 @@ export async function loadPoliciesFromToml( checkers.push(...parsedCheckers); } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const error = e as NodeJS.ErrnoException; // Catch-all for unexpected errors - if (error.code !== 'ENOENT') { + if (!isNodeError(e) || e.code !== 'ENOENT') { errors.push({ filePath, fileName: file, tier: tierName, errorType: 'file_read', message: 'Failed to read policy file', - details: error.message, + details: isNodeError(e) ? e.message : String(e), }); } } diff --git a/packages/core/src/policy/types.ts b/packages/core/src/policy/types.ts index 2e672fff26..e8aa0e6dd1 100644 --- a/packages/core/src/policy/types.ts +++ b/packages/core/src/policy/types.ts @@ -182,6 +182,12 @@ export interface SafetyCheckerRule { * If undefined or empty, it applies to all modes. */ modes?: ApprovalMode[]; + + /** + * Source of the rule. + * e.g. "my-policies.toml", "Workspace: project.toml", etc. + */ + source?: string; } export interface HookExecutionContext { @@ -272,7 +278,9 @@ export interface PolicySettings { allowed?: string[]; }; mcpServers?: Record; + // User provided policies that will replace the USER level policies in ~/.gemini/policies policyPaths?: string[]; + workspacePoliciesDir?: string; } export interface CheckResult { diff --git a/packages/core/src/policy/workspace-policy.test.ts b/packages/core/src/policy/workspace-policy.test.ts new file mode 100644 index 0000000000..999dae6f0d --- /dev/null +++ b/packages/core/src/policy/workspace-policy.test.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import nodePath from 'node:path'; +import { ApprovalMode } from './types.js'; +import { isDirectorySecure } from '../utils/security.js'; + +// Mock dependencies +vi.mock('../utils/security.js', () => ({ + isDirectorySecure: vi.fn().mockResolvedValue({ secure: true }), +})); + +describe('Workspace-Level Policies', () => { + beforeEach(async () => { + vi.resetModules(); + const { Storage } = await import('../config/storage.js'); + vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue( + '/mock/user/policies', + ); + vi.spyOn(Storage, 'getSystemPoliciesDir').mockReturnValue( + '/mock/system/policies', + ); + // Ensure security check always returns secure + vi.mocked(isDirectorySecure).mockResolvedValue({ secure: true }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.doUnmock('node:fs/promises'); + }); + + it('should load workspace policies with correct priority (Tier 2)', async () => { + const workspacePoliciesDir = '/mock/workspace/policies'; + const defaultPoliciesDir = '/mock/default/policies'; + + // Mock FS + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockStat = vi.fn(async (path: string) => { + if (typeof path === 'string' && path.startsWith('/mock/')) { + return { + isDirectory: () => true, + isFile: () => false, + } as unknown as Awaited>; + } + return actualFs.stat(path); + }); + + // Mock readdir to return a policy file for each tier + const mockReaddir = vi.fn(async (path: string) => { + const normalizedPath = nodePath.normalize(path); + if (normalizedPath.endsWith('default/policies')) + return [ + { + name: 'default.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + if (normalizedPath.endsWith('user/policies')) + return [ + { name: 'user.toml', isFile: () => true, isDirectory: () => false }, + ] as unknown as Awaited>; + if (normalizedPath.endsWith('workspace/policies')) + return [ + { + name: 'workspace.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + if (normalizedPath.endsWith('system/policies')) + return [ + { name: 'admin.toml', isFile: () => true, isDirectory: () => false }, + ] as unknown as Awaited>; + return []; + }); + + // Mock readFile to return content with distinct priorities/decisions + const mockReadFile = vi.fn(async (path: string) => { + if (path.includes('default.toml')) { + return `[[rule]] +toolName = "test_tool" +decision = "allow" +priority = 10 +`; // Tier 1 -> 1.010 + } + if (path.includes('user.toml')) { + return `[[rule]] +toolName = "test_tool" +decision = "deny" +priority = 10 +`; // Tier 3 -> 3.010 + } + if (path.includes('workspace.toml')) { + return `[[rule]] +toolName = "test_tool" +decision = "allow" +priority = 10 +`; // Tier 2 -> 2.010 + } + if (path.includes('admin.toml')) { + return `[[rule]] +toolName = "test_tool" +decision = "deny" +priority = 10 +`; // Tier 4 -> 4.010 + } + return ''; + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { + ...actualFs, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + }, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + })); + + const { createPolicyEngineConfig } = await import('./config.js'); + + // Test 1: Workspace vs User (User should win) + const config = await createPolicyEngineConfig( + { workspacePoliciesDir }, + ApprovalMode.DEFAULT, + defaultPoliciesDir, + ); + + const rules = config.rules?.filter((r) => r.toolName === 'test_tool'); + expect(rules).toBeDefined(); + + // Check for all 4 rules + const defaultRule = rules?.find((r) => r.priority === 1.01); + const workspaceRule = rules?.find((r) => r.priority === 2.01); + const userRule = rules?.find((r) => r.priority === 3.01); + const adminRule = rules?.find((r) => r.priority === 4.01); + + expect(defaultRule).toBeDefined(); + expect(userRule).toBeDefined(); + expect(workspaceRule).toBeDefined(); + expect(adminRule).toBeDefined(); + + // Verify Hierarchy: Admin > User > Workspace > Default + expect(adminRule!.priority).toBeGreaterThan(userRule!.priority!); + expect(userRule!.priority).toBeGreaterThan(workspaceRule!.priority!); + expect(workspaceRule!.priority).toBeGreaterThan(defaultRule!.priority!); + }); + + it('should ignore workspace policies if workspacePoliciesDir is undefined', async () => { + const defaultPoliciesDir = '/mock/default/policies'; + + // Mock FS (simplified) + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockStat = vi.fn(async (path: string) => { + if (typeof path === 'string' && path.startsWith('/mock/')) { + return { + isDirectory: () => true, + isFile: () => false, + } as unknown as Awaited>; + } + return actualFs.stat(path); + }); + + const mockReaddir = vi.fn(async (path: string) => { + const normalizedPath = nodePath.normalize(path); + if (normalizedPath.endsWith('default/policies')) + return [ + { + name: 'default.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + return []; + }); + const mockReadFile = vi.fn( + async () => `[[rule]] +toolName="t" +decision="allow" +priority=10`, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { + ...actualFs, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + }, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + })); + + const { createPolicyEngineConfig } = await import('./config.js'); + + const config = await createPolicyEngineConfig( + { workspacePoliciesDir: undefined }, + ApprovalMode.DEFAULT, + defaultPoliciesDir, + ); + + // Should only have default tier rule (1.01) + const rules = config.rules; + expect(rules).toHaveLength(1); + expect(rules![0].priority).toBe(1.01); + }); + + it('should load workspace policies and correctly transform to Tier 2', async () => { + const workspacePoliciesDir = '/mock/workspace/policies'; + + // Mock FS + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockStat = vi.fn(async (path: string) => { + if (typeof path === 'string' && path.startsWith('/mock/')) { + return { + isDirectory: () => true, + isFile: () => false, + } as unknown as Awaited>; + } + return actualFs.stat(path); + }); + + const mockReaddir = vi.fn(async (path: string) => { + const normalizedPath = nodePath.normalize(path); + if (normalizedPath.endsWith('workspace/policies')) + return [ + { + name: 'workspace.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + return []; + }); + const mockReadFile = vi.fn( + async () => `[[rule]] +toolName="p_tool" +decision="allow" +priority=500`, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { + ...actualFs, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + }, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + })); + + const { createPolicyEngineConfig } = await import('./config.js'); + + const config = await createPolicyEngineConfig( + { workspacePoliciesDir }, + ApprovalMode.DEFAULT, + ); + + const rule = config.rules?.find((r) => r.toolName === 'p_tool'); + expect(rule).toBeDefined(); + // Workspace Tier (2) + 500/1000 = 2.5 + expect(rule?.priority).toBe(2.5); + }); +}); diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index bdc8d553f3..d112b2f06f 100644 --- a/packages/core/src/prompts/promptProvider.test.ts +++ b/packages/core/src/prompts/promptProvider.test.ts @@ -38,9 +38,7 @@ describe('PromptProvider', () => { getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), - getProjectTempPlansDir: vi - .fn() - .mockReturnValue('/tmp/project-temp/plans'), + getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'), }, isInteractive: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true), diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 2b7b7854eb..4f1a3afbff 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -58,7 +58,10 @@ export class PromptProvider { const enabledToolNames = new Set(toolNames); const approvedPlanPath = config.getApprovedPlanPath(); - const desiredModel = resolveModel(config.getActiveModel()); + const desiredModel = resolveModel( + config.getActiveModel(), + config.getGemini31LaunchedSync?.() ?? false, + ); const isModernModel = supportsModernFeatures(desiredModel); const activeSnippets = isModernModel ? snippets : legacySnippets; const contextFilenames = getAllGeminiMdFilenames(); @@ -172,7 +175,7 @@ export class PromptProvider { 'planningWorkflow', () => ({ planModeToolsList, - plansDir: config.storage.getProjectTempPlansDir(), + plansDir: config.storage.getPlansDir(), approvedPlanPath: config.getApprovedPlanPath(), }), isPlanMode, @@ -231,7 +234,10 @@ export class PromptProvider { } getCompressionPrompt(config: Config): string { - const desiredModel = resolveModel(config.getActiveModel()); + const desiredModel = resolveModel( + config.getActiveModel(), + config.getGemini31LaunchedSync?.() ?? false, + ); const isModernModel = supportsModernFeatures(desiredModel); const activeSnippets = isModernModel ? snippets : legacySnippets; return activeSnippets.getCompressionPrompt(); diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 3791a856bf..f7ea9b1eee 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -192,7 +192,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like ${GREP_TOOL_NAME} and ${GLOB_TOOL_NAME} with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like ${GREP_TOOL_NAME} with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like ${GREP_TOOL_NAME} and/or ${READ_FILE_TOOL_NAME} called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like ${GREP_TOOL_NAME} and/or ${READ_FILE_TOOL_NAME} called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -460,7 +460,7 @@ ${options.planModeToolsList} ## Rules -1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`${options.plansDir}/\`. +1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`${options.plansDir}/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a detailed plan in the plans directory and get approval before any source code changes can be made. 2. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use ${formatToolName(ASK_USER_TOOL_NAME)} to clarify. Otherwise, explore the codebase and write the draft in one fluid motion. 3. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning. - **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), use read-only tools to explore and answer directly in your chat response. DO NOT create a plan or call ${formatToolName( diff --git a/packages/core/src/routing/strategies/classifierStrategy.test.ts b/packages/core/src/routing/strategies/classifierStrategy.test.ts index b2c7a8797e..7e024b790a 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.test.ts @@ -18,11 +18,14 @@ import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL_AUTO, PREVIEW_GEMINI_MODEL_AUTO, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, } from '../../config/models.js'; import { promptIdContext } from '../../utils/promptIdContext.js'; import type { Content } from '@google/genai'; import type { ResolvedModelConfig } from '../../services/modelConfigService.js'; import { debugLogger } from '../../utils/debugLogger.js'; +import { AuthType } from '../../core/contentGenerator.js'; vi.mock('../../core/baseLlmClient.js'); @@ -53,6 +56,10 @@ describe('ClassifierStrategy', () => { }, getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO), getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false), + getGemini31Launched: vi.fn().mockResolvedValue(false), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), } as unknown as Config; mockBaseLlmClient = { generateJson: vi.fn(), @@ -339,4 +346,49 @@ describe('ClassifierStrategy', () => { // Since requestedModel is Pro, and choice is flash, it should resolve to Flash expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL); }); + + describe('Gemini 3.1 and Custom Tools Routing', () => { + it('should route to PREVIEW_GEMINI_3_1_MODEL when Gemini 3.1 is launched', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO); + const mockApiResponse = { + reasoning: 'Complex task', + model_choice: 'pro', + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + + it('should route to PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL when Gemini 3.1 is launched and auth is USE_GEMINI', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO); + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + authType: AuthType.USE_GEMINI, + }); + const mockApiResponse = { + reasoning: 'Complex task', + model_choice: 'pro', + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); + }); + }); }); diff --git a/packages/core/src/routing/strategies/classifierStrategy.ts b/packages/core/src/routing/strategies/classifierStrategy.ts index 980e89829d..7e54d161de 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.ts @@ -21,6 +21,7 @@ import { } from '../../utils/messageInspectors.js'; import { debugLogger } from '../../utils/debugLogger.js'; import { LlmRole } from '../../telemetry/types.js'; +import { AuthType } from '../../core/contentGenerator.js'; // The number of recent history turns to provide to the router for context. const HISTORY_TURNS_FOR_CONTEXT = 4; @@ -169,9 +170,15 @@ export class ClassifierStrategy implements RoutingStrategy { const reasoning = routerResponse.reasoning; const latencyMs = Date.now() - startTime; + const useGemini3_1 = (await config.getGemini31Launched?.()) ?? false; + const useCustomToolModel = + useGemini3_1 && + config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI; const selectedModel = resolveClassifierModel( model, routerResponse.model_choice, + useGemini3_1, + useCustomToolModel, ); return { diff --git a/packages/core/src/routing/strategies/defaultStrategy.ts b/packages/core/src/routing/strategies/defaultStrategy.ts index e5b89eb1b3..1f5b7e54c2 100644 --- a/packages/core/src/routing/strategies/defaultStrategy.ts +++ b/packages/core/src/routing/strategies/defaultStrategy.ts @@ -21,7 +21,10 @@ export class DefaultStrategy implements TerminalStrategy { config: Config, _baseLlmClient: BaseLlmClient, ): Promise { - const defaultModel = resolveModel(config.getModel()); + const defaultModel = resolveModel( + config.getModel(), + config.getGemini31LaunchedSync?.() ?? false, + ); return { model: defaultModel, metadata: { diff --git a/packages/core/src/routing/strategies/fallbackStrategy.ts b/packages/core/src/routing/strategies/fallbackStrategy.ts index d568039cbc..a18e4fc4dd 100644 --- a/packages/core/src/routing/strategies/fallbackStrategy.ts +++ b/packages/core/src/routing/strategies/fallbackStrategy.ts @@ -23,7 +23,10 @@ export class FallbackStrategy implements RoutingStrategy { _baseLlmClient: BaseLlmClient, ): Promise { const requestedModel = context.requestedModel ?? config.getModel(); - const resolvedModel = resolveModel(requestedModel); + const resolvedModel = resolveModel( + requestedModel, + config.getGemini31LaunchedSync?.() ?? false, + ); const service = config.getModelAvailabilityService(); const snapshot = service.snapshot(resolvedModel); diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index 8767709f68..b8f6c50282 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -12,6 +12,8 @@ import type { BaseLlmClient } from '../../core/baseLlmClient.js'; import { PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL, @@ -20,6 +22,7 @@ import { promptIdContext } from '../../utils/promptIdContext.js'; import type { Content } from '@google/genai'; import type { ResolvedModelConfig } from '../../services/modelConfigService.js'; import { debugLogger } from '../../utils/debugLogger.js'; +import { AuthType } from '../../core/contentGenerator.js'; vi.mock('../../core/baseLlmClient.js'); @@ -52,6 +55,10 @@ describe('NumericalClassifierStrategy', () => { getSessionId: vi.fn().mockReturnValue('control-group-id'), // Default to Control Group (Hash 71 >= 50) getNumericalRoutingEnabled: vi.fn().mockResolvedValue(true), getClassifierThreshold: vi.fn().mockResolvedValue(undefined), + getGemini31Launched: vi.fn().mockResolvedValue(false), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), } as unknown as Config; mockBaseLlmClient = { generateJson: vi.fn(), @@ -535,4 +542,68 @@ describe('NumericalClassifierStrategy', () => { ), ); }); + + describe('Gemini 3.1 and Custom Tools Routing', () => { + it('should route to PREVIEW_GEMINI_3_1_MODEL when Gemini 3.1 is launched', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + const mockApiResponse = { + complexity_reasoning: 'Complex task', + complexity_score: 80, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + it('should route to PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL when Gemini 3.1 is launched and auth is USE_GEMINI', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + authType: AuthType.USE_GEMINI, + }); + const mockApiResponse = { + complexity_reasoning: 'Complex task', + complexity_score: 80, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); + }); + + it('should NOT route to custom tools model when auth is USE_VERTEX_AI', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + authType: AuthType.USE_VERTEX_AI, + }); + const mockApiResponse = { + complexity_reasoning: 'Complex task', + complexity_score: 80, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + }); }); diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index d4ddf99b8d..32cc6ccbb7 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -17,6 +17,7 @@ import { createUserContent, Type } from '@google/genai'; import type { Config } from '../../config/config.js'; import { debugLogger } from '../../utils/debugLogger.js'; import { LlmRole } from '../../telemetry/types.js'; +import { AuthType } from '../../core/contentGenerator.js'; // The number of recent history turns to provide to the router for context. const HISTORY_TURNS_FOR_CONTEXT = 8; @@ -182,8 +183,16 @@ export class NumericalClassifierStrategy implements RoutingStrategy { config, config.getSessionId() || 'unknown-session', ); - - const selectedModel = resolveClassifierModel(model, modelAlias); + const useGemini3_1 = (await config.getGemini31Launched?.()) ?? false; + const useCustomToolModel = + useGemini3_1 && + config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI; + const selectedModel = resolveClassifierModel( + model, + modelAlias, + useGemini3_1, + useCustomToolModel, + ); const latencyMs = Date.now() - startTime; diff --git a/packages/core/src/routing/strategies/overrideStrategy.ts b/packages/core/src/routing/strategies/overrideStrategy.ts index b8382407bd..5101ba9fe7 100644 --- a/packages/core/src/routing/strategies/overrideStrategy.ts +++ b/packages/core/src/routing/strategies/overrideStrategy.ts @@ -33,7 +33,10 @@ export class OverrideStrategy implements RoutingStrategy { // Return the overridden model name. return { - model: resolveModel(overrideModel), + model: resolveModel( + overrideModel, + config.getGemini31LaunchedSync?.() ?? false, + ), metadata: { source: this.name, latencyMs: 0, diff --git a/packages/core/src/safety/checker-runner.ts b/packages/core/src/safety/checker-runner.ts index 02f824d980..a46c3e6dbd 100644 --- a/packages/core/src/safety/checker-runner.ts +++ b/packages/core/src/safety/checker-runner.ts @@ -229,6 +229,7 @@ export class CheckerRunner { // Try to parse the output try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const rawResult = JSON.parse(stdout); const result = SafetyCheckResultSchema.parse(rawResult); diff --git a/packages/core/src/scheduler/policy.ts b/packages/core/src/scheduler/policy.ts index 247b696f22..579f081d2c 100644 --- a/packages/core/src/scheduler/policy.ts +++ b/packages/core/src/scheduler/policy.ts @@ -77,7 +77,10 @@ export async function checkPolicy( } } - return { decision, rule: result.rule }; + return { + decision, + rule: result.rule, + }; } /** diff --git a/packages/core/src/scheduler/scheduler.test.ts b/packages/core/src/scheduler/scheduler.test.ts index dd26ba4c03..61699d07a6 100644 --- a/packages/core/src/scheduler/scheduler.test.ts +++ b/packages/core/src/scheduler/scheduler.test.ts @@ -27,7 +27,6 @@ vi.mock('../telemetry/trace.js', () => ({ })); import { logToolCall } from '../telemetry/loggers.js'; -import { ToolCallEvent } from '../telemetry/types.js'; vi.mock('../telemetry/loggers.js', () => ({ logToolCall: vi.fn(), })); @@ -76,6 +75,8 @@ import type { CancelledToolCall, CompletedToolCall, ToolCallResponseInfo, + Status, + ToolCall, } from './types.js'; import { CoreToolCallStatus, ROOT_SCHEDULER_ID } from './types.js'; import { ToolErrorType } from '../tools/tool-error.js'; @@ -168,29 +169,55 @@ describe('Scheduler (Orchestrator)', () => { getPreferredEditor = vi.fn().mockReturnValue('vim'); // --- Setup Sub-component Mocks --- + const mockActiveCallsMap = new Map(); + const mockQueue: ToolCall[] = []; + mockStateManager = { - enqueue: vi.fn(), - dequeue: vi.fn(), - getToolCall: vi.fn(), - updateStatus: vi.fn(), - finalizeCall: vi.fn(), + enqueue: vi.fn((calls: ToolCall[]) => { + // Clone to preserve initial state for Phase 1 tests + mockQueue.push(...calls.map((c) => ({ ...c }) as ToolCall)); + }), + dequeue: vi.fn(() => { + const next = mockQueue.shift(); + if (next) mockActiveCallsMap.set(next.request.callId, next); + return next; + }), + peekQueue: vi.fn(() => mockQueue[0]), + getToolCall: vi.fn((id: string) => mockActiveCallsMap.get(id)), + updateStatus: vi.fn((id: string, status: Status) => { + const call = mockActiveCallsMap.get(id); + if (call) (call as unknown as { status: Status }).status = status; + }), + finalizeCall: vi.fn((id: string) => { + const call = mockActiveCallsMap.get(id); + if (call) { + mockActiveCallsMap.delete(id); + capturedTerminalHandler?.(call as CompletedToolCall); + } + }), updateArgs: vi.fn(), setOutcome: vi.fn(), - cancelAllQueued: vi.fn(), + cancelAllQueued: vi.fn(() => { + mockQueue.length = 0; + }), clearBatch: vi.fn(), } as unknown as Mocked; // Define getters for accessors idiomatically Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), + get: vi.fn(() => mockActiveCallsMap.size > 0), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'allActiveCalls', { + get: vi.fn(() => Array.from(mockActiveCallsMap.values())), configurable: true, }); Object.defineProperty(mockStateManager, 'queueLength', { - get: vi.fn().mockReturnValue(0), + get: vi.fn(() => mockQueue.length), configurable: true, }); Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(undefined), + get: vi.fn(() => mockActiveCallsMap.values().next().value), configurable: true, }); Object.defineProperty(mockStateManager, 'completedBatch', { @@ -227,8 +254,9 @@ describe('Scheduler (Orchestrator)', () => { ); mockStateManager.finalizeCall.mockImplementation((callId: string) => { - const call = mockStateManager.getToolCall(callId); + const call = mockActiveCallsMap.get(callId); if (call) { + mockActiveCallsMap.delete(callId); capturedTerminalHandler?.(call as CompletedToolCall); } }); @@ -242,6 +270,13 @@ describe('Scheduler (Orchestrator)', () => { vi.mocked(ToolExecutor).mockReturnValue( mockExecutor as unknown as Mocked, ); + mockExecutor.execute.mockResolvedValue({ + status: 'success', + response: { + callId: 'default', + responseParts: [], + } as unknown as ToolCallResponseInfo, + } as unknown as SuccessfulToolCall); vi.mocked(ToolModificationHandler).mockReturnValue( mockModifier as unknown as Mocked, ); @@ -339,35 +374,6 @@ describe('Scheduler (Orchestrator)', () => { describe('Phase 2: Queue Management', () => { it('should drain the queue if multiple calls are scheduled', async () => { - const validatingCall: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - // Setup queue simulation: two items - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi - .fn() - .mockReturnValueOnce(2) - .mockReturnValueOnce(1) - .mockReturnValue(0), - configurable: true, - }); - - Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), - configurable: true, - }); - - mockStateManager.dequeue.mockReturnValue(validatingCall); - vi.mocked(mockStateManager.dequeue).mockReturnValue(validatingCall); - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(validatingCall), - configurable: true, - }); - // Execute is the end of the loop, stub it mockExecutor.execute.mockResolvedValue({ status: CoreToolCallStatus.Success, @@ -375,56 +381,12 @@ describe('Scheduler (Orchestrator)', () => { await scheduler.schedule(req1, signal); - // Verify loop ran twice - expect(mockStateManager.dequeue).toHaveBeenCalledTimes(2); - expect(mockStateManager.finalizeCall).toHaveBeenCalledTimes(2); + // Verify loop ran once for this schedule call (which had 1 request) + // schedule(req1) enqueues 1 request. + expect(mockExecutor.execute).toHaveBeenCalledTimes(1); }); it('should execute tool calls sequentially (first completes before second starts)', async () => { - // Setup queue simulation: two items - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi - .fn() - .mockReturnValueOnce(2) - .mockReturnValueOnce(1) - .mockReturnValue(0), - configurable: true, - }); - - Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), - configurable: true, - }); - - const validatingCall1: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - const validatingCall2: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req2, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - vi.mocked(mockStateManager.dequeue) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2) - .mockReturnValue(undefined); - - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi - .fn() - .mockReturnValueOnce(validatingCall1) // Used in loop check for call 1 - .mockReturnValueOnce(validatingCall1) // Used in _execute for call 1 - .mockReturnValueOnce(validatingCall2) // Used in loop check for call 2 - .mockReturnValueOnce(validatingCall2), // Used in _execute for call 2 - configurable: true, - }); - const executionLog: string[] = []; // Mock executor to push to log with a deterministic microtask delay @@ -452,52 +414,6 @@ describe('Scheduler (Orchestrator)', () => { }); it('should queue and process multiple schedule() calls made synchronously', async () => { - const validatingCall1: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - const validatingCall2: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req2, // Second request - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - // Mock state responses dynamically - Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), - configurable: true, - }); - - // Queue state responses for the two batches: - // Batch 1: length 1 -> 0 - // Batch 2: length 1 -> 0 - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi - .fn() - .mockReturnValueOnce(1) - .mockReturnValueOnce(0) - .mockReturnValueOnce(1) - .mockReturnValue(0), - configurable: true, - }); - - vi.mocked(mockStateManager.dequeue) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2); - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi - .fn() - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2) - .mockReturnValueOnce(validatingCall2), - configurable: true, - }); - // Executor succeeds instantly mockExecutor.execute.mockResolvedValue({ status: CoreToolCallStatus.Success, @@ -516,50 +432,6 @@ describe('Scheduler (Orchestrator)', () => { }); it('should queue requests when scheduler is busy (overlapping batches)', async () => { - const validatingCall1: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - const validatingCall2: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req2, // Second request - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - // 1. Setup State Manager for 2 sequential batches - Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), - configurable: true, - }); - - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi - .fn() - .mockReturnValueOnce(1) // Batch 1 - .mockReturnValueOnce(0) - .mockReturnValueOnce(1) // Batch 2 - .mockReturnValue(0), - configurable: true, - }); - - vi.mocked(mockStateManager.dequeue) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2); - - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi - .fn() - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2) - .mockReturnValueOnce(validatingCall2), - configurable: true, - }); - // 2. Setup Executor with a controllable lock for the first batch const executionLog: string[] = []; let finishFirstBatch: (value: unknown) => void; @@ -635,10 +507,8 @@ describe('Scheduler (Orchestrator)', () => { invocation: mockInvocation as unknown as AnyToolInvocation, }; - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(activeCall), - configurable: true, - }); + mockStateManager.enqueue([activeCall]); + mockStateManager.dequeue(); scheduler.cancelAll(); @@ -676,24 +546,7 @@ describe('Scheduler (Orchestrator)', () => { }); describe('Phase 3: Policy & Confirmation Loop', () => { - const validatingCall: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - beforeEach(() => { - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi.fn().mockReturnValueOnce(1).mockReturnValue(0), - configurable: true, - }); - vi.mocked(mockStateManager.dequeue).mockReturnValue(validatingCall); - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(validatingCall), - configurable: true, - }); - }); + beforeEach(() => {}); it('should update state to error with POLICY_VIOLATION if Policy returns DENY', async () => { vi.mocked(checkPolicy).mockResolvedValue({ @@ -854,30 +707,6 @@ describe('Scheduler (Orchestrator)', () => { }); it('should auto-approve remaining identical tools in batch after ProceedAlways', async () => { - // Setup: two identical tools - const validatingCall1: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - const validatingCall2: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req2, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - vi.mocked(mockStateManager.dequeue) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2) - .mockReturnValue(undefined); - - vi.spyOn(mockStateManager, 'queueLength', 'get') - .mockReturnValueOnce(2) - .mockReturnValueOnce(1) - .mockReturnValue(0); - // First call requires confirmation, second is auto-approved (simulating policy update) vi.mocked(checkPolicy) .mockResolvedValueOnce({ @@ -1045,21 +874,7 @@ describe('Scheduler (Orchestrator)', () => { }); describe('Phase 4: Execution Outcomes', () => { - const validatingCall: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - beforeEach(() => { - vi.spyOn(mockStateManager, 'queueLength', 'get') - .mockReturnValueOnce(1) - .mockReturnValue(0); - mockStateManager.dequeue.mockReturnValue(validatingCall); - vi.spyOn(mockStateManager, 'firstActiveCall', 'get').mockReturnValue( - validatingCall, - ); mockPolicyEngine.check.mockResolvedValue({ decision: PolicyDecision.ALLOW, }); // Bypass confirmation @@ -1132,30 +947,12 @@ describe('Scheduler (Orchestrator)', () => { response: mockResponse, } as unknown as SuccessfulToolCall); - // Mock the state manager to return a SUCCESS state when getToolCall is - // called - const successfulCall: SuccessfulToolCall = { - status: CoreToolCallStatus.Success, - request: req1, - response: mockResponse, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - mockStateManager.getToolCall.mockReturnValue(successfulCall); - Object.defineProperty(mockStateManager, 'completedBatch', { - get: vi.fn().mockReturnValue([successfulCall]), - configurable: true, - }); - await scheduler.schedule(req1, signal); // Verify the finalizer and logger were called expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1'); - expect(ToolCallEvent).toHaveBeenCalledWith(successfulCall); - expect(logToolCall).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining(successfulCall), - ); + // We check that logToolCall was called (it's called via the state manager's terminal handler) + expect(logToolCall).toHaveBeenCalled(); }); it('should not double-report completed tools when concurrent completions occur', async () => { @@ -1182,6 +979,33 @@ describe('Scheduler (Orchestrator)', () => { expect(mockStateManager.finalizeCall).toHaveBeenCalledTimes(1); expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1'); }); + + it('should break the loop if no progress is made (safeguard against stuck states)', async () => { + // Setup: A tool that is 'validating' but stays 'validating' even after processing + // This simulates a bug in state management or a weird edge case. + const stuckCall: ValidatingToolCall = { + status: CoreToolCallStatus.Validating, + request: req1, + tool: mockTool, + invocation: mockInvocation as unknown as AnyToolInvocation, + }; + + // Mock dequeue to keep returning the same stuck call + mockStateManager.dequeue.mockReturnValue(stuckCall); + // Mock isActive to be true + Object.defineProperty(mockStateManager, 'isActive', { + get: vi.fn().mockReturnValue(true), + configurable: true, + }); + + // Mock updateStatus to do NOTHING (simulating no progress) + mockStateManager.updateStatus.mockImplementation(() => {}); + + // This should return false (break loop) instead of hanging indefinitely + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (scheduler as any)._processNextItem(signal); + expect(result).toBe(false); + }); }); describe('Tool Call Context Propagation', () => { @@ -1196,26 +1020,6 @@ describe('Scheduler (Orchestrator)', () => { parentCallId, }); - const validatingCall: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - // Mock queueLength to run the loop once - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi.fn().mockReturnValueOnce(1).mockReturnValue(0), - configurable: true, - }); - - vi.mocked(mockStateManager.dequeue).mockReturnValue(validatingCall); - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(validatingCall), - configurable: true, - }); - vi.mocked(mockStateManager.getToolCall).mockReturnValue(validatingCall); - mockToolRegistry.getTool.mockReturnValue(mockTool); mockPolicyEngine.check.mockResolvedValue({ decision: PolicyDecision.ALLOW, diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index deb28d33a5..3ee55975f1 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -20,6 +20,7 @@ import { type ValidatingToolCall, type ErroredToolCall, CoreToolCallStatus, + type ScheduledToolCall, } from './types.js'; import { ToolErrorType } from '../tools/tool-error.js'; import type { ApprovalMode } from '../policy/types.js'; @@ -231,14 +232,16 @@ export class Scheduler { next?.reject(new Error('Operation cancelled by user')); } - // Cancel active call - const activeCall = this.state.firstActiveCall; - if (activeCall && !this.isTerminal(activeCall.status)) { - this.state.updateStatus( - activeCall.request.callId, - CoreToolCallStatus.Cancelled, - 'Operation cancelled by user', - ); + // Cancel active calls + const activeCalls = this.state.allActiveCalls; + for (const activeCall of activeCalls) { + if (!this.isTerminal(activeCall.status)) { + this.state.updateStatus( + activeCall.request.callId, + CoreToolCallStatus.Cancelled, + 'Operation cancelled by user', + ); + } } // Clear queue @@ -384,6 +387,10 @@ export class Scheduler { return false; } + const initialStatuses = new Map( + this.state.allActiveCalls.map((c) => [c.request.callId, c.status]), + ); + if (!this.state.isActive) { const next = this.state.dequeue(); if (!next) return false; @@ -397,16 +404,91 @@ export class Scheduler { this.state.finalizeCall(next.request.callId); return true; } + + // If the first tool is read-only, batch all contiguous read-only tools. + if (next.tool?.isReadOnly) { + while (this.state.queueLength > 0) { + const peeked = this.state.peekQueue(); + if (peeked && peeked.tool?.isReadOnly) { + this.state.dequeue(); + } else { + break; + } + } + } } - const active = this.state.firstActiveCall; - if (!active) return false; + // Now we have one or more active calls. Move them through the lifecycle + // as much as possible in this iteration. - if (active.status === CoreToolCallStatus.Validating) { - await this._processValidatingCall(active, signal); + // 1. Process all 'validating' calls (Policy & Confirmation) + let activeCalls = this.state.allActiveCalls; + const validatingCalls = activeCalls.filter( + (c): c is ValidatingToolCall => + c.status === CoreToolCallStatus.Validating, + ); + if (validatingCalls.length > 0) { + await Promise.all( + validatingCalls.map((c) => this._processValidatingCall(c, signal)), + ); } - return true; + // 2. Execute scheduled calls + // Refresh activeCalls as status might have changed to 'scheduled' + activeCalls = this.state.allActiveCalls; + const scheduledCalls = activeCalls.filter( + (c): c is ScheduledToolCall => c.status === CoreToolCallStatus.Scheduled, + ); + + // We only execute if ALL active calls are in a ready state (scheduled or terminal) + const allReady = activeCalls.every( + (c) => + c.status === CoreToolCallStatus.Scheduled || this.isTerminal(c.status), + ); + + if (allReady && scheduledCalls.length > 0) { + await Promise.all(scheduledCalls.map((c) => this._execute(c, signal))); + } + + // 3. Finalize terminal calls + activeCalls = this.state.allActiveCalls; + let madeProgress = false; + for (const call of activeCalls) { + if (this.isTerminal(call.status)) { + this.state.finalizeCall(call.request.callId); + madeProgress = true; + } + } + + // Check if any calls changed status during this iteration (excluding terminal finalization) + const currentStatuses = new Map( + activeCalls.map((c) => [c.request.callId, c.status]), + ); + const anyStatusChanged = Array.from(initialStatuses.entries()).some( + ([id, status]) => currentStatuses.get(id) !== status, + ); + + if (madeProgress || anyStatusChanged) { + return true; + } + + // If we have active calls but NONE of them progressed, check if we are waiting for external events. + // States that are 'waiting' from the loop's perspective: awaiting_approval, executing. + const isWaitingForExternal = activeCalls.some( + (c) => + c.status === CoreToolCallStatus.AwaitingApproval || + c.status === CoreToolCallStatus.Executing, + ); + + if (isWaitingForExternal && this.state.isActive) { + // Yield to the event loop to allow external events (tool completion, user input) to progress. + await new Promise((resolve) => queueMicrotask(() => resolve(true))); + return true; + } + + // If we are here, we have active calls (likely Validating or Scheduled) but none progressed. + // This is a stuck state. + return false; } private async _processValidatingCall( @@ -437,8 +519,6 @@ export class Scheduler { ); } } - - this.state.finalizeCall(active.request.callId); } // --- Phase 3: Single Call Orchestration --- @@ -467,7 +547,6 @@ export class Scheduler { errorType, ), ); - this.state.finalizeCall(callId); return; } @@ -506,13 +585,11 @@ export class Scheduler { CoreToolCallStatus.Cancelled, 'User denied execution.', ); - this.state.finalizeCall(callId); this.state.cancelAllQueued('User cancelled operation'); return; // Skip execution } - // Execution - await this._execute(callId, signal); + this.state.updateStatus(callId, CoreToolCallStatus.Scheduled); } // --- Sub-phase Handlers --- @@ -520,13 +597,23 @@ export class Scheduler { /** * Executes the tool and records the result. */ - private async _execute(callId: string, signal: AbortSignal): Promise { - this.state.updateStatus(callId, CoreToolCallStatus.Scheduled); - if (signal.aborted) throw new Error('Operation cancelled'); + private async _execute( + toolCall: ScheduledToolCall, + signal: AbortSignal, + ): Promise { + const callId = toolCall.request.callId; + if (signal.aborted) { + this.state.updateStatus( + callId, + CoreToolCallStatus.Cancelled, + 'Operation cancelled', + ); + return; + } this.state.updateStatus(callId, CoreToolCallStatus.Executing); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const activeCall = this.state.firstActiveCall as ExecutingToolCall; + const activeCall = this.state.getToolCall(callId) as ExecutingToolCall; const result = await runWithToolCallContext( { diff --git a/packages/core/src/scheduler/scheduler_parallel.test.ts b/packages/core/src/scheduler/scheduler_parallel.test.ts new file mode 100644 index 0000000000..824cdc4a16 --- /dev/null +++ b/packages/core/src/scheduler/scheduler_parallel.test.ts @@ -0,0 +1,397 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, + type Mocked, +} from 'vitest'; +import { randomUUID } from 'node:crypto'; + +vi.mock('node:crypto', () => ({ + randomUUID: vi.fn(), +})); + +vi.mock('../telemetry/trace.js', () => ({ + runInDevTraceSpan: vi.fn(async (_opts, fn) => + fn({ metadata: { input: {}, output: {} } }), + ), +})); +vi.mock('../telemetry/loggers.js', () => ({ + logToolCall: vi.fn(), +})); +vi.mock('../telemetry/types.js', () => ({ + ToolCallEvent: vi.fn().mockImplementation((call) => ({ ...call })), +})); + +import { + SchedulerStateManager, + type TerminalCallHandler, +} from './state-manager.js'; +import { checkPolicy, updatePolicy } from './policy.js'; +import { ToolExecutor } from './tool-executor.js'; +import { ToolModificationHandler } from './tool-modifier.js'; + +vi.mock('./state-manager.js'); +vi.mock('./confirmation.js'); +vi.mock('./policy.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + checkPolicy: vi.fn(), + updatePolicy: vi.fn(), + }; +}); +vi.mock('./tool-executor.js'); +vi.mock('./tool-modifier.js'); + +import { Scheduler } from './scheduler.js'; +import type { Config } from '../config/config.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import type { PolicyEngine } from '../policy/policy-engine.js'; +import type { ToolRegistry } from '../tools/tool-registry.js'; +import { ApprovalMode, PolicyDecision } from '../policy/types.js'; +import { + type AnyDeclarativeTool, + type AnyToolInvocation, +} from '../tools/tools.js'; +import type { + ToolCallRequestInfo, + CompletedToolCall, + SuccessfulToolCall, + Status, + ToolCall, +} from './types.js'; +import { ROOT_SCHEDULER_ID } from './types.js'; +import type { EditorType } from '../utils/editor.js'; + +describe('Scheduler Parallel Execution', () => { + let scheduler: Scheduler; + let signal: AbortSignal; + let abortController: AbortController; + + let mockConfig: Mocked; + let mockMessageBus: Mocked; + let mockPolicyEngine: Mocked; + let mockToolRegistry: Mocked; + let getPreferredEditor: Mock<() => EditorType | undefined>; + + let mockStateManager: Mocked; + let mockExecutor: Mocked; + let mockModifier: Mocked; + + const req1: ToolCallRequestInfo = { + callId: 'call-1', + name: 'read-tool-1', + args: { path: 'a.txt' }, + isClientInitiated: false, + prompt_id: 'p1', + schedulerId: ROOT_SCHEDULER_ID, + }; + + const req2: ToolCallRequestInfo = { + callId: 'call-2', + name: 'read-tool-2', + args: { path: 'b.txt' }, + isClientInitiated: false, + prompt_id: 'p1', + schedulerId: ROOT_SCHEDULER_ID, + }; + + const req3: ToolCallRequestInfo = { + callId: 'call-3', + name: 'write-tool', + args: { path: 'c.txt', content: 'hi' }, + isClientInitiated: false, + prompt_id: 'p1', + schedulerId: ROOT_SCHEDULER_ID, + }; + + const readTool1 = { + name: 'read-tool-1', + isReadOnly: true, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + const readTool2 = { + name: 'read-tool-2', + isReadOnly: true, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + const writeTool = { + name: 'write-tool', + isReadOnly: false, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + + const mockInvocation = { + shouldConfirmExecute: vi.fn().mockResolvedValue(false), + }; + + beforeEach(() => { + vi.mocked(randomUUID).mockReturnValue( + 'uuid' as unknown as `${string}-${string}-${string}-${string}-${string}`, + ); + abortController = new AbortController(); + signal = abortController.signal; + + mockPolicyEngine = { + check: vi.fn().mockResolvedValue({ decision: PolicyDecision.ALLOW }), + } as unknown as Mocked; + + mockToolRegistry = { + getTool: vi.fn((name) => { + if (name === 'read-tool-1') return readTool1; + if (name === 'read-tool-2') return readTool2; + if (name === 'write-tool') return writeTool; + return undefined; + }), + getAllToolNames: vi + .fn() + .mockReturnValue(['read-tool-1', 'read-tool-2', 'write-tool']), + } as unknown as Mocked; + + mockConfig = { + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + isInteractive: vi.fn().mockReturnValue(true), + getEnableHooks: vi.fn().mockReturnValue(true), + setApprovalMode: vi.fn(), + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + } as unknown as Mocked; + + mockMessageBus = { + publish: vi.fn(), + subscribe: vi.fn(), + } as unknown as Mocked; + getPreferredEditor = vi.fn().mockReturnValue('vim'); + + vi.mocked(checkPolicy).mockReset(); + vi.mocked(checkPolicy).mockResolvedValue({ + decision: PolicyDecision.ALLOW, + rule: undefined, + }); + vi.mocked(updatePolicy).mockReset(); + + const mockActiveCallsMap = new Map(); + const mockQueue: ToolCall[] = []; + let capturedTerminalHandler: TerminalCallHandler | undefined; + + mockStateManager = { + enqueue: vi.fn((calls: ToolCall[]) => { + mockQueue.push(...calls.map((c) => ({ ...c }) as ToolCall)); + }), + dequeue: vi.fn(() => { + const next = mockQueue.shift(); + if (next) mockActiveCallsMap.set(next.request.callId, next); + return next; + }), + peekQueue: vi.fn(() => mockQueue[0]), + getToolCall: vi.fn((id: string) => mockActiveCallsMap.get(id)), + updateStatus: vi.fn((id: string, status: Status) => { + const call = mockActiveCallsMap.get(id); + if (call) (call as unknown as { status: Status }).status = status; + }), + finalizeCall: vi.fn((id: string) => { + const call = mockActiveCallsMap.get(id); + if (call) { + mockActiveCallsMap.delete(id); + capturedTerminalHandler?.(call as CompletedToolCall); + } + }), + updateArgs: vi.fn(), + setOutcome: vi.fn(), + cancelAllQueued: vi.fn(() => { + mockQueue.length = 0; + }), + clearBatch: vi.fn(), + } as unknown as Mocked; + + Object.defineProperty(mockStateManager, 'isActive', { + get: vi.fn(() => mockActiveCallsMap.size > 0), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'allActiveCalls', { + get: vi.fn(() => Array.from(mockActiveCallsMap.values())), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'queueLength', { + get: vi.fn(() => mockQueue.length), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'firstActiveCall', { + get: vi.fn(() => mockActiveCallsMap.values().next().value), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'completedBatch', { + get: vi.fn().mockReturnValue([]), + configurable: true, + }); + + vi.mocked(SchedulerStateManager).mockImplementation( + (_bus, _id, onTerminal) => { + capturedTerminalHandler = onTerminal; + return mockStateManager as unknown as SchedulerStateManager; + }, + ); + + mockExecutor = { execute: vi.fn() } as unknown as Mocked; + vi.mocked(ToolExecutor).mockReturnValue( + mockExecutor as unknown as Mocked, + ); + mockModifier = { + handleModifyWithEditor: vi.fn(), + applyInlineModify: vi.fn(), + } as unknown as Mocked; + vi.mocked(ToolModificationHandler).mockReturnValue( + mockModifier as unknown as Mocked, + ); + + scheduler = new Scheduler({ + config: mockConfig, + messageBus: mockMessageBus, + getPreferredEditor, + schedulerId: 'root', + }); + + vi.mocked(readTool1.build).mockReturnValue( + mockInvocation as unknown as AnyToolInvocation, + ); + vi.mocked(readTool2.build).mockReturnValue( + mockInvocation as unknown as AnyToolInvocation, + ); + vi.mocked(writeTool.build).mockReturnValue( + mockInvocation as unknown as AnyToolInvocation, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should execute contiguous read-only tools in parallel', async () => { + const executionLog: string[] = []; + + mockExecutor.execute.mockImplementation(async ({ call }) => { + const id = call.request.callId; + executionLog.push(`start-${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionLog.push(`end-${id}`); + return { + status: 'success', + response: { callId: id, responseParts: [] }, + } as unknown as SuccessfulToolCall; + }); + + // Schedule 2 read tools and 1 write tool + await scheduler.schedule([req1, req2, req3], signal); + + // Parallel read tools should start together + expect(executionLog[0]).toBe('start-call-1'); + expect(executionLog[1]).toBe('start-call-2'); + + // They can finish in any order, but both must finish before call-3 starts + expect(executionLog.indexOf('start-call-3')).toBeGreaterThan( + executionLog.indexOf('end-call-1'), + ); + expect(executionLog.indexOf('start-call-3')).toBeGreaterThan( + executionLog.indexOf('end-call-2'), + ); + + expect(executionLog).toContain('end-call-3'); + }); + + it('should execute non-read-only tools sequentially', async () => { + const executionLog: string[] = []; + + mockExecutor.execute.mockImplementation(async ({ call }) => { + const id = call.request.callId; + executionLog.push(`start-${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionLog.push(`end-${id}`); + return { + status: 'success', + response: { callId: id, responseParts: [] }, + } as unknown as SuccessfulToolCall; + }); + + // req3 is NOT read-only + await scheduler.schedule([req3, req1], signal); + + // Should be strictly sequential + expect(executionLog).toEqual([ + 'start-call-3', + 'end-call-3', + 'start-call-1', + 'end-call-1', + ]); + }); + + it('should execute [WRITE, READ, READ] as [sequential, parallel]', async () => { + const executionLog: string[] = []; + mockExecutor.execute.mockImplementation(async ({ call }) => { + const id = call.request.callId; + executionLog.push(`start-${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionLog.push(`end-${id}`); + return { + status: 'success', + response: { callId: id, responseParts: [] }, + } as unknown as SuccessfulToolCall; + }); + + // req3 (WRITE), req1 (READ), req2 (READ) + await scheduler.schedule([req3, req1, req2], signal); + + // Order should be: + // 1. write starts and ends + // 2. read1 and read2 start together (parallel) + expect(executionLog[0]).toBe('start-call-3'); + expect(executionLog[1]).toBe('end-call-3'); + expect(executionLog.slice(2, 4)).toContain('start-call-1'); + expect(executionLog.slice(2, 4)).toContain('start-call-2'); + }); + + it('should execute [READ, READ, WRITE, READ, READ] in three waves', async () => { + const executionLog: string[] = []; + mockExecutor.execute.mockImplementation(async ({ call }) => { + const id = call.request.callId; + executionLog.push(`start-${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionLog.push(`end-${id}`); + return { + status: 'success', + response: { callId: id, responseParts: [] }, + } as unknown as SuccessfulToolCall; + }); + + const req4: ToolCallRequestInfo = { ...req1, callId: 'call-4' }; + const req5: ToolCallRequestInfo = { ...req2, callId: 'call-5' }; + + await scheduler.schedule([req1, req2, req3, req4, req5], signal); + + // Wave 1: call-1, call-2 (parallel) + expect(executionLog.slice(0, 2)).toContain('start-call-1'); + expect(executionLog.slice(0, 2)).toContain('start-call-2'); + + // Wave 2: call-3 (sequential) + // Must start after both call-1 and call-2 end + const start3 = executionLog.indexOf('start-call-3'); + expect(start3).toBeGreaterThan(executionLog.indexOf('end-call-1')); + expect(start3).toBeGreaterThan(executionLog.indexOf('end-call-2')); + const end3 = executionLog.indexOf('end-call-3'); + expect(end3).toBeGreaterThan(start3); + + // Wave 3: call-4, call-5 (parallel) + // Must start after call-3 ends + expect(executionLog.indexOf('start-call-4')).toBeGreaterThan(end3); + expect(executionLog.indexOf('start-call-5')).toBeGreaterThan(end3); + }); +}); diff --git a/packages/core/src/scheduler/state-manager.ts b/packages/core/src/scheduler/state-manager.ts index b282c3eb78..fb16125340 100644 --- a/packages/core/src/scheduler/state-manager.ts +++ b/packages/core/src/scheduler/state-manager.ts @@ -17,8 +17,7 @@ import type { ExecutingToolCall, ToolCallResponseInfo, } from './types.js'; -import { CoreToolCallStatus } from './types.js'; -import { ROOT_SCHEDULER_ID } from './types.js'; +import { CoreToolCallStatus, ROOT_SCHEDULER_ID } from './types.js'; import type { ToolConfirmationOutcome, ToolResultDisplay, @@ -78,10 +77,18 @@ export class SchedulerStateManager { return next; } + peekQueue(): ToolCall | undefined { + return this.queue[0]; + } + get isActive(): boolean { return this.activeCalls.size > 0; } + get allActiveCalls(): ToolCall[] { + return Array.from(this.activeCalls.values()); + } + get activeCallCount(): number { return this.activeCalls.size; } diff --git a/packages/core/src/scheduler/tool-executor.test.ts b/packages/core/src/scheduler/tool-executor.test.ts index 53b244031d..1cbee019c6 100644 --- a/packages/core/src/scheduler/tool-executor.test.ts +++ b/packages/core/src/scheduler/tool-executor.test.ts @@ -6,13 +6,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ToolExecutor } from './tool-executor.js'; -import type { Config } from '../index.js'; +import type { Config, AnyToolInvocation } from '../index.js'; import type { ToolResult } from '../tools/tools.js'; import { makeFakeConfig } from '../test-utils/config.js'; import { MockTool } from '../test-utils/mock-tool.js'; import type { ScheduledToolCall } from './types.js'; import { CoreToolCallStatus } from './types.js'; -import type { AnyToolInvocation } from '../index.js'; import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; import * as fileUtils from '../utils/fileUtils.js'; import * as coreToolHookTriggers from '../core/coreToolHookTriggers.js'; diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 116598a2b9..b94b0e5184 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -192,6 +192,8 @@ export class ToolExecutor { tool: call.tool, invocation: call.invocation, durationMs: startTime ? Date.now() - startTime : undefined, + startTime, + endTime: Date.now(), outcome: call.outcome, }; } @@ -263,6 +265,8 @@ export class ToolExecutor { response: successResponse, invocation: call.invocation, durationMs: startTime ? Date.now() - startTime : undefined, + startTime, + endTime: Date.now(), outcome: call.outcome, }; } @@ -287,6 +291,8 @@ export class ToolExecutor { response, tool: call.tool, durationMs: startTime ? Date.now() - startTime : undefined, + startTime, + endTime: Date.now(), outcome: call.outcome, }; } diff --git a/packages/core/src/scheduler/types.ts b/packages/core/src/scheduler/types.ts index 7da611f23a..5fe6028bac 100644 --- a/packages/core/src/scheduler/types.ts +++ b/packages/core/src/scheduler/types.ts @@ -86,6 +86,8 @@ export type ErroredToolCall = { response: ToolCallResponseInfo; tool?: AnyDeclarativeTool; durationMs?: number; + startTime?: number; + endTime?: number; outcome?: ToolConfirmationOutcome; schedulerId?: string; approvalMode?: ApprovalMode; @@ -98,6 +100,8 @@ export type SuccessfulToolCall = { response: ToolCallResponseInfo; invocation: AnyToolInvocation; durationMs?: number; + startTime?: number; + endTime?: number; outcome?: ToolConfirmationOutcome; schedulerId?: string; approvalMode?: ApprovalMode; @@ -125,6 +129,8 @@ export type CancelledToolCall = { tool: AnyDeclarativeTool; invocation: AnyToolInvocation; durationMs?: number; + startTime?: number; + endTime?: number; outcome?: ToolConfirmationOutcome; schedulerId?: string; approvalMode?: ApprovalMode; diff --git a/packages/core/src/services/FolderTrustDiscoveryService.test.ts b/packages/core/src/services/FolderTrustDiscoveryService.test.ts new file mode 100644 index 0000000000..b6d7d7734a --- /dev/null +++ b/packages/core/src/services/FolderTrustDiscoveryService.test.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { FolderTrustDiscoveryService } from './FolderTrustDiscoveryService.js'; +import { GEMINI_DIR } from '../utils/paths.js'; + +describe('FolderTrustDiscoveryService', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'gemini-discovery-test-'), + ); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should discover commands, skills, mcps, and hooks', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + + // Mock commands + const commandsDir = path.join(geminiDir, 'commands'); + await fs.mkdir(commandsDir); + await fs.writeFile( + path.join(commandsDir, 'test-cmd.toml'), + 'prompt = "test"', + ); + + // Mock skills + const skillsDir = path.join(geminiDir, 'skills'); + await fs.mkdir(path.join(skillsDir, 'test-skill'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'test-skill', 'SKILL.md'), 'body'); + + // Mock settings (MCPs, Hooks, and general settings) + const settings = { + mcpServers: { + 'test-mcp': { command: 'node', args: ['test.js'] }, + }, + hooks: { + BeforeTool: [{ command: 'test-hook' }], + }, + general: { vimMode: true }, + ui: { theme: 'Dark' }, + }; + await fs.writeFile( + path.join(geminiDir, 'settings.json'), + JSON.stringify(settings), + ); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + + expect(results.commands).toContain('test-cmd'); + expect(results.skills).toContain('test-skill'); + expect(results.mcps).toContain('test-mcp'); + expect(results.hooks).toContain('test-hook'); + expect(results.settings).toContain('general'); + expect(results.settings).toContain('ui'); + expect(results.settings).not.toContain('mcpServers'); + expect(results.settings).not.toContain('hooks'); + }); + + it('should flag security warnings for sensitive settings', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + + const settings = { + tools: { + allowed: ['git'], + sandbox: false, + }, + experimental: { + enableAgents: true, + }, + security: { + folderTrust: { + enabled: false, + }, + }, + }; + await fs.writeFile( + path.join(geminiDir, 'settings.json'), + JSON.stringify(settings), + ); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + + expect(results.securityWarnings).toContain( + 'This project auto-approves certain tools (tools.allowed).', + ); + expect(results.securityWarnings).toContain( + 'This project enables autonomous agents (enableAgents).', + ); + expect(results.securityWarnings).toContain( + 'This project attempts to disable folder trust (security.folderTrust.enabled).', + ); + expect(results.securityWarnings).toContain( + 'This project disables the security sandbox (tools.sandbox).', + ); + }); + + it('should handle missing .gemini directory', async () => { + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.commands).toHaveLength(0); + expect(results.skills).toHaveLength(0); + expect(results.mcps).toHaveLength(0); + expect(results.hooks).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); + + it('should handle malformed settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), 'invalid json'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors[0]).toContain( + 'Failed to discover settings: Unexpected token', + ); + }); + + it('should handle null settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), 'null'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); + + it('should handle array settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), '[]'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); + + it('should handle string settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), '"string"'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); +}); diff --git a/packages/core/src/services/FolderTrustDiscoveryService.ts b/packages/core/src/services/FolderTrustDiscoveryService.ts new file mode 100644 index 0000000000..e81273af22 --- /dev/null +++ b/packages/core/src/services/FolderTrustDiscoveryService.ts @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import stripJsonComments from 'strip-json-comments'; +import { GEMINI_DIR } from '../utils/paths.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { isNodeError } from '../utils/errors.js'; + +export interface FolderDiscoveryResults { + commands: string[]; + mcps: string[]; + hooks: string[]; + skills: string[]; + settings: string[]; + securityWarnings: string[]; + discoveryErrors: string[]; +} + +/** + * A safe, read-only service to discover local configurations in a folder + * before it is trusted. + */ +export class FolderTrustDiscoveryService { + /** + * Discovers configurations in the given workspace directory. + * @param workspaceDir The directory to scan. + * @returns A summary of discovered configurations. + */ + static async discover(workspaceDir: string): Promise { + const results: FolderDiscoveryResults = { + commands: [], + mcps: [], + hooks: [], + skills: [], + settings: [], + securityWarnings: [], + discoveryErrors: [], + }; + + const geminiDir = path.join(workspaceDir, GEMINI_DIR); + if (!(await this.exists(geminiDir))) { + return results; + } + + await Promise.all([ + this.discoverCommands(geminiDir, results), + this.discoverSkills(geminiDir, results), + this.discoverSettings(geminiDir, results), + ]); + + return results; + } + + private static async discoverCommands( + geminiDir: string, + results: FolderDiscoveryResults, + ) { + const commandsDir = path.join(geminiDir, 'commands'); + if (await this.exists(commandsDir)) { + try { + const files = await fs.readdir(commandsDir, { recursive: true }); + results.commands = files + .filter((f) => f.endsWith('.toml')) + .map((f) => path.basename(f, '.toml')); + } catch (e) { + results.discoveryErrors.push( + `Failed to discover commands: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + } + + private static async discoverSkills( + geminiDir: string, + results: FolderDiscoveryResults, + ) { + const skillsDir = path.join(geminiDir, 'skills'); + if (await this.exists(skillsDir)) { + try { + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md'); + if (await this.exists(skillMdPath)) { + results.skills.push(entry.name); + } + } + } + } catch (e) { + results.discoveryErrors.push( + `Failed to discover skills: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + } + + private static async discoverSettings( + geminiDir: string, + results: FolderDiscoveryResults, + ) { + const settingsPath = path.join(geminiDir, 'settings.json'); + if (!(await this.exists(settingsPath))) return; + + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + const settings = JSON.parse(stripJsonComments(content)) as unknown; + + if (!this.isRecord(settings)) { + debugLogger.debug('Settings must be a JSON object'); + return; + } + + results.settings = Object.keys(settings).filter( + (key) => !['mcpServers', 'hooks', '$schema'].includes(key), + ); + + results.securityWarnings = this.collectSecurityWarnings(settings); + + const mcpServers = settings['mcpServers']; + if (this.isRecord(mcpServers)) { + results.mcps = Object.keys(mcpServers); + } + + const hooksConfig = settings['hooks']; + if (this.isRecord(hooksConfig)) { + const hooks = new Set(); + for (const event of Object.values(hooksConfig)) { + if (!Array.isArray(event)) continue; + for (const hook of event) { + if (this.isRecord(hook) && typeof hook['command'] === 'string') { + hooks.add(hook['command']); + } + } + } + results.hooks = Array.from(hooks); + } + } catch (e) { + results.discoveryErrors.push( + `Failed to discover settings: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + private static collectSecurityWarnings( + settings: Record, + ): string[] { + const warnings: string[] = []; + + const tools = this.isRecord(settings['tools']) + ? settings['tools'] + : undefined; + + const experimental = this.isRecord(settings['experimental']) + ? settings['experimental'] + : undefined; + + const security = this.isRecord(settings['security']) + ? settings['security'] + : undefined; + + const folderTrust = + security && this.isRecord(security['folderTrust']) + ? security['folderTrust'] + : undefined; + + const allowedTools = tools?.['allowed']; + + const checks = [ + { + condition: Array.isArray(allowedTools) && allowedTools.length > 0, + message: 'This project auto-approves certain tools (tools.allowed).', + }, + { + condition: experimental?.['enableAgents'] === true, + message: 'This project enables autonomous agents (enableAgents).', + }, + { + condition: folderTrust?.['enabled'] === false, + message: + 'This project attempts to disable folder trust (security.folderTrust.enabled).', + }, + { + condition: tools?.['sandbox'] === false, + message: 'This project disables the security sandbox (tools.sandbox).', + }, + ]; + + for (const check of checks) { + if (check.condition) warnings.push(check.message); + } + + return warnings; + } + + private static isRecord(val: unknown): val is Record { + return !!val && typeof val === 'object' && !Array.isArray(val); + } + + private static async exists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch (e) { + if (isNodeError(e) && e.code === 'ENOENT') { + return false; + } + throw e; + } + } +} diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 44ffe90cf2..5303a1a82a 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -12,7 +12,7 @@ import { tokenLimit } from '../core/tokenLimits.js'; import { getCompressionPrompt } from '../core/prompts.js'; import { getResponseText } from '../utils/partUtils.js'; import { logChatCompression } from '../telemetry/loggers.js'; -import { makeChatCompressionEvent } from '../telemetry/types.js'; +import { makeChatCompressionEvent, LlmRole } from '../telemetry/types.js'; import { saveTruncatedToolOutput, formatTruncatedToolOutput, @@ -29,9 +29,9 @@ import { DEFAULT_GEMINI_MODEL, PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_FLASH_MODEL, + PREVIEW_GEMINI_3_1_MODEL, } from '../config/models.js'; import { PreCompressTrigger } from '../hooks/types.js'; -import { LlmRole } from '../telemetry/types.js'; /** * Default threshold for compression token count as a fraction of the model's @@ -101,6 +101,7 @@ export function findCompressSplitPoint( export function modelStringToModelConfigAlias(model: string): string { switch (model) { case PREVIEW_GEMINI_MODEL: + case PREVIEW_GEMINI_3_1_MODEL: return 'chat-compression-3-pro'; case PREVIEW_GEMINI_FLASH_MODEL: return 'chat-compression-3-flash'; diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 61ba3d32a3..086a7b6ff5 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -86,6 +86,21 @@ describe('ChatRecordingService', () => { expect(files[0]).toMatch(/^session-.*-test-ses\.json$/); }); + it('should include the conversation kind when specified', () => { + chatRecordingService.initialize(undefined, 'subagent'); + chatRecordingService.recordMessage({ + type: 'user', + content: 'ping', + model: 'm', + }); + + const sessionFile = chatRecordingService.getConversationFilePath()!; + const conversation = JSON.parse( + fs.readFileSync(sessionFile, 'utf8'), + ) as ConversationRecord; + expect(conversation.kind).toBe('subagent'); + }); + it('should resume from an existing session if provided', () => { const chatsDir = path.join(testTempDir, 'chats'); fs.mkdirSync(chatsDir, { recursive: true }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 0b94825353..2afbd16657 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -102,6 +102,8 @@ export interface ConversationRecord { summary?: string; /** Workspace directories added during the session via /dir add */ directories?: string[]; + /** The kind of conversation (main agent or subagent) */ + kind?: 'main' | 'subagent'; } /** @@ -128,6 +130,7 @@ export class ChatRecordingService { private cachedLastConvData: string | null = null; private sessionId: string; private projectHash: string; + private kind?: 'main' | 'subagent'; private queuedThoughts: Array = []; private queuedTokens: TokensSummary | null = null; private config: Config; @@ -141,13 +144,21 @@ export class ChatRecordingService { /** * 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(resumedSessionData?: ResumedSessionData): void { + initialize( + resumedSessionData?: ResumedSessionData, + kind?: 'main' | 'subagent', + ): void { 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) => { @@ -180,6 +191,7 @@ export class ChatRecordingService { startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), messages: [], + kind: this.kind, }); } @@ -419,6 +431,7 @@ export class ChatRecordingService { private readConversation(): ConversationRecord { try { this.cachedLastConvData = fs.readFileSync(this.conversationFile!, 'utf8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(this.cachedLastConvData); } catch (error) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -434,6 +447,7 @@ export class ChatRecordingService { startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), messages: [], + kind: this.kind, }; } } diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 8ae2b77898..247b1dacf4 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -18,6 +18,7 @@ import { LoopDetectionDisabledEvent, LoopType, LlmLoopCheckEvent, + LlmRole, } from '../telemetry/types.js'; import type { Config } from '../config/config.js'; import { @@ -25,7 +26,6 @@ import { isFunctionResponse, } from '../utils/messageInspectors.js'; import { debugLogger } from '../utils/debugLogger.js'; -import { LlmRole } from '../telemetry/types.js'; const TOOL_CALL_LOOP_THRESHOLD = 5; const CONTENT_LOOP_THRESHOLD = 10; diff --git a/packages/core/src/services/sessionSummaryUtils.ts b/packages/core/src/services/sessionSummaryUtils.ts index ed51cecd2b..c64f19870d 100644 --- a/packages/core/src/services/sessionSummaryUtils.ts +++ b/packages/core/src/services/sessionSummaryUtils.ts @@ -26,6 +26,7 @@ async function generateAndSaveSummary( ): Promise { // Read session file const content = await fs.readFile(sessionPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const conversation: ConversationRecord = JSON.parse(content); // Skip if summary already exists @@ -69,6 +70,7 @@ async function generateAndSaveSummary( // Re-read the file before writing to handle race conditions const freshContent = await fs.readFile(sessionPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const freshConversation: ConversationRecord = JSON.parse(freshContent); // Check if summary was added by another process @@ -127,6 +129,7 @@ export async function getPreviousSession( try { const content = await fs.readFile(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const conversation: ConversationRecord = JSON.parse(content); if (conversation.summary) { diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 96cae8c269..c21eeb1136 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -568,6 +568,7 @@ export class ShellExecutionService { const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); const args = [...argsPrefix, guardedCommand]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const ptyProcess = ptyInfo.module.spawn(executable, args, { cwd, name: 'xterm-256color', @@ -598,6 +599,7 @@ export class ShellExecutionService { headlessTerminal.scrollToTop(); this.activePtys.set(ptyProcess.pid, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment ptyProcess, headlessTerminal, maxSerializedLines: shellExecutionConfig.maxSerializedLines, @@ -831,6 +833,7 @@ export class ShellExecutionService { signal: signal ?? null, error, aborted: abortSignal.aborted, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment pid: ptyProcess.pid, executionMethod: ptyInfo?.name ?? 'node-pty', }); @@ -862,9 +865,11 @@ export class ShellExecutionService { const abortHandler = async () => { if (ptyProcess.pid && !exited) { await killProcessGroup({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment pid: ptyProcess.pid, escalate: true, isExited: () => exited, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment pty: ptyProcess, }); } @@ -873,6 +878,7 @@ export class ShellExecutionService { abortSignal.addEventListener('abort', abortHandler, { once: true }); }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment return { pid: ptyProcess.pid, result }; } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion diff --git a/packages/core/src/skills/skillLoader.ts b/packages/core/src/skills/skillLoader.ts index 08374ec93a..e746caa179 100644 --- a/packages/core/src/skills/skillLoader.ts +++ b/packages/core/src/skills/skillLoader.ts @@ -7,7 +7,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { glob } from 'glob'; -import yaml from 'js-yaml'; +import { load } from 'js-yaml'; import { debugLogger } from '../utils/debugLogger.js'; import { coreEvents } from '../utils/events.js'; @@ -40,7 +40,7 @@ function parseFrontmatter( content: string, ): { name: string; description: string } | null { try { - const parsed = yaml.load(content); + const parsed = load(content); if (parsed && typeof parsed === 'object') { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const { name, description } = parsed as Record; diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts index c5a00bc11d..ed87bb34fc 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import 'vitest'; import { vi, describe, diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index d0407aa0d6..9a0900d86d 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -716,6 +716,7 @@ export class ClearcutLogger { event.function_name === ASK_USER_TOOL_NAME && event.metadata['ask_user'] ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const askUser = event.metadata['ask_user']; const askUserMapping: { [key: string]: EventMetadataKey } = { question_types: EventMetadataKey.GEMINI_CLI_ASK_USER_QUESTION_TYPES, diff --git a/packages/core/src/telemetry/gcp-exporters.ts b/packages/core/src/telemetry/gcp-exporters.ts index 528b15b22e..c7429383eb 100644 --- a/packages/core/src/telemetry/gcp-exporters.ts +++ b/packages/core/src/telemetry/gcp-exporters.ts @@ -9,9 +9,8 @@ import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter' import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter'; import { Logging } from '@google-cloud/logging'; import type { Log } from '@google-cloud/logging'; -import { hrTimeToMilliseconds } from '@opentelemetry/core'; +import { hrTimeToMilliseconds, ExportResultCode } from '@opentelemetry/core'; import type { ExportResult } from '@opentelemetry/core'; -import { ExportResultCode } from '@opentelemetry/core'; import type { ReadableLogRecord, LogRecordExporter, diff --git a/packages/core/src/telemetry/integration.test.circular.ts b/packages/core/src/telemetry/integration.test.circular.ts index af09b3f8b0..6cb9e84faf 100644 --- a/packages/core/src/telemetry/integration.test.circular.ts +++ b/packages/core/src/telemetry/integration.test.circular.ts @@ -36,11 +36,13 @@ describe('Circular Reference Integration Test', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const socketLike: any = { _httpMessage: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment agent: proxyAgentLike, socket: null, }, }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment socketLike._httpMessage.socket = socketLike; // Create circular reference proxyAgentLike.sockets['cloudcode-pa.googleapis.com:443'] = [socketLike]; @@ -49,6 +51,7 @@ describe('Circular Reference Integration Test', () => { error: new Error('Network error'), function_args: { filePath: '/test/file.txt', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment httpAgent: proxyAgentLike, // This would cause the circular reference }, }; diff --git a/packages/core/src/telemetry/loggers.test.circular.ts b/packages/core/src/telemetry/loggers.test.circular.ts index b8c365cd2c..d6b6ea86ce 100644 --- a/packages/core/src/telemetry/loggers.test.circular.ts +++ b/packages/core/src/telemetry/loggers.test.circular.ts @@ -39,8 +39,10 @@ describe('Circular Reference Handling', () => { sockets: {}, agent: null, }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment circularObject.agent = circularObject; // Create circular reference circularObject.sockets['test-host'] = [ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment { _httpMessage: { agent: circularObject } }, ]; @@ -48,6 +50,7 @@ describe('Circular Reference Handling', () => { const mockRequest: ToolCallRequestInfo = { callId: 'test-call-id', name: 'ReadFile', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment args: circularObject, // This would cause the original error isClientInitiated: false, prompt_id: 'test-prompt-id', diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 316cf0b33f..db0e44be25 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -10,6 +10,7 @@ import type { CompletedToolCall, ContentGeneratorConfig, ErroredToolCall, + MessageBus, } from '../index.js'; import { CoreToolCallStatus, @@ -19,11 +20,10 @@ import { ToolConfirmationOutcome, ToolErrorType, ToolRegistry, - type MessageBus, } from '../index.js'; import { OutputFormat } from '../output/types.js'; import { logs } from '@opentelemetry/api-logs'; -import type { Config } from '../config/config.js'; +import type { Config, GeminiCLIExtension } from '../config/config.js'; import { logApiError, logApiRequest, @@ -100,7 +100,6 @@ import { FileOperation } from './metrics.js'; import * as sdk from './sdk.js'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; import { vi, describe, beforeEach, it, expect, afterEach } from 'vitest'; -import { type GeminiCLIExtension } from '../config/config.js'; import { FinishReason, type CallableTool, diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 54bec22a65..b4935f3af7 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -145,12 +145,14 @@ export function logToolCall(config: Config, event: ToolCallEvent): void { }); if (event.metadata) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const added = event.metadata['model_added_lines']; if (typeof added === 'number' && added > 0) { recordLinesChanged(config, added, 'added', { function_name: event.function_name, }); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const removed = event.metadata['model_removed_lines']; if (typeof removed === 'number' && removed > 0) { recordLinesChanged(config, removed, 'removed', { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 497ff97469..e1a4079f3d 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -243,6 +243,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { mcp_server_name?: string; extension_name?: string; extension_id?: string; + start_time?: number; + end_time?: number; // eslint-disable-next-line @typescript-eslint/no-explicit-any metadata?: { [key: string]: any }; @@ -256,6 +258,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { prompt_id: string, tool_type: 'native' | 'mcp', error?: string, + start_time?: number, + end_time?: number, ); constructor( call?: CompletedToolCall, @@ -266,6 +270,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { prompt_id?: string, tool_type?: 'native' | 'mcp', error?: string, + start_time?: number, + end_time?: number, ) { this['event.name'] = 'tool_call'; this['event.timestamp'] = new Date().toISOString(); @@ -282,6 +288,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { this.error_type = call.response.errorType; this.prompt_id = call.request.prompt_id; this.content_length = call.response.contentLength; + this.start_time = call.startTime; + this.end_time = call.endTime; if ( typeof call.tool !== 'undefined' && call.tool instanceof DiscoveredMCPTool @@ -332,6 +340,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { this.prompt_id = prompt_id!; this.tool_type = tool_type!; this.error = error; + this.start_time = start_time; + this.end_time = end_time; } } @@ -351,6 +361,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { mcp_server_name: this.mcp_server_name, extension_name: this.extension_name, extension_id: this.extension_id, + start_time: this.start_time, + end_time: this.end_time, metadata: this.metadata, }; diff --git a/packages/core/src/telemetry/uiTelemetry.test.ts b/packages/core/src/telemetry/uiTelemetry.test.ts index 52f0911730..d1a3b1a9a6 100644 --- a/packages/core/src/telemetry/uiTelemetry.test.ts +++ b/packages/core/src/telemetry/uiTelemetry.test.ts @@ -8,8 +8,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { UiTelemetryService } from './uiTelemetry.js'; import { ToolCallDecision } from './tool-call-decision.js'; import type { ApiErrorEvent, ApiResponseEvent } from './types.js'; -import { ToolCallEvent } from './types.js'; import { + ToolCallEvent, EVENT_API_ERROR, EVENT_API_RESPONSE, EVENT_TOOL_CALL, diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index 8c9f2adb83..669b6a8c68 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -16,10 +16,9 @@ import type { ApiErrorEvent, ApiResponseEvent, ToolCallEvent, + LlmRole, } from './types.js'; -import type { LlmRole } from './types.js'; - export type UiEvent = | (ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE }) | (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }) diff --git a/packages/core/src/tools/__snapshots__/read-file.test.ts.snap b/packages/core/src/tools/__snapshots__/read-file.test.ts.snap index c6adf2819d..de36bd639e 100644 --- a/packages/core/src/tools/__snapshots__/read-file.test.ts.snap +++ b/packages/core/src/tools/__snapshots__/read-file.test.ts.snap @@ -1,5 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`ReadFileTool > getSchema > should return the base schema when no modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`; +exports[`ReadFileTool > getSchema > should return the base schema when no modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`; -exports[`ReadFileTool > getSchema > should return the schema from the resolver when modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`; +exports[`ReadFileTool > getSchema > should return the schema from the resolver when modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`; diff --git a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap index 8aa86f60a7..7e3e1dcf80 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -411,20 +411,20 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snapshot for tool: read_file 1`] = ` { - "description": "Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.", + "description": "Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.", "name": "read_file", "parametersJsonSchema": { "properties": { + "end_line": { + "description": "Optional: The 1-based line number to end reading at (inclusive).", + "type": "number", + }, "file_path": { "description": "The path to the file to read.", "type": "string", }, - "limit": { - "description": "Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).", - "type": "number", - }, - "offset": { - "description": "Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.", + "start_line": { + "description": "Optional: The 1-based line number to start reading from.", "type": "number", }, }, @@ -1089,7 +1089,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: grep_search_ripgrep 1`] = ` { - "description": "Searches for a regular expression pattern within file contents.", + "description": "Searches for a regular expression pattern within file contents. This tool is FAST and optimized, powered by ripgrep. PREFERRED over standard \`run_shell_command("grep ...")\` due to better performance and automatic output limiting (defaults to 100 matches, but can be increased via \`total_max_matches\`).", "name": "grep_search", "parametersJsonSchema": { "properties": { @@ -1200,20 +1200,20 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: read_file 1`] = ` { - "description": "Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.", + "description": "Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.", "name": "read_file", "parametersJsonSchema": { "properties": { + "end_line": { + "description": "Optional: The 1-based line number to end reading at (inclusive).", + "type": "number", + }, "file_path": { "description": "The path to the file to read.", "type": "string", }, - "limit": { - "description": "Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).", - "type": "number", - }, - "offset": { - "description": "Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.", + "start_line": { + "description": "Optional: The 1-based line number to start reading from.", "type": "number", }, }, @@ -1293,18 +1293,8 @@ Use this tool when the user's query implies needing the content of several files exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: replace 1`] = ` { - "description": "Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the read_file tool to examine the file's current content before attempting a text replacement. - - The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. - - Expectation for required parameters: - 1. \`old_string\` MUST be the exact literal text to replace (including all whitespace, indentation, newlines, and surrounding code etc.). - 2. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic and that \`old_string\` and \`new_string\` are different. - 3. \`instruction\` is the detailed instruction of what needs to be changed. It is important to Make it specific and detailed so developers or large language models can understand what needs to be changed and perform the changes on their own if necessary. - 4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. - **Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. - 5. Prefer to break down complex and long changes into multiple smaller atomic calls to this tool. Always check the content of the file after changes or not finding a string to match. - **Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.", + "description": "Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences ONLY when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. +The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.", "name": "replace", "parametersJsonSchema": { "properties": { @@ -1318,29 +1308,15 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > "type": "string", }, "instruction": { - "description": "A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change. - -A good instruction should concisely answer: -1. WHY is the change needed? (e.g., "To fix a bug where users can be null...") -2. WHERE should the change happen? (e.g., "...in the 'renderUserProfile' function...") -3. WHAT is the high-level change? (e.g., "...add a null check for the 'user' object...") -4. WHAT is the desired outcome? (e.g., "...so that it displays a loading spinner instead of crashing.") - -**GOOD Example:** "In the 'calculateTotal' function, correct the sales tax calculation by updating the 'taxRate' constant from 0.05 to 0.075 to reflect the new regional tax laws." - -**BAD Examples:** -- "Change the text." (Too vague) -- "Fix the bug." (Doesn't explain the bug or the fix) -- "Replace the line with this new line." (Brittle, just repeats the other parameters) -", + "description": "A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change.", "type": "string", }, "new_string": { - "description": "The exact literal text to replace \`old_string\` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.", + "description": "The exact literal text to replace \`old_string\` with, unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.", "type": "string", }, "old_string": { - "description": "The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.", + "description": "The exact literal text to replace, unescaped. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.", "type": "string", }, }, @@ -1448,8 +1424,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: write_file 1`] = ` { "description": "Writes content to a specified file in the local filesystem. - - The user has the ability to modify \`content\`. If modified, this will be stated in the response.", +The user has the ability to modify \`content\`. If modified, this will be stated in the response.", "name": "write_file", "parametersJsonSchema": { "properties": { diff --git a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts index bae510be9e..fad72047a9 100644 --- a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts +++ b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts @@ -35,7 +35,7 @@ import { export const DEFAULT_LEGACY_SET: CoreToolSet = { read_file: { name: READ_FILE_TOOL_NAME, - description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`, + description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`, parametersJsonSchema: { type: 'object', properties: { @@ -43,14 +43,14 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = { description: 'The path to the file to read.', type: 'string', }, - offset: { + start_line: { description: - "Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.", + 'Optional: The 1-based line number to start reading from.', type: 'number', }, - limit: { + end_line: { description: - "Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).", + 'Optional: The 1-based line number to end reading at (inclusive).', type: 'number', }, }, diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index a532cac8ba..1ceca46d9f 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -38,7 +38,7 @@ import { export const GEMINI_3_SET: CoreToolSet = { read_file: { name: READ_FILE_TOOL_NAME, - description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`, + description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`, parametersJsonSchema: { type: 'object', properties: { @@ -46,14 +46,14 @@ export const GEMINI_3_SET: CoreToolSet = { description: 'The path to the file to read.', type: 'string', }, - offset: { + start_line: { description: - "Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.", + 'Optional: The 1-based line number to start reading from.', type: 'number', }, - limit: { + end_line: { description: - "Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).", + 'Optional: The 1-based line number to end reading at (inclusive).', type: 'number', }, }, @@ -64,8 +64,7 @@ export const GEMINI_3_SET: CoreToolSet = { write_file: { name: WRITE_FILE_TOOL_NAME, description: `Writes content to a specified file in the local filesystem. - - The user has the ability to modify \`content\`. If modified, this will be stated in the response.`, +The user has the ability to modify \`content\`. If modified, this will be stated in the response.`, parametersJsonSchema: { type: 'object', properties: { @@ -132,7 +131,7 @@ export const GEMINI_3_SET: CoreToolSet = { grep_search_ripgrep: { name: GREP_TOOL_NAME, description: - 'Searches for a regular expression pattern within file contents.', + 'Searches for a regular expression pattern within file contents. This tool is FAST and optimized, powered by ripgrep. PREFERRED over standard `run_shell_command("grep ...")` due to better performance and automatic output limiting (defaults to 100 matches, but can be increased via `total_max_matches`).', parametersJsonSchema: { type: 'object', properties: { @@ -291,18 +290,8 @@ export const GEMINI_3_SET: CoreToolSet = { replace: { name: EDIT_TOOL_NAME, - description: `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${READ_FILE_TOOL_NAME} tool to examine the file's current content before attempting a text replacement. - - The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. - - Expectation for required parameters: - 1. \`old_string\` MUST be the exact literal text to replace (including all whitespace, indentation, newlines, and surrounding code etc.). - 2. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic and that \`old_string\` and \`new_string\` are different. - 3. \`instruction\` is the detailed instruction of what needs to be changed. It is important to Make it specific and detailed so developers or large language models can understand what needs to be changed and perform the changes on their own if necessary. - 4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. - **Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. - 5. Prefer to break down complex and long changes into multiple smaller atomic calls to this tool. Always check the content of the file after changes or not finding a string to match. - **Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`, + description: `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences ONLY when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. +The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.`, parametersJsonSchema: { type: 'object', properties: { @@ -311,31 +300,17 @@ export const GEMINI_3_SET: CoreToolSet = { type: 'string', }, instruction: { - description: `A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change. - -A good instruction should concisely answer: -1. WHY is the change needed? (e.g., "To fix a bug where users can be null...") -2. WHERE should the change happen? (e.g., "...in the 'renderUserProfile' function...") -3. WHAT is the high-level change? (e.g., "...add a null check for the 'user' object...") -4. WHAT is the desired outcome? (e.g., "...so that it displays a loading spinner instead of crashing.") - -**GOOD Example:** "In the 'calculateTotal' function, correct the sales tax calculation by updating the 'taxRate' constant from 0.05 to 0.075 to reflect the new regional tax laws." - -**BAD Examples:** -- "Change the text." (Too vague) -- "Fix the bug." (Doesn't explain the bug or the fix) -- "Replace the line with this new line." (Brittle, just repeats the other parameters) -`, + description: `A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change.`, type: 'string', }, old_string: { description: - 'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', + 'The exact literal text to replace, unescaped. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', type: 'string', }, new_string: { description: - 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', + 'The exact literal text to replace `old_string` with, unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', type: 'string', }, expected_replacements: { diff --git a/packages/core/src/tools/diff-utils.test.ts b/packages/core/src/tools/diff-utils.test.ts new file mode 100644 index 0000000000..7d19f7af19 --- /dev/null +++ b/packages/core/src/tools/diff-utils.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { getDiffContextSnippet } from './diff-utils.js'; + +describe('getDiffContextSnippet', () => { + it('should return the whole new content if originalContent is empty', () => { + const original = ''; + const modified = 'line1\nline2\nline3'; + expect(getDiffContextSnippet(original, modified)).toBe(modified); + }); + + it('should return the whole content if there are no changes', () => { + const content = 'line1\nline2\nline3'; + expect(getDiffContextSnippet(content, content)).toBe(content); + }); + + it('should show added lines with context', () => { + const original = '1\n2\n3\n4\n5\n6\n7\n8\n9\n10'; + const modified = '1\n2\n3\n4\n5\nadded\n6\n7\n8\n9\n10'; + // Default context is 5 lines. + expect(getDiffContextSnippet(original, modified)).toBe(modified); + }); + + it('should use ellipses for changes far apart', () => { + const original = Array.from({ length: 20 }, (_, i) => `${i + 1}`).join( + '\n', + ); + const modified = original + .replace('2\n', '2\nadded1\n') + .replace('19', '19\nadded2'); + const snippet = getDiffContextSnippet(original, modified, 2); + + expect(snippet).toContain('1\n2\nadded1\n3\n4'); + expect(snippet).toContain('...'); + expect(snippet).toContain('18\n19\nadded2\n20'); + }); + + it('should respect custom contextLines', () => { + const original = '1\n2\n3\n4\n5\n6\n7\n8\n9\n10'; + const modified = '1\n2\n3\n4\n5\nadded\n6\n7\n8\n9\n10'; + const snippet = getDiffContextSnippet(original, modified, 1); + + expect(snippet).toBe('...\n5\nadded\n6\n...'); + }); + + it('should handle multiple changes close together by merging ranges', () => { + const original = '1\n2\n3\n4\n5\n6\n7\n8\n9\n10'; + const modified = '1\nadded1\n2\nadded2\n3\n4\n5\n6\n7\n8\n9\n10'; + const snippet = getDiffContextSnippet(original, modified, 1); + + expect(snippet).toBe('1\nadded1\n2\nadded2\n3\n...'); + }); + + it('should handle removals', () => { + const original = '1\n2\n3\n4\n5\n6\n7\n8\n9\n10'; + const modified = '1\n2\n3\n4\n6\n7\n8\n9\n10'; + const snippet = getDiffContextSnippet(original, modified, 1); + + expect(snippet).toBe('...\n4\n6\n...'); + }); +}); diff --git a/packages/core/src/tools/diff-utils.ts b/packages/core/src/tools/diff-utils.ts new file mode 100644 index 0000000000..9c44a1756e --- /dev/null +++ b/packages/core/src/tools/diff-utils.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Diff from 'diff'; + +/** + * Generates a snippet of the diff between two strings, including a few lines of context around the changes. + */ +export function getDiffContextSnippet( + originalContent: string, + newContent: string, + contextLines = 5, +): string { + if (!originalContent) { + return newContent; + } + + const changes = Diff.diffLines(originalContent, newContent); + const newLines = newContent.split(/\r?\n/); + const ranges: Array<{ start: number; end: number }> = []; + let newLineIdx = 0; + + for (const change of changes) { + if (change.added) { + ranges.push({ start: newLineIdx, end: newLineIdx + (change.count ?? 0) }); + newLineIdx += change.count ?? 0; + } else if (change.removed) { + ranges.push({ start: newLineIdx, end: newLineIdx }); + } else { + newLineIdx += change.count ?? 0; + } + } + + if (ranges.length === 0) { + return newContent; + } + + const expandedRanges = ranges.map((r) => ({ + start: Math.max(0, r.start - contextLines), + end: Math.min(newLines.length, r.end + contextLines), + })); + expandedRanges.sort((a, b) => a.start - b.start); + const mergedRanges: Array<{ start: number; end: number }> = []; + + if (expandedRanges.length > 0) { + let current = expandedRanges[0]; + for (let i = 1; i < expandedRanges.length; i++) { + const next = expandedRanges[i]; + if (next.start <= current.end) { + current.end = Math.max(current.end, next.end); + } else { + mergedRanges.push(current); + current = next; + } + } + mergedRanges.push(current); + } + + const outputParts: string[] = []; + let lastEnd = 0; + + for (const range of mergedRanges) { + if (range.start > lastEnd) outputParts.push('...'); + outputParts.push(newLines.slice(range.start, range.end).join('\n')); + lastEnd = range.end; + } + + if (lastEnd < newLines.length) { + outputParts.push('...'); + } + return outputParts.join('\n'); +} diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 130a05a8fe..d758e03229 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -30,6 +30,7 @@ import { ApprovalMode } from '../policy/types.js'; import { CoreToolCallStatus } from '../scheduler/types.js'; import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js'; +import { getDiffContextSnippet } from './diff-utils.js'; import { type ModifiableDeclarativeTool, type ModifyContext, @@ -37,10 +38,11 @@ import { import { IdeClient } from '../ide/ide-client.js'; import { FixLLMEditWithInstruction } from '../utils/llm-edit-fixer.js'; import { safeLiteralReplace, detectLineEnding } from '../utils/textUtils.js'; -import { EditStrategyEvent } from '../telemetry/types.js'; -import { logEditStrategy } from '../telemetry/loggers.js'; -import { EditCorrectionEvent } from '../telemetry/types.js'; -import { logEditCorrectionEvent } from '../telemetry/loggers.js'; +import { EditStrategyEvent, EditCorrectionEvent } from '../telemetry/types.js'; +import { + logEditStrategy, + logEditCorrectionEvent, +} from '../telemetry/loggers.js'; import { correctPath } from '../utils/pathCorrector.js'; import { @@ -871,6 +873,16 @@ class EditToolInvocation ? `Created new file: ${this.params.file_path} with provided content.` : `Successfully modified file: ${this.params.file_path} (${editData.occurrences} replacements).`, ]; + + // Return a diff of the file before and after the write so that the agent + // can avoid the need to spend a turn doing a verification read. + const snippet = getDiffContextSnippet( + editData.currentContent ?? '', + finalContent, + 5, + ); + llmSuccessMessageParts.push(`Here is the updated code: +${snippet}`); const fuzzyFeedback = getFuzzyMatchFeedback(editData); if (fuzzyFeedback) { llmSuccessMessageParts.push(fuzzyFeedback); diff --git a/packages/core/src/tools/enter-plan-mode.test.ts b/packages/core/src/tools/enter-plan-mode.test.ts index 0b1d0a37f0..48bc5b494e 100644 --- a/packages/core/src/tools/enter-plan-mode.test.ts +++ b/packages/core/src/tools/enter-plan-mode.test.ts @@ -24,7 +24,7 @@ describe('EnterPlanModeTool', () => { mockConfig = { setApprovalMode: vi.fn(), storage: { - getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), } as unknown as Config['storage'], }; tool = new EnterPlanModeTool( diff --git a/packages/core/src/tools/exit-plan-mode.test.ts b/packages/core/src/tools/exit-plan-mode.test.ts index 3e226c5142..22de81fc7f 100644 --- a/packages/core/src/tools/exit-plan-mode.test.ts +++ b/packages/core/src/tools/exit-plan-mode.test.ts @@ -45,7 +45,7 @@ describe('ExitPlanModeTool', () => { setApprovalMode: vi.fn(), setApprovedPlanPath: vi.fn(), storage: { - getProjectTempPlansDir: vi.fn().mockReturnValue(mockPlansDir), + getPlansDir: vi.fn().mockReturnValue(mockPlansDir), } as unknown as Config['storage'], }; tool = new ExitPlanModeTool( diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts index a0540b11e3..c11eaa119e 100644 --- a/packages/core/src/tools/exit-plan-mode.ts +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -57,7 +57,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< private config: Config, messageBus: MessageBus, ) { - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.storage.getPlansDir(); const definition = getExitPlanModeDefinition(plansDir); super( EXIT_PLAN_MODE_TOOL_NAME, @@ -78,9 +78,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< // Since validateToolParamValues is synchronous, we use a basic synchronous check // for path traversal safety. High-level async validation is deferred to shouldConfirmExecute. - const plansDir = resolveToRealPath( - this.config.storage.getProjectTempPlansDir(), - ); + const plansDir = resolveToRealPath(this.config.storage.getPlansDir()); const resolvedPath = path.resolve( this.config.getTargetDir(), params.plan_path, @@ -111,7 +109,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< } override getSchema(modelId?: string) { - const plansDir = this.config.storage.getProjectTempPlansDir(); + const plansDir = this.config.storage.getPlansDir(); return resolveToolDeclaration(getExitPlanModeDefinition(plansDir), modelId); } } @@ -141,7 +139,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation< const pathError = await validatePlanPath( this.params.plan_path, - this.config.storage.getProjectTempPlansDir(), + this.config.storage.getPlansDir(), this.config.getTargetDir(), ); if (pathError) { diff --git a/packages/core/src/tools/grep-utils.ts b/packages/core/src/tools/grep-utils.ts new file mode 100644 index 0000000000..27c744f60c --- /dev/null +++ b/packages/core/src/tools/grep-utils.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fsPromises from 'node:fs/promises'; +import { debugLogger } from '../utils/debugLogger.js'; + +/** + * Result object for a single grep match + */ +export interface GrepMatch { + filePath: string; + absolutePath: string; + lineNumber: number; + line: string; + isContext?: boolean; +} + +/** + * Groups matches by their file path and ensures they are sorted by line number. + */ +export function groupMatchesByFile( + allMatches: GrepMatch[], +): Record { + const groups: Record = {}; + + for (const match of allMatches) { + if (!groups[match.filePath]) { + groups[match.filePath] = []; + } + groups[match.filePath].push(match); + } + + for (const filePath in groups) { + groups[filePath].sort((a, b) => a.lineNumber - b.lineNumber); + } + + return groups; +} + +/** + * Reads the content of a file and splits it into lines. + * Returns null if the file cannot be read. + */ +export async function readFileLines( + absolutePath: string, +): Promise { + try { + const content = await fsPromises.readFile(absolutePath, 'utf8'); + return content.split(/\r?\n/); + } catch (err) { + debugLogger.warn(`Failed to read file for context: ${absolutePath}`, err); + return null; + } +} + +/** + * Automatically enriches grep results with surrounding context if the match count is low + * and no specific context was requested. This optimization can enable the agent + * to skip turns that would be spent reading files after grep calls. + */ +export async function enrichWithAutoContext( + matchesByFile: Record, + matchCount: number, + params: { + names_only?: boolean; + context?: number; + before?: number; + after?: number; + }, +): Promise { + const { names_only, context, before, after } = params; + + if ( + matchCount >= 1 && + matchCount <= 3 && + !names_only && + context === undefined && + before === undefined && + after === undefined + ) { + const contextLines = matchCount === 1 ? 50 : 15; + for (const filePath in matchesByFile) { + const fileMatches = matchesByFile[filePath]; + if (fileMatches.length === 0) continue; + + const fileLines = await readFileLines(fileMatches[0].absolutePath); + + if (fileLines) { + const newFileMatches: GrepMatch[] = []; + const seenLines = new Set(); + + // Sort matches to process them in order + fileMatches.sort((a, b) => a.lineNumber - b.lineNumber); + + for (const match of fileMatches) { + const startLine = Math.max(0, match.lineNumber - 1 - contextLines); + const endLine = Math.min( + fileLines.length, + match.lineNumber - 1 + contextLines + 1, + ); + + for (let i = startLine; i < endLine; i++) { + const lineNum = i + 1; + if (!seenLines.has(lineNum)) { + newFileMatches.push({ + absolutePath: match.absolutePath, + filePath: match.filePath, + lineNumber: lineNum, + line: fileLines[i], + isContext: lineNum !== match.lineNumber, + }); + seenLines.add(lineNum); + } else if (lineNum === match.lineNumber) { + const existing = newFileMatches.find( + (m) => m.lineNumber === lineNum, + ); + if (existing) { + existing.isContext = false; + } + } + } + } + matchesByFile[filePath] = newFileMatches.sort( + (a, b) => a.lineNumber - b.lineNumber, + ); + } + } + } +} + +/** + * Formats the grep results for the LLM, including optional context. + */ +export async function formatGrepResults( + allMatches: GrepMatch[], + params: { + pattern: string; + names_only?: boolean; + include?: string; + // Context params to determine if auto-context should be skipped + context?: number; + before?: number; + after?: number; + }, + searchLocationDescription: string, + totalMaxMatches: number, +): Promise<{ llmContent: string; returnDisplay: string }> { + const { pattern, names_only, include } = params; + + if (allMatches.length === 0) { + const noMatchMsg = `No matches found for pattern "${pattern}" ${searchLocationDescription}${include ? ` (filter: "${include}")` : ''}.`; + return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; + } + + const matchesByFile = groupMatchesByFile(allMatches); + + const matchesOnly = allMatches.filter((m) => !m.isContext); + const matchCount = matchesOnly.length; // Count actual matches, not context lines + const matchTerm = matchCount === 1 ? 'match' : 'matches'; + + // If the result count is low and Gemini didn't request before/after lines of context + // add a small amount anyways to enable the agent to avoid one or more extra turns + // reading the matched files. This optimization reduces turns count by ~10% in SWEBench. + await enrichWithAutoContext(matchesByFile, matchCount, params); + + const wasTruncated = matchCount >= totalMaxMatches; + + if (names_only) { + const filePaths = Object.keys(matchesByFile).sort(); + let llmContent = `Found ${filePaths.length} files with matches for pattern "${pattern}" ${searchLocationDescription}${ + include ? ` (filter: "${include}")` : '' + }${ + wasTruncated + ? ` (results limited to ${totalMaxMatches} matches for performance)` + : '' + }:\n`; + llmContent += filePaths.join('\n'); + return { + llmContent: llmContent.trim(), + returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`, + }; + } + + let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${pattern}" ${searchLocationDescription}${include ? ` (filter: "${include}")` : ''}`; + + if (wasTruncated) { + llmContent += ` (results limited to ${totalMaxMatches} matches for performance)`; + } + + llmContent += `:\n---\n`; + + for (const filePath in matchesByFile) { + llmContent += `File: ${filePath}\n`; + matchesByFile[filePath].forEach((match) => { + // If isContext is undefined, assume it's a match (false) + const separator = match.isContext ? '-' : ':'; + // trimEnd to avoid double newlines if line has them, but we want to preserve indentation + llmContent += `L${match.lineNumber}${separator} ${match.line.trimEnd()}\n`; + }); + llmContent += '---\n'; + } + + return { + llmContent: llmContent.trim(), + returnDisplay: `Found ${matchCount} ${matchTerm}${ + wasTruncated ? ' (limited)' : '' + }`, + }; +} diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index cecc32d5f1..f696495253 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -493,7 +493,9 @@ describe('GrepTool', () => { // sub/fileC.txt has 1 world, so total matches = 2. expect(result.llmContent).toContain('Found 2 matches'); expect(result.llmContent).toContain('File: fileA.txt'); + // Should be a match expect(result.llmContent).toContain('L1: hello world'); + // Should NOT be a match (but might be in context as L2-) expect(result.llmContent).not.toContain('L2: second line with world'); expect(result.llmContent).toContain('File: sub/fileC.txt'); expect(result.llmContent).toContain('L1: another world in sub dir'); @@ -530,8 +532,33 @@ describe('GrepTool', () => { expect(result.llmContent).toContain('Found 1 match'); expect(result.llmContent).toContain('copyright.txt'); - expect(result.llmContent).toContain('Copyright 2025 Google LLC'); - expect(result.llmContent).not.toContain('Copyright 2026 Google LLC'); + // Should be a match + expect(result.llmContent).toContain('L1: Copyright 2025 Google LLC'); + // Should NOT be a match (but might be in context as L2-) + expect(result.llmContent).not.toContain('L2: Copyright 2026 Google LLC'); + }); + + it('should include context when matches are <= 3', async () => { + const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); + lines[50] = 'Target match'; + await fs.writeFile( + path.join(tempRootDir, 'context.txt'), + lines.join('\n'), + ); + + const params: GrepToolParams = { pattern: 'Target match' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain( + 'Found 1 match for pattern "Target match"', + ); + // Verify context before + expect(result.llmContent).toContain('L40- Line 40'); + // Verify match line + expect(result.llmContent).toContain('L51: Target match'); + // Verify context after + expect(result.llmContent).toContain('L60- Line 60'); }); }); diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index b1fdb9474c..92fe58288d 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -27,6 +27,7 @@ import { GREP_TOOL_NAME } from './tool-names.js'; import { debugLogger } from '../utils/debugLogger.js'; import { GREP_DEFINITION } from './definitions/coreTools.js'; import { resolveToolDeclaration } from './definitions/resolver.js'; +import { type GrepMatch, formatGrepResults } from './grep-utils.js'; // --- Interfaces --- @@ -70,15 +71,6 @@ export interface GrepToolParams { total_max_matches?: number; } -/** - * Result object for a single grep match - */ -interface GrepMatch { - filePath: string; - lineNumber: number; - line: string; -} - class GrepToolInvocation extends BaseToolInvocation< GrepToolParams, ToolResult @@ -130,6 +122,7 @@ class GrepToolInvocation extends BaseToolInvocation< return { filePath: relativeFilePath || path.basename(absoluteFilePath), + absolutePath: absoluteFilePath, lineNumber, line: lineContent, }; @@ -267,62 +260,12 @@ class GrepToolInvocation extends BaseToolInvocation< searchLocationDescription = `in path "${searchDirDisplay}"`; } - if (allMatches.length === 0) { - const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`; - return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; - } - - const wasTruncated = allMatches.length >= totalMaxMatches; - - // Group matches by file - const matchesByFile = allMatches.reduce( - (acc, match) => { - const fileKey = match.filePath; - if (!acc[fileKey]) { - acc[fileKey] = []; - } - acc[fileKey].push(match); - acc[fileKey].sort((a, b) => a.lineNumber - b.lineNumber); - return acc; - }, - {} as Record, + return await formatGrepResults( + allMatches, + this.params, + searchLocationDescription, + totalMaxMatches, ); - - const matchCount = allMatches.length; - const matchTerm = matchCount === 1 ? 'match' : 'matches'; - - if (this.params.names_only) { - const filePaths = Object.keys(matchesByFile).sort(); - let llmContent = `Found ${filePaths.length} files with matches for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n`; - llmContent += filePaths.join('\n'); - return { - llmContent: llmContent.trim(), - returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`, - }; - } - - let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`; - - if (wasTruncated) { - llmContent += ` (results limited to ${totalMaxMatches} matches for performance)`; - } - - llmContent += `:\n---\n`; - - for (const filePath in matchesByFile) { - llmContent += `File: ${filePath} -`; - matchesByFile[filePath].forEach((match) => { - const trimmedLine = match.line.trim(); - llmContent += `L${match.lineNumber}: ${trimmedLine}\n`; - }); - llmContent += '---\n'; - } - - return { - llmContent: llmContent.trim(), - returnDisplay: `Found ${matchCount} ${matchTerm}${wasTruncated ? ' (limited)' : ''}`, - }; } catch (error) { debugLogger.warn(`Error during GrepLogic execution: ${error}`); const errorMessage = getErrorMessage(error); @@ -569,6 +512,7 @@ class GrepToolInvocation extends BaseToolInvocation< filePath: path.relative(absolutePath, fileAbsolutePath) || path.basename(fileAbsolutePath), + absolutePath: fileAbsolutePath, lineNumber: index + 1, line, }); diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 19430c2f9a..3e592825dd 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -2056,6 +2056,90 @@ describe('connectToMcpServer with OAuth', () => { capturedTransport._requestInit?.headers?.['Authorization']; expect(authHeader).toBe('Bearer test-access-token-from-discovery'); }); + + it('should use discoverOAuthFromWWWAuthenticate when it succeeds and skip discoverOAuthConfig', async () => { + const serverUrl = 'http://test-server.com/mcp'; + const authUrl = 'http://auth.example.com/auth'; + const tokenUrl = 'http://auth.example.com/token'; + const wwwAuthHeader = `Bearer realm="test", resource_metadata="http://test-server.com/.well-known/oauth-protected-resource"`; + + vi.mocked(mockedClient.connect).mockRejectedValueOnce( + new StreamableHTTPError( + 401, + `Unauthorized\nwww-authenticate: ${wwwAuthHeader}`, + ), + ); + + vi.mocked(OAuthUtils.discoverOAuthFromWWWAuthenticate).mockResolvedValue({ + authorizationUrl: authUrl, + tokenUrl, + scopes: ['read'], + }); + + vi.mocked(mockedClient.connect).mockResolvedValueOnce(undefined); + + const client = await connectToMcpServer( + '0.0.1', + 'test-server', + { httpUrl: serverUrl, oauth: { enabled: true } }, + false, + workspaceContext, + EMPTY_CONFIG, + ); + + expect(client).toBe(mockedClient); + expect(OAuthUtils.discoverOAuthFromWWWAuthenticate).toHaveBeenCalledWith( + wwwAuthHeader, + serverUrl, + ); + expect(OAuthUtils.discoverOAuthConfig).not.toHaveBeenCalled(); + expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce(); + }); + + it('should fall back to extractBaseUrl + discoverOAuthConfig when discoverOAuthFromWWWAuthenticate returns null', async () => { + const serverUrl = 'http://test-server.com/mcp'; + const baseUrl = 'http://test-server.com'; + const authUrl = 'http://auth.example.com/auth'; + const tokenUrl = 'http://auth.example.com/token'; + const wwwAuthHeader = `Bearer realm="test"`; + + vi.mocked(mockedClient.connect).mockRejectedValueOnce( + new StreamableHTTPError( + 401, + `Unauthorized\nwww-authenticate: ${wwwAuthHeader}`, + ), + ); + + vi.mocked(OAuthUtils.discoverOAuthFromWWWAuthenticate).mockResolvedValue( + null, + ); + vi.mocked(OAuthUtils.extractBaseUrl).mockReturnValue(baseUrl); + vi.mocked(OAuthUtils.discoverOAuthConfig).mockResolvedValue({ + authorizationUrl: authUrl, + tokenUrl, + scopes: ['read'], + }); + + vi.mocked(mockedClient.connect).mockResolvedValueOnce(undefined); + + const client = await connectToMcpServer( + '0.0.1', + 'test-server', + { httpUrl: serverUrl, oauth: { enabled: true } }, + false, + workspaceContext, + EMPTY_CONFIG, + ); + + expect(client).toBe(mockedClient); + expect(OAuthUtils.discoverOAuthFromWWWAuthenticate).toHaveBeenCalledWith( + wwwAuthHeader, + serverUrl, + ); + expect(OAuthUtils.extractBaseUrl).toHaveBeenCalledWith(serverUrl); + expect(OAuthUtils.discoverOAuthConfig).toHaveBeenCalledWith(baseUrl); + expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce(); + }); }); describe('connectToMcpServer - HTTP→SSE fallback', () => { diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index a838cf76e5..5e802e8157 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -22,6 +22,7 @@ import type { Prompt, ReadResourceResult, Resource, + Tool as McpTool, } from '@modelcontextprotocol/sdk/types.js'; import { ListResourcesResultSchema, @@ -31,7 +32,6 @@ import { ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, ProgressNotificationSchema, - type Tool as McpTool, } from '@modelcontextprotocol/sdk/types.js'; import { ApprovalMode, PolicyDecision } from '../policy/types.js'; import { parse } from 'shell-quote'; @@ -719,18 +719,17 @@ async function handleAutomaticOAuth( try { debugLogger.log(`🔐 '${mcpServerName}' requires OAuth authentication`); - // Always try to parse the resource metadata URI from the www-authenticate header - let oauthConfig; - const resourceMetadataUri = - OAuthUtils.parseWWWAuthenticateHeader(wwwAuthenticate); - if (resourceMetadataUri) { - oauthConfig = await OAuthUtils.discoverOAuthConfig(resourceMetadataUri); - } else if (hasNetworkTransport(mcpServerConfig)) { + const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url; + + // Try to discover OAuth config from the WWW-Authenticate header first + let oauthConfig = await OAuthUtils.discoverOAuthFromWWWAuthenticate( + wwwAuthenticate, + serverUrl, + ); + + if (!oauthConfig && hasNetworkTransport(mcpServerConfig)) { // Fallback: try to discover OAuth config from the base URL - const serverUrl = new URL( - mcpServerConfig.httpUrl || mcpServerConfig.url!, - ); - const baseUrl = `${serverUrl.protocol}//${serverUrl.host}`; + const baseUrl = OAuthUtils.extractBaseUrl(serverUrl!); oauthConfig = await OAuthUtils.discoverOAuthConfig(baseUrl); } @@ -754,8 +753,6 @@ async function handleAutomaticOAuth( }; // Perform OAuth authentication - // Pass the server URL for proper discovery - const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url; debugLogger.log( `Starting OAuth authentication for server '${mcpServerName}'...`, ); @@ -1999,6 +1996,7 @@ export async function createTransport( // The `XcodeMcpBridgeFixTransport` wrapper hides the underlying `StdioClientTransport`, // which exposes `stderr` for debug logging. We need to unwrap it to attach the listener. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const underlyingTransport = transport instanceof XcodeMcpBridgeFixTransport ? // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion @@ -2010,6 +2008,7 @@ export async function createTransport( underlyingTransport.stderr ) { underlyingTransport.stderr.on('data', (data) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const stderrStr = data.toString().trim(); debugLogger.debug( `[DEBUG] [MCP STDERR (${mcpServerName})]: `, diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 280af4589a..6faa30c673 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -10,13 +10,13 @@ import type { ToolInvocation, ToolMcpConfirmationDetails, ToolResult, + PolicyUpdateOptions, } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind, ToolConfirmationOutcome, - type PolicyUpdateOptions, } from './tools.js'; import type { CallableTool, FunctionCall, Part } from '@google/genai'; import { ToolErrorType } from './tool-error.js'; @@ -247,7 +247,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< override readonly parameterSchema: unknown, messageBus: MessageBus, readonly trust?: boolean, - readonly isReadOnly?: boolean, + isReadOnly?: boolean, nameOverride?: string, private readonly cliConfig?: Config, override readonly extensionName?: string, @@ -265,6 +265,16 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< extensionName, extensionId, ); + this._isReadOnly = isReadOnly; + } + + private readonly _isReadOnly?: boolean; + + override get isReadOnly(): boolean { + if (this._isReadOnly !== undefined) { + return this._isReadOnly; + } + return super.isReadOnly; } getFullyQualifiedPrefix(): string { diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index 494b007dec..5457b8337b 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -130,29 +130,36 @@ describe('ReadFileTool', () => { ); }); - it('should throw error if offset is negative', () => { + it('should throw error if start_line is less than 1', () => { const params: ReadFileToolParams = { file_path: path.join(tempRootDir, 'test.txt'), - offset: -1, + start_line: 0, }; - expect(() => tool.build(params)).toThrow( - 'Offset must be a non-negative number', - ); + expect(() => tool.build(params)).toThrow('start_line must be at least 1'); }); - it('should throw error if limit is zero or negative', () => { + it('should throw error if end_line is less than 1', () => { const params: ReadFileToolParams = { file_path: path.join(tempRootDir, 'test.txt'), - limit: 0, + end_line: 0, + }; + expect(() => tool.build(params)).toThrow('end_line must be at least 1'); + }); + + it('should throw error if start_line is greater than end_line', () => { + const params: ReadFileToolParams = { + file_path: path.join(tempRootDir, 'test.txt'), + start_line: 10, + end_line: 5, }; expect(() => tool.build(params)).toThrow( - 'Limit must be a positive number', + 'start_line cannot be greater than end_line', ); }); }); describe('getDescription', () => { - it('should return relative path without limit/offset', () => { + it('should return relative path without ranges', () => { const subDir = path.join(tempRootDir, 'sub', 'dir'); const params: ReadFileToolParams = { file_path: path.join(subDir, 'file.txt'), @@ -393,7 +400,7 @@ describe('ReadFileTool', () => { expect(result.returnDisplay).toBe(''); }); - it('should support offset and limit for text files', async () => { + it('should support start_line and end_line for text files', async () => { const filePath = path.join(tempRootDir, 'paginated.txt'); const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`); const fileContent = lines.join('\n'); @@ -401,8 +408,8 @@ describe('ReadFileTool', () => { const params: ReadFileToolParams = { file_path: filePath, - offset: 5, // Start from line 6 - limit: 3, + start_line: 6, + end_line: 8, }; const invocation = tool.build(params); @@ -569,6 +576,10 @@ describe('ReadFileTool', () => { const schema = tool.getSchema(); expect(schema.name).toBe(ReadFileTool.Name); expect(schema.description).toMatchSnapshot(); + expect( + (schema.parametersJsonSchema as { properties: Record }) + .properties, + ).not.toHaveProperty('offset'); }); it('should return the schema from the resolver when modelId is provided', () => { diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index c43f79ded0..170cccf905 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -36,14 +36,14 @@ export interface ReadFileToolParams { file_path: string; /** - * The line number to start reading from (optional) + * The line number to start reading from (optional, 1-based) */ - offset?: number; + start_line?: number; /** - * The number of lines to read (optional) + * The line number to end reading at (optional, 1-based, inclusive) */ - limit?: number; + end_line?: number; } class ReadFileToolInvocation extends BaseToolInvocation< @@ -74,7 +74,12 @@ class ReadFileToolInvocation extends BaseToolInvocation< } override toolLocations(): ToolLocation[] { - return [{ path: this.resolvedPath, line: this.params.offset }]; + return [ + { + path: this.resolvedPath, + line: this.params.start_line, + }, + ]; } async execute(): Promise { @@ -97,8 +102,8 @@ class ReadFileToolInvocation extends BaseToolInvocation< this.resolvedPath, this.config.getTargetDir(), this.config.getFileSystemService(), - this.params.offset, - this.params.limit, + this.params.start_line, + this.params.end_line, ); if (result.error) { @@ -116,13 +121,11 @@ class ReadFileToolInvocation extends BaseToolInvocation< if (result.isTruncated) { const [start, end] = result.linesShown!; const total = result.originalLineCount!; - const nextOffset = this.params.offset - ? this.params.offset + end - start + 1 - : end; + llmContent = ` IMPORTANT: The file content has been truncated. Status: Showing lines ${start}-${end} of ${total} total lines. -Action: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: ${nextOffset}. +Action: To read more of the file, you can use the 'start_line' and 'end_line' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use start_line: ${end + 1}. --- FILE CONTENT (truncated) --- ${result.llmContent}`; @@ -207,11 +210,18 @@ export class ReadFileTool extends BaseDeclarativeTool< return validationError; } - if (params.offset !== undefined && params.offset < 0) { - return 'Offset must be a non-negative number'; + if (params.start_line !== undefined && params.start_line < 1) { + return 'start_line must be at least 1'; } - if (params.limit !== undefined && params.limit <= 0) { - return 'Limit must be a positive number'; + if (params.end_line !== undefined && params.end_line < 1) { + return 'end_line must be at least 1'; + } + if ( + params.start_line !== undefined && + params.end_line !== undefined && + params.start_line > params.end_line + ) { + return 'start_line cannot be greater than end_line'; } const fileFilteringOptions = this.config.getFileFilteringOptions(); diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index 09f8b5f00c..58842e9b22 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -265,6 +265,7 @@ describe('RipGrepTool', () => { downloadRipGrepMock.mockReset(); downloadRipGrepMock.mockResolvedValue(undefined); mockSpawn.mockReset(); + mockSpawn.mockImplementation(createMockSpawn()); tempBinRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-')); binDir = path.join(tempBinRoot, 'bin'); await fs.mkdir(binDir, { recursive: true }); @@ -396,7 +397,7 @@ describe('RipGrepTool', () => { describe('execute', () => { it('should find matches for a simple pattern in all files', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -447,7 +448,7 @@ describe('RipGrepTool', () => { }); it('should ignore matches that escape the base path', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -482,7 +483,7 @@ describe('RipGrepTool', () => { it('should find matches in a specific path', async () => { // Setup specific mock for this test - searching in 'sub' should only return matches from that directory - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -510,7 +511,7 @@ describe('RipGrepTool', () => { it('should find matches with an include glob', async () => { // Setup specific mock for this test - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -545,7 +546,7 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - searching for 'hello' in 'sub' with '*.js' filter - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -577,7 +578,7 @@ describe('RipGrepTool', () => { it('should return "No matches found" when pattern does not exist', async () => { // Setup specific mock for no matches - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ exitCode: 1, // No matches found }), @@ -699,7 +700,7 @@ describe('RipGrepTool', () => { ); // Mock ripgrep returning both an ignored file and an allowed file - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -738,7 +739,7 @@ describe('RipGrepTool', () => { it('should handle regex special characters correctly', async () => { // Setup specific mock for this test - regex pattern 'foo.*bar' should match 'const foo = "bar";' - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -765,7 +766,7 @@ describe('RipGrepTool', () => { it('should be case-insensitive by default (JS fallback)', async () => { // Setup specific mock for this test - case insensitive search for 'HELLO' - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -878,7 +879,7 @@ describe('RipGrepTool', () => { // Setup specific mock for this test - multi-directory search for 'world' // Mock will be called twice - once for each directory - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -989,7 +990,7 @@ describe('RipGrepTool', () => { } as unknown as Config; // Setup specific mock for this test - searching in 'sub' should only return matches from that directory - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1042,7 +1043,7 @@ describe('RipGrepTool', () => { it('should abort streaming search when signal is triggered', async () => { // Setup specific mock for this test - simulate process being killed due to abort - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ exitCode: null, signal: 'SIGTERM', @@ -1087,7 +1088,7 @@ describe('RipGrepTool', () => { }, }, ])('should handle $name gracefully', async ({ setup }) => { - mockSpawn.mockImplementationOnce(createMockSpawn({ exitCode: 1 })); + mockSpawn.mockImplementation(createMockSpawn({ exitCode: 1 })); const params = await setup(); const invocation = grepTool.build(params); @@ -1104,7 +1105,7 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - searching for 'world' should find the file with special characters - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1135,7 +1136,7 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - searching for 'deep' should find the deeply nested file - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1166,7 +1167,7 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - regex pattern should match function declarations - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1180,7 +1181,10 @@ describe('RipGrepTool', () => { }), ); - const params: RipGrepToolParams = { pattern: 'function\\s+\\w+\\s*\\(' }; + const params: RipGrepToolParams = { + pattern: 'function\\s+\\w+\\s*\\(', + context: 0, + }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); @@ -1195,7 +1199,7 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - case insensitive search should match all variants - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1244,7 +1248,7 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - escaped regex pattern should match price format - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1258,7 +1262,10 @@ describe('RipGrepTool', () => { }), ); - const params: RipGrepToolParams = { pattern: '\\$\\d+\\.\\d+' }; + const params: RipGrepToolParams = { + pattern: '\\$\\d+\\.\\d+', + context: 0, + }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); @@ -1281,7 +1288,7 @@ describe('RipGrepTool', () => { await fs.writeFile(path.join(tempRootDir, 'test.txt'), 'text content'); // Setup specific mock for this test - include pattern should filter to only ts/tsx files - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1327,7 +1334,7 @@ describe('RipGrepTool', () => { await fs.writeFile(path.join(tempRootDir, 'other.ts'), 'other code'); // Setup specific mock for this test - include pattern should filter to only src/** files - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1356,7 +1363,7 @@ describe('RipGrepTool', () => { describe('advanced search options', () => { it('should handle case_sensitive parameter', async () => { // Case-insensitive search (default) - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1370,7 +1377,7 @@ describe('RipGrepTool', () => { exitCode: 0, }), ); - let params: RipGrepToolParams = { pattern: 'HELLO' }; + let params: RipGrepToolParams = { pattern: 'HELLO', context: 0 }; let invocation = grepTool.build(params); let result = await invocation.execute(abortSignal); expect(mockSpawn).toHaveBeenLastCalledWith( @@ -1382,7 +1389,7 @@ describe('RipGrepTool', () => { expect(result.llmContent).toContain('L1: hello world'); // Case-sensitive search - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1396,7 +1403,7 @@ describe('RipGrepTool', () => { exitCode: 0, }), ); - params = { pattern: 'HELLO', case_sensitive: true }; + params = { pattern: 'HELLO', case_sensitive: true, context: 0 }; invocation = grepTool.build(params); result = await invocation.execute(abortSignal); expect(mockSpawn).toHaveBeenLastCalledWith( @@ -1409,7 +1416,7 @@ describe('RipGrepTool', () => { }); it('should handle fixed_strings parameter', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1453,7 +1460,7 @@ describe('RipGrepTool', () => { }); it('should handle no_ignore parameter', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1526,7 +1533,7 @@ describe('RipGrepTool', () => { createMockMessageBus(), ); - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1592,7 +1599,7 @@ describe('RipGrepTool', () => { createMockMessageBus(), ); - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1658,7 +1665,7 @@ describe('RipGrepTool', () => { createMockMessageBus(), ); - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1685,7 +1692,7 @@ describe('RipGrepTool', () => { }); it('should handle context parameters', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1855,7 +1862,7 @@ describe('RipGrepTool', () => { describe('new parameters', () => { it('should pass --max-count when max_matches_per_file is provided', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1884,7 +1891,7 @@ describe('RipGrepTool', () => { it('should respect total_max_matches and truncate results', async () => { // Return 3 matches, but set total_max_matches to 2 - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1921,6 +1928,7 @@ describe('RipGrepTool', () => { const params: RipGrepToolParams = { pattern: 'match', total_max_matches: 2, + context: 0, }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); @@ -1936,7 +1944,7 @@ describe('RipGrepTool', () => { }); it('should return only file paths when names_only is true', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1976,7 +1984,7 @@ describe('RipGrepTool', () => { }); it('should filter out matches based on exclude_pattern', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -2004,6 +2012,7 @@ describe('RipGrepTool', () => { const params: RipGrepToolParams = { pattern: 'Copyright .* Google LLC', exclude_pattern: '2026', + context: 0, }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 5fe516c335..9ad929f256 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -31,6 +31,7 @@ import { } from './constants.js'; import { RIP_GREP_DEFINITION } from './definitions/coreTools.js'; import { resolveToolDeclaration } from './definitions/resolver.js'; +import { type GrepMatch, formatGrepResults } from './grep-utils.js'; function getRgCandidateFilenames(): readonly string[] { return process.platform === 'win32' ? ['rg.exe', 'rg'] : ['rg']; @@ -155,16 +156,6 @@ export interface RipGrepToolParams { total_max_matches?: number; } -/** - * Result object for a single grep match - */ -interface GrepMatch { - filePath: string; - lineNumber: number; - line: string; - isContext?: boolean; -} - class GrepToolInvocation extends BaseToolInvocation< RipGrepToolParams, ToolResult @@ -287,58 +278,23 @@ class GrepToolInvocation extends BaseToolInvocation< ); } - const searchLocationDescription = `in path "${searchDirDisplay}"`; - if (allMatches.length === 0) { - const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`; - return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; - } - - const matchesByFile = allMatches.reduce( - (acc, match) => { - const fileKey = match.filePath; - if (!acc[fileKey]) { - acc[fileKey] = []; - } - acc[fileKey].push(match); - acc[fileKey].sort((a, b) => a.lineNumber - b.lineNumber); - return acc; - }, - {} as Record, + const matchCount = allMatches.filter((m) => !m.isContext).length; + allMatches = await this.enrichWithRipgrepAutoContext( + allMatches, + matchCount, + totalMaxMatches, + searchDirAbs, + timeoutController.signal, ); - const matchesOnly = allMatches.filter((m) => !m.isContext); - const matchCount = matchesOnly.length; - const matchTerm = matchCount === 1 ? 'match' : 'matches'; + const searchLocationDescription = `in path "${searchDirDisplay}"`; - const wasTruncated = matchCount >= totalMaxMatches; - - if (this.params.names_only) { - const filePaths = Object.keys(matchesByFile).sort(); - let llmContent = `Found ${filePaths.length} files with matches for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n`; - llmContent += filePaths.join('\n'); - return { - llmContent: llmContent.trim(), - returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`, - }; - } - - let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n---\n`; - - for (const filePath in matchesByFile) { - llmContent += `File: ${filePath}\n`; - matchesByFile[filePath].forEach((match) => { - const separator = match.isContext ? '-' : ':'; - llmContent += `L${match.lineNumber}${separator} ${match.line}\n`; - }); - llmContent += '---\n'; - } - - return { - llmContent: llmContent.trim(), - returnDisplay: `Found ${matchCount} ${matchTerm}${ - wasTruncated ? ' (limited)' : '' - }`, - }; + return await formatGrepResults( + allMatches, + this.params, + searchLocationDescription, + totalMaxMatches, + ); } catch (error) { debugLogger.warn(`Error during GrepLogic execution: ${error}`); const errorMessage = getErrorMessage(error); @@ -349,9 +305,61 @@ class GrepToolInvocation extends BaseToolInvocation< } } + private async enrichWithRipgrepAutoContext( + allMatches: GrepMatch[], + matchCount: number, + totalMaxMatches: number, + searchDirAbs: string, + signal: AbortSignal, + ): Promise { + if ( + matchCount >= 1 && + matchCount <= 3 && + !this.params.names_only && + this.params.context === undefined && + this.params.before === undefined && + this.params.after === undefined + ) { + const contextLines = matchCount === 1 ? 50 : 15; + const uniqueFiles = Array.from( + new Set(allMatches.map((m) => m.absolutePath)), + ); + + let enrichedMatches = await this.performRipgrepSearch({ + pattern: this.params.pattern, + path: uniqueFiles, + basePath: searchDirAbs, + include: this.params.include, + exclude_pattern: this.params.exclude_pattern, + case_sensitive: this.params.case_sensitive, + fixed_strings: this.params.fixed_strings, + context: contextLines, + no_ignore: this.params.no_ignore, + maxMatches: totalMaxMatches, + max_matches_per_file: this.params.max_matches_per_file, + signal, + }); + + if (!this.params.no_ignore) { + const allowedFiles = this.fileDiscoveryService.filterFiles(uniqueFiles); + const allowedSet = new Set(allowedFiles); + enrichedMatches = enrichedMatches.filter((m) => + allowedSet.has(m.absolutePath), + ); + } + + // Set context to prevent grep-utils from doing the JS fallback auto-context + this.params.context = contextLines; + return enrichedMatches; + } + + return allMatches; + } + private async performRipgrepSearch(options: { pattern: string; - path: string; + path: string | string[]; + basePath?: string; include?: string; exclude_pattern?: string; case_sensitive?: boolean; @@ -366,7 +374,8 @@ class GrepToolInvocation extends BaseToolInvocation< }): Promise { const { pattern, - path: absolutePath, + path, + basePath, include, exclude_pattern, case_sensitive, @@ -379,6 +388,8 @@ class GrepToolInvocation extends BaseToolInvocation< max_matches_per_file, } = options; + const searchPaths = Array.isArray(path) ? path : [path]; + const rgArgs = ['--json']; if (!case_sensitive) { @@ -436,7 +447,7 @@ class GrepToolInvocation extends BaseToolInvocation< } rgArgs.push('--threads', '4'); - rgArgs.push(absolutePath); + rgArgs.push(...searchPaths); const results: GrepMatch[] = []; try { @@ -452,8 +463,10 @@ class GrepToolInvocation extends BaseToolInvocation< excludeRegex = new RegExp(exclude_pattern, case_sensitive ? '' : 'i'); } + const parseBasePath = basePath || searchPaths[0]; + for await (const line of generator) { - const match = this.parseRipgrepJsonLine(line, absolutePath); + const match = this.parseRipgrepJsonLine(line, parseBasePath); if (match) { if (excludeRegex && excludeRegex.test(match.line)) { continue; @@ -481,8 +494,10 @@ class GrepToolInvocation extends BaseToolInvocation< basePath: string, ): GrepMatch | null { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const json = JSON.parse(line); if (json.type === 'match' || json.type === 'context') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const data = json.data; // Defensive check: ensure text properties exist (skips binary/invalid encoding) if (data.path?.text && data.lines?.text) { @@ -499,8 +514,11 @@ class GrepToolInvocation extends BaseToolInvocation< const relativeFilePath = path.relative(basePath, absoluteFilePath); return { + absolutePath: absoluteFilePath, filePath: relativeFilePath || path.basename(absoluteFilePath), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment lineNumber: data.line_number, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment line: data.lines.text.trimEnd(), isContext: json.type === 'context', }; diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 5fc3ca7f25..907d117439 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -42,7 +42,7 @@ vi.mock('crypto'); vi.mock('../utils/summarizer.js'); import { initializeShellParsers } from '../utils/shell-utils.js'; -import { ShellTool } from './shell.js'; +import { ShellTool, OUTPUT_UPDATE_INTERVAL_MS } from './shell.js'; import { debugLogger } from '../index.js'; import { type Config } from '../config/config.js'; import { @@ -58,7 +58,6 @@ import * as crypto from 'node:crypto'; import * as summarizer from '../utils/summarizer.js'; import { ToolErrorType } from './tool-error.js'; import { ToolConfirmationOutcome } from './tools.js'; -import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js'; import { SHELL_TOOL_NAME } from './tool-names.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; import { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 76db302f42..741272f555 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -16,13 +16,13 @@ import type { ToolResult, ToolCallConfirmationDetails, ToolExecuteConfirmationDetails, + PolicyUpdateOptions, } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, ToolConfirmationOutcome, Kind, - type PolicyUpdateOptions, } from './tools.js'; import { getErrorMessage } from '../utils/errors.js'; diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 60b1451838..95bac200be 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -12,6 +12,7 @@ import type { } from './tools.js'; import { Kind, BaseDeclarativeTool, BaseToolInvocation } from './tools.js'; import type { Config } from '../config/config.js'; +import { ApprovalMode } from '../policy/types.js'; import { spawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; import { DiscoveredMCPTool } from './mcp-tool.js'; @@ -25,6 +26,9 @@ import { DISCOVERED_TOOL_PREFIX, TOOL_LEGACY_ALIASES, getToolAliases, + PLAN_MODE_TOOLS, + WRITE_FILE_TOOL_NAME, + EDIT_TOOL_NAME, } from './tool-names.js'; type ToolParams = Record; @@ -386,6 +390,7 @@ export class ToolRegistry { // execute discovery command and extract function declarations (w/ or w/o "tool" wrappers) const functions: FunctionDeclaration[] = []; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const discoveredItems = JSON.parse(stdout.trim()); if (!discoveredItems || !Array.isArray(discoveredItems)) { @@ -484,6 +489,31 @@ export class ToolRegistry { excludeTools ??= this.expandExcludeToolsWithAliases(this.config.getExcludeTools()) ?? new Set([]); + + // Filter tools in Plan Mode to only allow approved read-only tools. + const isPlanMode = + typeof this.config.getApprovalMode === 'function' && + this.config.getApprovalMode() === ApprovalMode.PLAN; + if (isPlanMode) { + const allowedToolNames = new Set(PLAN_MODE_TOOLS); + // We allow write_file and replace for writing plans specifically. + allowedToolNames.add(WRITE_FILE_TOOL_NAME); + allowedToolNames.add(EDIT_TOOL_NAME); + + // Discovered MCP tools are allowed if they are read-only. + if ( + tool instanceof DiscoveredMCPTool && + tool.isReadOnly && + !allowedToolNames.has(tool.name) + ) { + allowedToolNames.add(tool.name); + } + + if (!allowedToolNames.has(tool.name)) { + return false; + } + } + const normalizedClassName = tool.constructor.name.replace(/^_+/, ''); const possibleNames = [tool.name, normalizedClassName]; if (tool instanceof DiscoveredMCPTool) { @@ -507,9 +537,22 @@ export class ToolRegistry { * @returns An array of FunctionDeclarations. */ getFunctionDeclarations(modelId?: string): FunctionDeclaration[] { + const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN; + const plansDir = this.config.storage.getPlansDir(); + const declarations: FunctionDeclaration[] = []; this.getActiveTools().forEach((tool) => { - declarations.push(tool.getSchema(modelId)); + let schema = tool.getSchema(modelId); + if ( + isPlanMode && + (tool.name === WRITE_FILE_TOOL_NAME || tool.name === EDIT_TOOL_NAME) + ) { + schema = { + ...schema, + description: `ONLY FOR PLANS: ${schema.description}. You are currently in Plan Mode and may ONLY use this tool to write or update plans (.md files) in the plans directory: ${plansDir}/. You cannot use this tool to modify source code directly.`, + }; + } + declarations.push(schema); }); return declarations; } diff --git a/packages/core/src/tools/tools.test.ts b/packages/core/src/tools/tools.test.ts index 514f4f3455..41edf9f21d 100644 --- a/packages/core/src/tools/tools.test.ts +++ b/packages/core/src/tools/tools.test.ts @@ -9,6 +9,8 @@ import type { ToolInvocation, ToolResult } from './tools.js'; import { DeclarativeTool, hasCycleInSchema, Kind } from './tools.js'; import { ToolErrorType } from './tool-error.js'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import { ReadFileTool } from './read-file.js'; +import { makeFakeConfig } from '../test-utils/config.js'; class TestToolInvocation implements ToolInvocation { constructor( @@ -238,3 +240,30 @@ describe('hasCycleInSchema', () => { expect(hasCycleInSchema({})).toBe(false); }); }); + +describe('Tools Read-Only property', () => { + it('should have isReadOnly true for ReadFileTool', () => { + const config = makeFakeConfig(); + const bus = createMockMessageBus(); + const tool = new ReadFileTool(config, bus); + expect(tool.isReadOnly).toBe(true); + }); + + it('should derive isReadOnly from Kind', () => { + const bus = createMockMessageBus(); + class MyTool extends DeclarativeTool { + build(_params: object): ToolInvocation { + throw new Error('Not implemented'); + } + } + + const mutator = new MyTool('m', 'M', 'd', Kind.Edit, {}, bus); + expect(mutator.isReadOnly).toBe(false); + + const reader = new MyTool('r', 'R', 'd', Kind.Read, {}, bus); + expect(reader.isReadOnly).toBe(true); + + const searcher = new MyTool('s', 'S', 'd', Kind.Search, {}, bus); + expect(searcher.isReadOnly).toBe(true); + }); +}); diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 4e9972e37c..acbbd7bfff 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -333,6 +333,11 @@ export interface ToolBuilder< */ canUpdateOutput: boolean; + /** + * Whether the tool is read-only (has no side effects). + */ + isReadOnly: boolean; + /** * Validates raw parameters and builds a ready-to-execute invocation. * @param params The raw, untrusted parameters from the model. @@ -363,6 +368,10 @@ export abstract class DeclarativeTool< readonly extensionId?: string, ) {} + get isReadOnly(): boolean { + return READ_ONLY_KINDS.includes(this.kind); + } + getSchema(_modelId?: string): FunctionDeclaration { return { name: this.name, @@ -819,6 +828,13 @@ export const MUTATOR_KINDS: Kind[] = [ Kind.Execute, ] as const; +// Function kinds that are safe to run in parallel +export const READ_ONLY_KINDS: Kind[] = [ + Kind.Read, + Kind.Search, + Kind.Fetch, +] as const; + export interface ToolLocation { // Absolute path to the file path: string; diff --git a/packages/core/src/tools/web-fetch.test.ts b/packages/core/src/tools/web-fetch.test.ts index f0c6ff2c7e..2e06a46ee5 100644 --- a/packages/core/src/tools/web-fetch.test.ts +++ b/packages/core/src/tools/web-fetch.test.ts @@ -183,6 +183,26 @@ describe('WebFetchTool', () => { }); describe('execute', () => { + it('should return WEB_FETCH_PROCESSING_ERROR on rate limit exceeded', async () => { + vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'response' }] } }], + }); + const tool = new WebFetchTool(mockConfig, bus); + const params = { prompt: 'fetch https://ratelimit.example.com' }; + const invocation = tool.build(params); + + // Execute 10 times to hit the limit + for (let i = 0; i < 10; i++) { + await invocation.execute(new AbortController().signal); + } + + // The 11th time should fail due to rate limit + const result = await invocation.execute(new AbortController().signal); + expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR); + expect(result.error?.message).toContain('Rate limit exceeded for host'); + }); + it('should return WEB_FETCH_FALLBACK_FAILED on fallback fetch failure', async () => { vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true); vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue( diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 214cf4916b..3521ad935b 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -8,13 +8,9 @@ import type { ToolCallConfirmationDetails, ToolInvocation, ToolResult, + ToolConfirmationOutcome, } from './tools.js'; -import { - BaseDeclarativeTool, - BaseToolInvocation, - Kind, - type ToolConfirmationOutcome, -} from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { ToolErrorType } from './tool-error.js'; import { getErrorMessage } from '../utils/errors.js'; @@ -33,10 +29,46 @@ import { debugLogger } from '../utils/debugLogger.js'; import { retryWithBackoff } from '../utils/retry.js'; import { WEB_FETCH_DEFINITION } from './definitions/coreTools.js'; import { resolveToolDeclaration } from './definitions/resolver.js'; +import { LRUCache } from 'mnemonist'; const URL_FETCH_TIMEOUT_MS = 10000; const MAX_CONTENT_LENGTH = 100000; +// Rate limiting configuration +const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute +const MAX_REQUESTS_PER_WINDOW = 10; +const hostRequestHistory = new LRUCache(1000); + +function checkRateLimit(url: string): { + allowed: boolean; + waitTimeMs?: number; +} { + try { + const hostname = new URL(url).hostname; + const now = Date.now(); + const windowStart = now - RATE_LIMIT_WINDOW_MS; + + let history = hostRequestHistory.get(hostname) || []; + // Clean up old timestamps + history = history.filter((timestamp) => timestamp > windowStart); + + if (history.length >= MAX_REQUESTS_PER_WINDOW) { + // Calculate wait time based on the oldest timestamp in the current window + const oldestTimestamp = history[0]; + const waitTimeMs = oldestTimestamp + RATE_LIMIT_WINDOW_MS - now; + hostRequestHistory.set(hostname, history); // Update cleaned history + return { allowed: false, waitTimeMs: Math.max(0, waitTimeMs) }; + } + + history.push(now); + hostRequestHistory.set(hostname, history); + return { allowed: true }; + } catch (_e) { + // If URL parsing fails, we fallback to allowed (should be caught by parsePrompt anyway) + return { allowed: true }; + } +} + /** * Parses a prompt to extract valid URLs and identify malformed ones. */ @@ -258,6 +290,23 @@ ${textContent} const userPrompt = this.params.prompt; const { validUrls: urls } = parsePrompt(userPrompt); const url = urls[0]; + + // Enforce rate limiting + const rateLimitResult = checkRateLimit(url); + if (!rateLimitResult.allowed) { + const waitTimeSecs = Math.ceil((rateLimitResult.waitTimeMs || 0) / 1000); + const errorMessage = `Rate limit exceeded for host. Please wait ${waitTimeSecs} seconds before trying again.`; + debugLogger.warn(`[WebFetchTool] Rate limit exceeded for ${url}`); + return { + llmContent: `Error: ${errorMessage}`, + returnDisplay: `Error: ${errorMessage}`, + error: { + message: errorMessage, + type: ToolErrorType.WEB_FETCH_PROCESSING_ERROR, + }, + }; + } + const isPrivate = isPrivateIp(url); if (isPrivate) { diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 3545affe3f..3a0c8487b8 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -831,6 +831,63 @@ describe('WriteFileTool', () => { } }, ); + + it('should include the file content in llmContent', async () => { + const filePath = path.join(rootDir, 'content_check.txt'); + const content = 'This is the content that should be returned.'; + mockEnsureCorrectFileContent.mockResolvedValue(content); + + const params = { file_path: filePath, content }; + const invocation = tool.build(params); + + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Here is the updated code:'); + expect(result.llmContent).toContain(content); + }); + + it('should return only changed lines plus context for large updates', async () => { + const filePath = path.join(rootDir, 'large_update.txt'); + const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); + const originalContent = lines.join('\n'); + fs.writeFileSync(filePath, originalContent, 'utf8'); + + const newLines = [...lines]; + newLines[50] = 'Line 51 Modified'; // Modify one line in the middle + + const newContent = newLines.join('\n'); + mockEnsureCorrectEdit.mockResolvedValue({ + params: { + file_path: filePath, + old_string: originalContent, + new_string: newContent, + }, + occurrences: 1, + }); + + const params = { file_path: filePath, content: newContent }; + const invocation = tool.build(params); + + // Confirm execution first + const confirmDetails = await invocation.shouldConfirmExecute(abortSignal); + if (confirmDetails && 'onConfirm' in confirmDetails) { + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); + } + + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Here is the updated code:'); + // Should contain the modified line + expect(result.llmContent).toContain('Line 51 Modified'); + // Should contain context lines (e.g. Line 46, Line 56) + expect(result.llmContent).toContain('Line 46'); + expect(result.llmContent).toContain('Line 56'); + // Should NOT contain far away lines (e.g. Line 1, Line 100) + expect(result.llmContent).not.toContain('Line 1\n'); + expect(result.llmContent).not.toContain('Line 100'); + // Should indicate truncation + expect(result.llmContent).toContain('...'); + }); }); describe('workspace boundary validation', () => { diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 4d521db33c..3ad5838c95 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -20,13 +20,9 @@ import type { ToolInvocation, ToolLocation, ToolResult, + ToolConfirmationOutcome, } from './tools.js'; -import { - BaseDeclarativeTool, - BaseToolInvocation, - Kind, - type ToolConfirmationOutcome, -} from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { ToolErrorType } from './tool-error.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; @@ -36,6 +32,7 @@ import { } from '../utils/editCorrector.js'; import { detectLineEnding } from '../utils/textUtils.js'; import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js'; +import { getDiffContextSnippet } from './diff-utils.js'; import type { ModifiableDeclarativeTool, ModifyContext, @@ -351,6 +348,15 @@ class WriteFileToolInvocation extends BaseToolInvocation< ); } + // Return a diff of the file before and after the write so that the agent + // can avoid the need to spend a turn doing a verification read. + const snippet = getDiffContextSnippet( + isNewFile ? '' : originalContent, + finalContent, + 5, + ); + llmSuccessMessageParts.push(`Here is the updated code:\n${snippet}`); + // Log file operation for telemetry (without diff_stat to avoid double-counting) const mimetype = getSpecificMimeType(this.resolvedPath); const programmingLanguage = getLanguageFromFilePath(this.resolvedPath); diff --git a/packages/core/src/tools/write-todos.ts b/packages/core/src/tools/write-todos.ts index 38aef4f309..5eb42c73f4 100644 --- a/packages/core/src/tools/write-todos.ts +++ b/packages/core/src/tools/write-todos.ts @@ -4,14 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ToolInvocation } from './tools.js'; -import { - BaseDeclarativeTool, - BaseToolInvocation, - Kind, - type Todo, - type ToolResult, -} from './tools.js'; +import type { ToolInvocation, Todo, ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { WRITE_TODOS_TOOL_NAME } from './tool-names.js'; import { WRITE_TODOS_DEFINITION } from './definitions/coreTools.js'; diff --git a/packages/core/src/tools/xcode-mcp-fix-transport.ts b/packages/core/src/tools/xcode-mcp-fix-transport.ts index 7daabef87e..9f7785e8c9 100644 --- a/packages/core/src/tools/xcode-mcp-fix-transport.ts +++ b/packages/core/src/tools/xcode-mcp-fix-transport.ts @@ -75,7 +75,7 @@ export class XcodeMcpBridgeFixTransport // We can cast because we verified 'result' is in response, // but TS might still be picky if the type is a strict union. // Let's treat it safely. - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment const result = response.result as any; // Check if we have content but missing structuredContent @@ -85,12 +85,15 @@ export class XcodeMcpBridgeFixTransport result.content.length > 0 && !result.structuredContent ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const firstItem = result.content[0]; if (firstItem.type === 'text' && typeof firstItem.text === 'string') { try { // Attempt to parse the text as JSON + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(firstItem.text); // If successful, populate structuredContent + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment result.structuredContent = parsed; } catch (_) { // Ignored: Content is likely plain text, not JSON. diff --git a/packages/core/src/utils/authConsent.test.ts b/packages/core/src/utils/authConsent.test.ts index d2188ded17..7fc05b2a03 100644 --- a/packages/core/src/utils/authConsent.test.ts +++ b/packages/core/src/utils/authConsent.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Mock } from 'vitest'; import readline from 'node:readline'; import process from 'node:process'; @@ -27,73 +27,135 @@ vi.mock('./stdio.js', () => ({ })); describe('getConsentForOauth', () => { - it('should use coreEvents when listeners are present', async () => { + beforeEach(() => { vi.restoreAllMocks(); - const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest'); - const mockListenerCount = vi - .spyOn(coreEvents, 'listenerCount') - .mockReturnValue(1); + }); - mockEmitConsentRequest.mockImplementation((payload) => { - payload.onConfirm(true); + describe('in interactive mode', () => { + beforeEach(() => { + (isHeadlessMode as Mock).mockReturnValue(false); }); - const result = await getConsentForOauth('Login required.'); + it('should emit consent request when UI listeners are present', async () => { + const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest'); + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); - expect(result).toBe(true); - expect(mockEmitConsentRequest).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: expect.stringContaining( - 'Login required. Opening authentication page in your browser.', - ), - }), - ); + mockEmitConsentRequest.mockImplementation((payload) => { + payload.onConfirm(true); + }); - mockListenerCount.mockRestore(); - mockEmitConsentRequest.mockRestore(); + const result = await getConsentForOauth('Login required.'); + + expect(result).toBe(true); + expect(mockEmitConsentRequest).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining( + 'Login required. Opening authentication page in your browser.', + ), + }), + ); + }); + + it('should handle empty prompt correctly', async () => { + const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest'); + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); + + mockEmitConsentRequest.mockImplementation((payload) => { + payload.onConfirm(true); + }); + + await getConsentForOauth(''); + + expect(mockEmitConsentRequest).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringMatching( + /^Opening authentication page in your browser\./, + ), + }), + ); + }); + + it('should return false when user declines via UI', async () => { + const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest'); + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); + + mockEmitConsentRequest.mockImplementation((payload) => { + payload.onConfirm(false); + }); + + const result = await getConsentForOauth('Login required.'); + + expect(result).toBe(false); + }); + + it('should throw FatalAuthenticationError when no UI listeners are present', async () => { + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(0); + + await expect(getConsentForOauth('Login required.')).rejects.toThrow( + FatalAuthenticationError, + ); + }); }); - it('should use readline when no listeners are present and not headless', async () => { - vi.restoreAllMocks(); - const mockListenerCount = vi - .spyOn(coreEvents, 'listenerCount') - .mockReturnValue(0); - (isHeadlessMode as Mock).mockReturnValue(false); + describe('in non-interactive mode', () => { + beforeEach(() => { + (isHeadlessMode as Mock).mockReturnValue(true); + }); - const mockReadline = { - on: vi.fn((event, callback) => { - if (event === 'line') { - callback('y'); - } - }), - close: vi.fn(), - }; - (readline.createInterface as Mock).mockReturnValue(mockReadline); + it('should use readline to prompt for consent', async () => { + const mockReadline = { + on: vi.fn((event, callback) => { + if (event === 'line') { + callback('y'); + } + }), + close: vi.fn(), + }; + (readline.createInterface as Mock).mockReturnValue(mockReadline); - const result = await getConsentForOauth('Login required.'); + const result = await getConsentForOauth('Login required.'); - expect(result).toBe(true); - expect(readline.createInterface).toHaveBeenCalled(); - expect(writeToStdout).toHaveBeenCalledWith( - expect.stringContaining( - 'Login required. Opening authentication page in your browser.', - ), - ); + expect(result).toBe(true); + expect(readline.createInterface).toHaveBeenCalledWith( + expect.objectContaining({ + terminal: true, + }), + ); + expect(writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('Login required.'), + ); + }); - mockListenerCount.mockRestore(); - }); + it('should accept empty response as "yes"', async () => { + const mockReadline = { + on: vi.fn((event, callback) => { + if (event === 'line') { + callback(''); + } + }), + close: vi.fn(), + }; + (readline.createInterface as Mock).mockReturnValue(mockReadline); - it('should throw FatalAuthenticationError when no listeners and headless', async () => { - vi.restoreAllMocks(); - const mockListenerCount = vi - .spyOn(coreEvents, 'listenerCount') - .mockReturnValue(0); - (isHeadlessMode as Mock).mockReturnValue(true); + const result = await getConsentForOauth('Login required.'); - await expect(getConsentForOauth('Login required.')).rejects.toThrow( - FatalAuthenticationError, - ); + expect(result).toBe(true); + }); - mockListenerCount.mockRestore(); + it('should return false when user declines via readline', async () => { + const mockReadline = { + on: vi.fn((event, callback) => { + if (event === 'line') { + callback('n'); + } + }), + close: vi.fn(), + }; + (readline.createInterface as Mock).mockReturnValue(mockReadline); + + const result = await getConsentForOauth('Login required.'); + + expect(result).toBe(false); + }); }); }); diff --git a/packages/core/src/utils/authConsent.ts b/packages/core/src/utils/authConsent.ts index 65ef633dd4..589c922f57 100644 --- a/packages/core/src/utils/authConsent.ts +++ b/packages/core/src/utils/authConsent.ts @@ -12,22 +12,23 @@ import { isHeadlessMode } from './headless.js'; /** * Requests consent from the user for OAuth login. - * Handles both TTY and non-TTY environments. + * Handles both interactive and non-interactive (headless) modes. */ export async function getConsentForOauth(prompt: string): Promise { - const finalPrompt = prompt + ' Opening authentication page in your browser. '; + const finalPrompt = + (prompt ? prompt + ' ' : '') + + 'Opening authentication page in your browser. '; - if (coreEvents.listenerCount(CoreEvent.ConsentRequest) === 0) { - if (isHeadlessMode()) { - throw new FatalAuthenticationError( - 'Interactive consent could not be obtained.\n' + - 'Please run Gemini CLI in an interactive terminal to authenticate, or use NO_BROWSER=true for manual authentication.', - ); - } + if (isHeadlessMode()) { return getOauthConsentNonInteractive(finalPrompt); + } else if (coreEvents.listenerCount(CoreEvent.ConsentRequest) > 0) { + return getOauthConsentInteractive(finalPrompt); } - - return getOauthConsentInteractive(finalPrompt); + throw new FatalAuthenticationError( + 'Authentication consent could not be obtained.\n' + + 'Please run Gemini CLI in an interactive terminal to authenticate, ' + + 'or use NO_BROWSER=true for manual authentication.', + ); } async function getOauthConsentNonInteractive(prompt: string) { diff --git a/packages/core/src/utils/compatibility.test.ts b/packages/core/src/utils/compatibility.test.ts index 8db512292a..faf0dd579d 100644 --- a/packages/core/src/utils/compatibility.test.ts +++ b/packages/core/src/utils/compatibility.test.ts @@ -9,8 +9,10 @@ import os from 'node:os'; import { isWindows10, isJetBrainsTerminal, + supports256Colors, supportsTrueColor, getCompatibilityWarnings, + WarningPriority, } from './compatibility.js'; vi.mock('node:os', () => ({ @@ -30,64 +32,128 @@ describe('compatibility', () => { }); describe('isWindows10', () => { - it('should return true for Windows 10 (build < 22000)', () => { - vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(os.release).mockReturnValue('10.0.19041'); - expect(isWindows10()).toBe(true); - }); - - it('should return false for Windows 11 (build >= 22000)', () => { - vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(os.release).mockReturnValue('10.0.22000'); - expect(isWindows10()).toBe(false); - }); - - it('should return false for non-Windows platforms', () => { - vi.mocked(os.platform).mockReturnValue('darwin'); - vi.mocked(os.release).mockReturnValue('20.6.0'); - expect(isWindows10()).toBe(false); - }); + it.each<{ + platform: NodeJS.Platform; + release: string; + expected: boolean; + desc: string; + }>([ + { + platform: 'win32', + release: '10.0.19041', + expected: true, + desc: 'Windows 10 (build < 22000)', + }, + { + platform: 'win32', + release: '10.0.22000', + expected: false, + desc: 'Windows 11 (build >= 22000)', + }, + { + platform: 'darwin', + release: '20.6.0', + expected: false, + desc: 'non-Windows platforms', + }, + ])( + 'should return $expected for $desc', + ({ platform, release, expected }) => { + vi.mocked(os.platform).mockReturnValue(platform); + vi.mocked(os.release).mockReturnValue(release); + expect(isWindows10()).toBe(expected); + }, + ); }); describe('isJetBrainsTerminal', () => { - it('should return true when TERMINAL_EMULATOR is JetBrains-JediTerm', () => { - vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); - expect(isJetBrainsTerminal()).toBe(true); + it.each<{ env: string; expected: boolean; desc: string }>([ + { + env: 'JetBrains-JediTerm', + expected: true, + desc: 'TERMINAL_EMULATOR is JetBrains-JediTerm', + }, + { env: 'something-else', expected: false, desc: 'other terminals' }, + { env: '', expected: false, desc: 'TERMINAL_EMULATOR is not set' }, + ])('should return $expected when $desc', ({ env, expected }) => { + vi.stubEnv('TERMINAL_EMULATOR', env); + expect(isJetBrainsTerminal()).toBe(expected); }); + }); - it('should return false for other terminals', () => { - vi.stubEnv('TERMINAL_EMULATOR', 'something-else'); - expect(isJetBrainsTerminal()).toBe(false); - }); - - it('should return false when TERMINAL_EMULATOR is not set', () => { - vi.stubEnv('TERMINAL_EMULATOR', ''); - expect(isJetBrainsTerminal()).toBe(false); + describe('supports256Colors', () => { + it.each<{ + depth: number; + term?: string; + expected: boolean; + desc: string; + }>([ + { + depth: 8, + term: undefined, + expected: true, + desc: 'getColorDepth returns >= 8', + }, + { + depth: 4, + term: 'xterm-256color', + expected: true, + desc: 'TERM contains 256color', + }, + { + depth: 4, + term: 'xterm', + expected: false, + desc: '256 colors are not supported', + }, + ])('should return $expected when $desc', ({ depth, term, expected }) => { + process.stdout.getColorDepth = vi.fn().mockReturnValue(depth); + if (term !== undefined) { + vi.stubEnv('TERM', term); + } + expect(supports256Colors()).toBe(expected); }); }); describe('supportsTrueColor', () => { - it('should return true when COLORTERM is truecolor', () => { - vi.stubEnv('COLORTERM', 'truecolor'); - expect(supportsTrueColor()).toBe(true); - }); - - it('should return true when COLORTERM is 24bit', () => { - vi.stubEnv('COLORTERM', '24bit'); - expect(supportsTrueColor()).toBe(true); - }); - - it('should return true when getColorDepth returns >= 24', () => { - vi.stubEnv('COLORTERM', ''); - process.stdout.getColorDepth = vi.fn().mockReturnValue(24); - expect(supportsTrueColor()).toBe(true); - }); - - it('should return false when true color is not supported', () => { - vi.stubEnv('COLORTERM', ''); - process.stdout.getColorDepth = vi.fn().mockReturnValue(8); - expect(supportsTrueColor()).toBe(false); - }); + it.each<{ + colorterm: string; + depth: number; + expected: boolean; + desc: string; + }>([ + { + colorterm: 'truecolor', + depth: 8, + expected: true, + desc: 'COLORTERM is truecolor', + }, + { + colorterm: '24bit', + depth: 8, + expected: true, + desc: 'COLORTERM is 24bit', + }, + { + colorterm: '', + depth: 24, + expected: true, + desc: 'getColorDepth returns >= 24', + }, + { + colorterm: '', + depth: 8, + expected: false, + desc: 'true color is not supported', + }, + ])( + 'should return $expected when $desc', + ({ colorterm, depth, expected }) => { + vi.stubEnv('COLORTERM', colorterm); + process.stdout.getColorDepth = vi.fn().mockReturnValue(depth); + expect(supportsTrueColor()).toBe(expected); + }, + ); }); describe('getCompatibilityWarnings', () => { @@ -103,45 +169,132 @@ describe('compatibility', () => { vi.stubEnv('TERMINAL_EMULATOR', ''); const warnings = getCompatibilityWarnings(); - expect(warnings).toContain( - 'Warning: Windows 10 detected. Some UI features like smooth scrolling may be degraded. Windows 11 is recommended for the best experience.', + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'windows-10', + message: expect.stringContaining('Windows 10 detected'), + }), ); }); - it('should return JetBrains warning when detected', () => { + it.each<{ + platform: NodeJS.Platform; + release: string; + externalTerminal: string; + desc: string; + }>([ + { + platform: 'darwin', + release: '20.6.0', + externalTerminal: 'iTerm2 or Ghostty', + desc: 'macOS', + }, + { + platform: 'win32', + release: '10.0.22000', + externalTerminal: 'Windows Terminal', + desc: 'Windows', + }, // Valid Windows 11 release to not trigger the Windows 10 warning + { + platform: 'linux', + release: '5.10.0', + externalTerminal: 'Ghostty', + desc: 'Linux', + }, + ])( + 'should return JetBrains warning when detected and in alternate buffer ($desc)', + ({ platform, release, externalTerminal }) => { + vi.mocked(os.platform).mockReturnValue(platform); + vi.mocked(os.release).mockReturnValue(release); + vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); + + const warnings = getCompatibilityWarnings({ isAlternateBuffer: true }); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'jetbrains-terminal', + message: expect.stringContaining( + `Warning: JetBrains mouse scrolling is unreliable. Disabling alternate buffer mode in settings or using an external terminal (e.g., ${externalTerminal}) is recommended.`, + ), + priority: WarningPriority.High, + }), + ); + }, + ); + + it('should not return JetBrains warning when detected but NOT in alternate buffer', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); - const warnings = getCompatibilityWarnings(); - expect(warnings).toContain( - 'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.', - ); + const warnings = getCompatibilityWarnings({ isAlternateBuffer: false }); + expect( + warnings.find((w) => w.id === 'jetbrains-terminal'), + ).toBeUndefined(); }); - it('should return true color warning when not supported', () => { - vi.mocked(os.platform).mockReturnValue('darwin'); + it('should return 256-color warning when 256 colors are not supported', () => { + vi.mocked(os.platform).mockReturnValue('linux'); vi.stubEnv('TERMINAL_EMULATOR', ''); vi.stubEnv('COLORTERM', ''); + vi.stubEnv('TERM', 'xterm'); + process.stdout.getColorDepth = vi.fn().mockReturnValue(4); + + const warnings = getCompatibilityWarnings(); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: '256-color', + message: expect.stringContaining('256-color support not detected'), + priority: WarningPriority.High, + }), + ); + // Should NOT show true-color warning if 256-color warning is shown + expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined(); + }); + + it('should return true color warning when 256 colors are supported but true color is not, and not Apple Terminal', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.stubEnv('TERMINAL_EMULATOR', ''); + vi.stubEnv('COLORTERM', ''); + vi.stubEnv('TERM_PROGRAM', 'xterm'); process.stdout.getColorDepth = vi.fn().mockReturnValue(8); const warnings = getCompatibilityWarnings(); - expect(warnings).toContain( - 'Warning: True color (24-bit) support not detected. Using a terminal with true color enabled will result in a better visual experience.', + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'true-color', + message: expect.stringContaining( + 'True color (24-bit) support not detected', + ), + priority: WarningPriority.Low, + }), ); }); + it('should NOT return true color warning for Apple Terminal', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.stubEnv('TERMINAL_EMULATOR', ''); + vi.stubEnv('COLORTERM', ''); + vi.stubEnv('TERM_PROGRAM', 'Apple_Terminal'); + process.stdout.getColorDepth = vi.fn().mockReturnValue(8); + + const warnings = getCompatibilityWarnings(); + expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined(); + }); + it('should return all warnings when all are detected', () => { vi.mocked(os.platform).mockReturnValue('win32'); vi.mocked(os.release).mockReturnValue('10.0.19041'); vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); vi.stubEnv('COLORTERM', ''); + vi.stubEnv('TERM_PROGRAM', 'xterm'); process.stdout.getColorDepth = vi.fn().mockReturnValue(8); - const warnings = getCompatibilityWarnings(); + const warnings = getCompatibilityWarnings({ isAlternateBuffer: true }); expect(warnings).toHaveLength(3); - expect(warnings[0]).toContain('Windows 10 detected'); - expect(warnings[1]).toContain('JetBrains terminal detected'); - expect(warnings[2]).toContain('True color (24-bit) support not detected'); + expect(warnings[0].message).toContain('Windows 10 detected'); + expect(warnings[1].message).toContain('JetBrains'); + expect(warnings[2].message).toContain( + 'True color (24-bit) support not detected', + ); }); it('should return no warnings in a standard environment with true color', () => { diff --git a/packages/core/src/utils/compatibility.ts b/packages/core/src/utils/compatibility.ts index b1b6240a65..15b2ae24b4 100644 --- a/packages/core/src/utils/compatibility.ts +++ b/packages/core/src/utils/compatibility.ts @@ -30,6 +30,31 @@ export function isJetBrainsTerminal(): boolean { return process.env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm'; } +/** + * Detects if the current terminal is the default Apple Terminal.app. + */ +export function isAppleTerminal(): boolean { + return process.env['TERM_PROGRAM'] === 'Apple_Terminal'; +} + +/** + * Detects if the current terminal supports 256 colors (8-bit). + */ +export function supports256Colors(): boolean { + // Check if stdout supports at least 8-bit color depth + if (process.stdout.getColorDepth && process.stdout.getColorDepth() >= 8) { + return true; + } + + // Check TERM environment variable + const term = process.env['TERM'] || ''; + if (term.includes('256color')) { + return true; + } + + return false; +} + /** * Detects if the current terminal supports true color (24-bit). */ @@ -50,28 +75,64 @@ export function supportsTrueColor(): boolean { return false; } +export enum WarningPriority { + Low = 'low', + High = 'high', +} + +export interface StartupWarning { + id: string; + message: string; + priority: WarningPriority; +} + /** * Returns a list of compatibility warnings based on the current environment. */ -export function getCompatibilityWarnings(): string[] { - const warnings: string[] = []; +export function getCompatibilityWarnings(options?: { + isAlternateBuffer?: boolean; +}): StartupWarning[] { + const warnings: StartupWarning[] = []; if (isWindows10()) { - warnings.push( - 'Warning: Windows 10 detected. Some UI features like smooth scrolling may be degraded. Windows 11 is recommended for the best experience.', - ); + warnings.push({ + id: 'windows-10', + message: + 'Warning: Windows 10 detected. Some UI features like smooth scrolling may be degraded. Windows 11 is recommended for the best experience.', + priority: WarningPriority.High, + }); } - if (isJetBrainsTerminal()) { - warnings.push( - 'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.', - ); + if (isJetBrainsTerminal() && options?.isAlternateBuffer) { + const platformTerminals: Partial> = { + win32: 'Windows Terminal', + darwin: 'iTerm2 or Ghostty', + linux: 'Ghostty', + }; + const suggestion = platformTerminals[os.platform()]; + const suggestedTerminals = suggestion ? ` (e.g., ${suggestion})` : ''; + + warnings.push({ + id: 'jetbrains-terminal', + message: `Warning: JetBrains mouse scrolling is unreliable. Disabling alternate buffer mode in settings or using an external terminal${suggestedTerminals} is recommended.`, + priority: WarningPriority.High, + }); } - if (!supportsTrueColor()) { - warnings.push( - 'Warning: True color (24-bit) support not detected. Using a terminal with true color enabled will result in a better visual experience.', - ); + if (!supports256Colors()) { + warnings.push({ + id: '256-color', + message: + 'Warning: 256-color support not detected. Using a terminal with at least 256-color support is recommended for a better visual experience.', + priority: WarningPriority.High, + }); + } else if (!supportsTrueColor() && !isAppleTerminal()) { + warnings.push({ + id: 'true-color', + message: + 'Warning: True color (24-bit) support not detected. Using a terminal with true color enabled will result in a better visual experience.', + priority: WarningPriority.Low, + }); } return warnings; diff --git a/packages/core/src/utils/editCorrector.test.ts b/packages/core/src/utils/editCorrector.test.ts index 86e7c61d0f..35b126a5ea 100644 --- a/packages/core/src/utils/editCorrector.test.ts +++ b/packages/core/src/utils/editCorrector.test.ts @@ -5,8 +5,8 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { Mock } from 'vitest'; -import { vi, describe, it, expect, beforeEach, type Mocked } from 'vitest'; +import type { Mock, Mocked } from 'vitest'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; import * as fs from 'node:fs'; import { EDIT_TOOL_NAME } from '../tools/tool-names.js'; import type { BaseLlmClient } from '../core/baseLlmClient.js'; diff --git a/packages/core/src/utils/errors.test.ts b/packages/core/src/utils/errors.test.ts index 58c7004190..b3df89ef93 100644 --- a/packages/core/src/utils/errors.test.ts +++ b/packages/core/src/utils/errors.test.ts @@ -12,6 +12,14 @@ import { BadRequestError, ForbiddenError, getErrorMessage, + getErrorType, + FatalAuthenticationError, + FatalCancellationError, + FatalInputError, + FatalSandboxError, + FatalConfigError, + FatalTurnLimitedError, + FatalToolExecutionError, } from './errors.js'; describe('getErrorMessage', () => { @@ -201,3 +209,44 @@ describe('toFriendlyError', () => { expect(toFriendlyError(error)).toBe(error); }); }); + +describe('getErrorType', () => { + it('should return error name for standard errors', () => { + expect(getErrorType(new Error('test'))).toBe('Error'); + expect(getErrorType(new TypeError('test'))).toBe('TypeError'); + expect(getErrorType(new SyntaxError('test'))).toBe('SyntaxError'); + }); + + it('should return constructor name for custom errors', () => { + expect(getErrorType(new FatalAuthenticationError('test'))).toBe( + 'FatalAuthenticationError', + ); + expect(getErrorType(new FatalInputError('test'))).toBe('FatalInputError'); + expect(getErrorType(new FatalSandboxError('test'))).toBe( + 'FatalSandboxError', + ); + expect(getErrorType(new FatalConfigError('test'))).toBe('FatalConfigError'); + expect(getErrorType(new FatalTurnLimitedError('test'))).toBe( + 'FatalTurnLimitedError', + ); + expect(getErrorType(new FatalToolExecutionError('test'))).toBe( + 'FatalToolExecutionError', + ); + expect(getErrorType(new FatalCancellationError('test'))).toBe( + 'FatalCancellationError', + ); + expect(getErrorType(new ForbiddenError('test'))).toBe('ForbiddenError'); + expect(getErrorType(new UnauthorizedError('test'))).toBe( + 'UnauthorizedError', + ); + expect(getErrorType(new BadRequestError('test'))).toBe('BadRequestError'); + }); + + it('should return "unknown" for non-Error objects', () => { + expect(getErrorType('string error')).toBe('unknown'); + expect(getErrorType(123)).toBe('unknown'); + expect(getErrorType({})).toBe('unknown'); + expect(getErrorType(null)).toBe('unknown'); + expect(getErrorType(undefined)).toBe('unknown'); + }); +}); diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index 2bba4f8abe..5465977ff2 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -10,6 +10,16 @@ interface GaxiosError { }; } +function isGaxiosError(error: unknown): error is GaxiosError { + return ( + typeof error === 'object' && + error !== null && + 'response' in error && + typeof (error as { response: unknown }).response === 'object' && + (error as { response: unknown }).response !== null + ); +} + export function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && 'code' in error; } @@ -26,6 +36,15 @@ export function getErrorMessage(error: unknown): string { } } +export function getErrorType(error: unknown): string { + if (!(error instanceof Error)) return 'unknown'; + + // Return constructor name if the generic 'Error' name is used (for custom errors) + return error.name === 'Error' + ? (error.constructor?.name ?? 'Error') + : error.name; +} + export class FatalError extends Error { constructor( message: string, @@ -96,11 +115,41 @@ interface ResponseData { }; } +function isResponseData(data: unknown): data is ResponseData { + if (typeof data !== 'object' || data === null) { + return false; + } + const candidate = data as ResponseData; + if (!('error' in candidate)) { + return false; + } + const error = candidate.error; + if (typeof error !== 'object' || error === null) { + return false; // error property exists but is not an object (could be undefined, but we checked 'in') + } + + // Optional properties check + if ( + 'code' in error && + typeof error.code !== 'number' && + error.code !== undefined + ) { + return false; + } + if ( + 'message' in error && + typeof error.message !== 'string' && + error.message !== undefined + ) { + return false; + } + + return true; +} + export function toFriendlyError(error: unknown): unknown { - if (error && typeof error === 'object' && 'response' in error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const gaxiosError = error as GaxiosError; - const data = parseResponseData(gaxiosError); + if (isGaxiosError(error)) { + const data = parseResponseData(error); if (data && data.error && data.error.message && data.error.code) { switch (data.error.code) { case 400: @@ -120,17 +169,20 @@ export function toFriendlyError(error: unknown): unknown { } function parseResponseData(error: GaxiosError): ResponseData | undefined { + let data = error.response?.data; // Inexplicably, Gaxios sometimes doesn't JSONify the response data. - if (typeof error.response?.data === 'string') { + if (typeof data === 'string') { try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return JSON.parse(error.response?.data) as ResponseData; + data = JSON.parse(data); } catch { return undefined; } } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return error.response?.data as ResponseData | undefined; + + if (isResponseData(data)) { + return data; + } + return undefined; } /** @@ -143,8 +195,15 @@ function parseResponseData(error: GaxiosError): ResponseData | undefined { export function isAuthenticationError(error: unknown): boolean { // Check for MCP SDK errors with code property // (SseError and StreamableHTTPError both have numeric 'code' property) - if (error && typeof error === 'object' && 'code' in error) { - const errorCode = (error as { code: unknown }).code; + if ( + error && + typeof error === 'object' && + 'code' in error && + typeof (error as { code: unknown }).code === 'number' + ) { + // Safe access after check + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const errorCode = (error as { code: number }).code; if (errorCode === 401) { return true; } diff --git a/packages/core/src/utils/fileDiffUtils.ts b/packages/core/src/utils/fileDiffUtils.ts index bf9478627c..c0d3f64f37 100644 --- a/packages/core/src/utils/fileDiffUtils.ts +++ b/packages/core/src/utils/fileDiffUtils.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { FileDiff } from '../tools/tools.js'; +import type { DiffStat, FileDiff } from '../tools/tools.js'; import type { ToolCallRecord } from '../services/chatRecordingService.js'; /** @@ -23,17 +23,14 @@ export function getFileDiffFromResultDisplay( typeof resultDisplay.diffStat === 'object' && resultDisplay.diffStat !== null ) { - const diffStat = resultDisplay.diffStat as FileDiff['diffStat']; - if (diffStat) { + if (resultDisplay.diffStat) { return resultDisplay; } } return undefined; } -export function computeModelAddedAndRemovedLines( - stats: FileDiff['diffStat'] | undefined, -): { +export function computeModelAddedAndRemovedLines(stats: DiffStat | undefined): { addedLines: number; removedLines: number; } { diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index ef24dfca03..c2f413a27e 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -930,7 +930,7 @@ describe('fileUtils', () => { expect(result.returnDisplay).toContain('Path is a directory'); }); - it('should paginate text files correctly (offset and limit)', async () => { + it('should paginate text files correctly (startLine and endLine)', async () => { const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`); actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); @@ -938,9 +938,9 @@ describe('fileUtils', () => { testTextFilePath, tempRootDir, new StandardFileSystemService(), - 5, - 5, - ); // Read lines 6-10 + 6, + 10, + ); // Read lines 6-10 (1-based) const expectedContent = lines.slice(5, 10).join('\n'); expect(result.llmContent).toBe(expectedContent); @@ -954,13 +954,13 @@ describe('fileUtils', () => { const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`); actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); - // Read from line 11 to 20. The start is not 0, so it's truncated. + // Read from line 11 to 20. The start is not 1, so it's truncated. const result = await processSingleFileContent( testTextFilePath, tempRootDir, new StandardFileSystemService(), - 10, - 10, + 11, + 20, ); const expectedContent = lines.slice(10, 20).join('\n'); @@ -971,7 +971,7 @@ describe('fileUtils', () => { expect(result.linesShown).toEqual([11, 20]); }); - it('should handle limit exceeding file length', async () => { + it('should handle endLine exceeding file length', async () => { const lines = ['Line 1', 'Line 2']; actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); @@ -979,7 +979,7 @@ describe('fileUtils', () => { testTextFilePath, tempRootDir, new StandardFileSystemService(), - 0, + 1, 10, ); const expectedContent = lines.join('\n'); @@ -1015,21 +1015,22 @@ describe('fileUtils', () => { expect(result.isTruncated).toBe(true); }); - it('should truncate when line count exceeds the limit', async () => { - const lines = Array.from({ length: 11 }, (_, i) => `Line ${i + 1}`); + it('should truncate when line count exceeds the default limit', async () => { + const lines = Array.from({ length: 2500 }, (_, i) => `Line ${i + 1}`); actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); - // Read 5 lines, but there are 11 total + // No ranges provided, should use default limit (2000) const result = await processSingleFileContent( testTextFilePath, tempRootDir, new StandardFileSystemService(), - 0, - 5, ); expect(result.isTruncated).toBe(true); - expect(result.returnDisplay).toBe('Read lines 1-5 of 11 from test.txt'); + expect(result.returnDisplay).toBe( + 'Read lines 1-2000 of 2500 from test.txt', + ); + expect(result.linesShown).toEqual([1, 2000]); }); it('should truncate when a line length exceeds the character limit', async () => { @@ -1043,7 +1044,7 @@ describe('fileUtils', () => { testTextFilePath, tempRootDir, new StandardFileSystemService(), - 0, + 1, 11, ); @@ -1069,7 +1070,7 @@ describe('fileUtils', () => { testTextFilePath, tempRootDir, new StandardFileSystemService(), - 0, + 1, 10, ); expect(result.isTruncated).toBe(true); diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 32f32129c0..a5b32a3cb4 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -53,7 +53,7 @@ export async function loadWasmBinary( } // Constants for text file processing -const DEFAULT_MAX_LINES_TEXT_FILE = 2000; +export const DEFAULT_MAX_LINES_TEXT_FILE = 2000; const MAX_LINE_LENGTH_TEXT_FILE = 2000; // Default values for encoding and separator format @@ -399,16 +399,17 @@ export interface ProcessedFileReadResult { * Reads and processes a single file, handling text, images, and PDFs. * @param filePath Absolute path to the file. * @param rootDirectory Absolute path to the project root for relative path display. - * @param offset Optional offset for text files (0-based line number). - * @param limit Optional limit for text files (number of lines to read). + * @param _fileSystemService Currently unused in this function; kept for signature stability. + * @param startLine Optional 1-based line number to start reading from. + * @param endLine Optional 1-based line number to end reading at (inclusive). * @returns ProcessedFileReadResult object. */ export async function processSingleFileContent( filePath: string, rootDirectory: string, - fileSystemService: FileSystemService, - offset?: number, - limit?: number, + _fileSystemService: FileSystemService, + startLine?: number, + endLine?: number, ): Promise { try { if (!fs.existsSync(filePath)) { @@ -474,14 +475,24 @@ export async function processSingleFileContent( const lines = content.split('\n'); const originalLineCount = lines.length; - const startLine = offset || 0; - const effectiveLimit = - limit === undefined ? DEFAULT_MAX_LINES_TEXT_FILE : limit; - // Ensure endLine does not exceed originalLineCount - const endLine = Math.min(startLine + effectiveLimit, originalLineCount); - // Ensure selectedLines doesn't try to slice beyond array bounds if startLine is too high - const actualStartLine = Math.min(startLine, originalLineCount); - const selectedLines = lines.slice(actualStartLine, endLine); + let sliceStart = 0; + let sliceEnd = originalLineCount; + + if (startLine !== undefined || endLine !== undefined) { + sliceStart = startLine ? startLine - 1 : 0; + sliceEnd = endLine + ? Math.min(endLine, originalLineCount) + : Math.min( + sliceStart + DEFAULT_MAX_LINES_TEXT_FILE, + originalLineCount, + ); + } else { + sliceEnd = Math.min(DEFAULT_MAX_LINES_TEXT_FILE, originalLineCount); + } + + // Ensure selectedLines doesn't try to slice beyond array bounds + const actualStart = Math.min(sliceStart, originalLineCount); + const selectedLines = lines.slice(actualStart, sliceEnd); let linesWereTruncatedInLength = false; const formattedLines = selectedLines.map((line) => { @@ -494,17 +505,18 @@ export async function processSingleFileContent( return line; }); - const contentRangeTruncated = - startLine > 0 || endLine < originalLineCount; - const isTruncated = contentRangeTruncated || linesWereTruncatedInLength; + const isTruncated = + actualStart > 0 || + sliceEnd < originalLineCount || + linesWereTruncatedInLength; const llmContent = formattedLines.join('\n'); // By default, return nothing to streamline the common case of a successful read_file. let returnDisplay = ''; - if (contentRangeTruncated) { + if (actualStart > 0 || sliceEnd < originalLineCount) { returnDisplay = `Read lines ${ - actualStartLine + 1 - }-${endLine} of ${originalLineCount} from ${relativePathForDisplay}`; + actualStart + 1 + }-${sliceEnd} of ${originalLineCount} from ${relativePathForDisplay}`; if (linesWereTruncatedInLength) { returnDisplay += ' (some lines were shortened)'; } @@ -517,7 +529,7 @@ export async function processSingleFileContent( returnDisplay, isTruncated, originalLineCount, - linesShown: [actualStartLine + 1, endLine], + linesShown: [actualStart + 1, sliceEnd], }; } case 'image': diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index 6aedaf7276..97560f7070 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -145,9 +145,11 @@ class RecursiveFileSearch implements FileSearch { if (pattern.includes('*') || !this.fzf) { filteredCandidates = await filter(candidates, pattern, options.signal); } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment filteredCandidates = await this.fzf .find(pattern) .then((results: Array>) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return results.map((entry: FzfResultItem) => entry.item), ) .catch(() => { diff --git a/packages/core/src/utils/getFolderStructure.test.ts b/packages/core/src/utils/getFolderStructure.test.ts index e6c0c88cdc..5a9a077e91 100644 --- a/packages/core/src/utils/getFolderStructure.test.ts +++ b/packages/core/src/utils/getFolderStructure.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fsPromises from 'node:fs/promises'; -import * as nodePath from 'node:path'; import * as os from 'node:os'; import { getFolderStructure } from './getFolderStructure.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; @@ -251,7 +250,7 @@ ${testRootDir}${path.sep} it('should ignore files and folders specified in .gitignore', async () => { await fsPromises.writeFile( - nodePath.join(testRootDir, '.gitignore'), + path.join(testRootDir, '.gitignore'), 'ignored.txt\nnode_modules/\n.gemini/*\n!/.gemini/config.yaml', ); await createTestFile('file1.txt'); @@ -274,7 +273,7 @@ ${testRootDir}${path.sep} it('should not ignore files if respectGitIgnore is false', async () => { await fsPromises.writeFile( - nodePath.join(testRootDir, '.gitignore'), + path.join(testRootDir, '.gitignore'), 'ignored.txt', ); await createTestFile('file1.txt'); @@ -298,7 +297,7 @@ ${testRootDir}${path.sep} describe('with geminiignore', () => { it('should ignore geminiignore files by default', async () => { await fsPromises.writeFile( - nodePath.join(testRootDir, GEMINI_IGNORE_FILE_NAME), + path.join(testRootDir, GEMINI_IGNORE_FILE_NAME), 'ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml', ); await createTestFile('file1.txt'); @@ -318,7 +317,7 @@ ${testRootDir}${path.sep} it('should not ignore files if respectGeminiIgnore is false', async () => { await fsPromises.writeFile( - nodePath.join(testRootDir, GEMINI_IGNORE_FILE_NAME), + path.join(testRootDir, GEMINI_IGNORE_FILE_NAME), 'ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml', ); await createTestFile('file1.txt'); diff --git a/packages/core/src/utils/getPty.ts b/packages/core/src/utils/getPty.ts index 35712b6de3..b5d53ca473 100644 --- a/packages/core/src/utils/getPty.ts +++ b/packages/core/src/utils/getPty.ts @@ -23,12 +23,16 @@ export const getPty = async (): Promise => { } try { const lydell = '@lydell/node-pty'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const module = await import(lydell); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment return { module, name: 'lydell-node-pty' }; } catch (_e) { try { const nodePty = 'node-pty'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const module = await import(nodePty); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment return { module, name: 'node-pty' }; } catch (_e2) { return null; diff --git a/packages/core/src/utils/googleErrors.ts b/packages/core/src/utils/googleErrors.ts index 70c7098118..88f78e35d6 100644 --- a/packages/core/src/utils/googleErrors.ts +++ b/packages/core/src/utils/googleErrors.ts @@ -166,10 +166,12 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null { depth < maxDepth ) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedMessage = JSON.parse( currentError.message.replace(/\u00A0/g, '').replace(/\n/g, ' '), ); if (parsedMessage.error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment currentError = parsedMessage.error; depth++; } else { @@ -205,9 +207,13 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null { detailObj['@type'] = detailObj[typeKey]; delete detailObj[typeKey]; } - // We can just cast it; the consumer will have to switch on @type - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - details.push(detailObj as unknown as GoogleApiErrorDetail); + // Basic structural check before casting. + // Since the proto definitions are loose, we primarily rely on @type presence. + if (typeof detailObj['@type'] === 'string') { + // We can just cast it; the consumer will have to switch on @type + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + details.push(detailObj as unknown as GoogleApiErrorDetail); + } } } } @@ -223,6 +229,16 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null { return null; } +function isErrorShape(obj: unknown): obj is ErrorShape { + return ( + typeof obj === 'object' && + obj !== null && + (('message' in obj && + typeof (obj as { message: unknown }).message === 'string') || + ('code' in obj && typeof (obj as { code: unknown }).code === 'number')) + ); +} + function fromGaxiosError(errorObj: object): ErrorShape | undefined { const gaxiosError = errorObj as { response?: { @@ -243,6 +259,7 @@ function fromGaxiosError(errorObj: object): ErrorShape | undefined { if (typeof data === 'string') { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data = JSON.parse(data); } catch (_) { // Not a JSON string, can't parse. @@ -250,13 +267,17 @@ function fromGaxiosError(errorObj: object): ErrorShape | undefined { } if (Array.isArray(data) && data.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data = data[0]; } if (typeof data === 'object' && data !== null) { if ('error' in data) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - outerError = (data as { error: ErrorShape }).error; + const potentialError = (data as { error: unknown }).error; + if (isErrorShape(potentialError)) { + outerError = potentialError; + } } } } @@ -288,6 +309,7 @@ function fromApiError(errorObj: object): ErrorShape | undefined { if (typeof data === 'string') { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data = JSON.parse(data); } catch (_) { // Not a JSON string, can't parse. @@ -297,6 +319,7 @@ function fromApiError(errorObj: object): ErrorShape | undefined { const lastBrace = data.lastIndexOf('}'); if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data = JSON.parse(data.substring(firstBrace, lastBrace + 1)); } catch (__) { // Still failed @@ -307,13 +330,17 @@ function fromApiError(errorObj: object): ErrorShape | undefined { } if (Array.isArray(data) && data.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data = data[0]; } if (typeof data === 'object' && data !== null) { if ('error' in data) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - outerError = (data as { error: ErrorShape }).error; + const potentialError = (data as { error: unknown }).error; + if (isErrorShape(potentialError)) { + outerError = potentialError; + } } } } diff --git a/packages/core/src/utils/googleQuotaErrors.test.ts b/packages/core/src/utils/googleQuotaErrors.test.ts index c75eb8de4f..06bde6444b 100644 --- a/packages/core/src/utils/googleQuotaErrors.test.ts +++ b/packages/core/src/utils/googleQuotaErrors.test.ts @@ -65,7 +65,23 @@ describe('classifyGoogleError', () => { expect((result as RetryableQuotaError).message).toBe(rawError.message); }); - it('should return original error if code is not 429', () => { + it('should return RetryableQuotaError for 503 Service Unavailable', () => { + const apiError: GoogleApiError = { + code: 503, + message: 'Service Unavailable', + details: [], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const originalError = new Error('Service Unavailable'); + const result = classifyGoogleError(originalError); + expect(result).toBeInstanceOf(RetryableQuotaError); + if (result instanceof RetryableQuotaError) { + expect(result.cause).toBe(apiError); + expect(result.message).toBe('Service Unavailable'); + } + }); + + it('should return original error if code is not 429 or 503', () => { const apiError: GoogleApiError = { code: 500, message: 'Server error', diff --git a/packages/core/src/utils/googleQuotaErrors.ts b/packages/core/src/utils/googleQuotaErrors.ts index 0ecc14d93f..40c1c34361 100644 --- a/packages/core/src/utils/googleQuotaErrors.ts +++ b/packages/core/src/utils/googleQuotaErrors.ts @@ -202,6 +202,21 @@ 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 || diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 32cf8cabc4..3df110d678 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -22,7 +22,7 @@ import { } from '../tools/memoryTool.js'; import { flattenMemory } from '../config/memory.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; -import { GEMINI_DIR, normalizePath } from './paths.js'; +import { GEMINI_DIR, normalizePath, homedir as pathsHomedir } from './paths.js'; import type { HierarchicalMemory } from '../config/memory.js'; function flattenResult(result: { @@ -62,8 +62,6 @@ vi.mock('../utils/paths.js', async (importOriginal) => { }; }); -import { homedir as pathsHomedir } from './paths.js'; - describe('memoryDiscovery', () => { const DEFAULT_FOLDER_TRUST = true; let testRootDir: string; diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index aef6ff50b5..c35d009e1d 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -127,6 +127,7 @@ async function getGeminiMdFilePathsInternal( result.value.global.forEach((p) => globalPaths.add(p)); result.value.project.forEach((p) => projectPaths.add(p)); } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const error = result.reason; const message = error instanceof Error ? error.message : String(error); logger.error(`Error discovering files in directory: ${message}`); @@ -299,6 +300,7 @@ export async function readGeminiMdFiles( } else { // This case shouldn't happen since we catch all errors above, // but handle it for completeness + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const error = result.reason; const message = error instanceof Error ? error.message : String(error); logger.error(`Unexpected error processing file: ${message}`); diff --git a/packages/core/src/utils/safeJsonStringify.ts b/packages/core/src/utils/safeJsonStringify.ts index fd03e7965d..b32a09df27 100644 --- a/packages/core/src/utils/safeJsonStringify.ts +++ b/packages/core/src/utils/safeJsonStringify.ts @@ -27,6 +27,7 @@ export function safeJsonStringify( } seen.add(value); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; }, space, @@ -37,6 +38,7 @@ export function safeJsonStringify( function removeEmptyObjects(data: any): object { const cleanedObject: { [key: string]: unknown } = {}; for (const k in data) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const v = data[k]; if (v !== null && v !== undefined && typeof v === 'boolean') { cleanedObject[k] = v; @@ -59,6 +61,7 @@ export function safeJsonStringifyBooleanValuesOnly(obj: any): string { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion if ((value as Config) !== null && !configSeen) { configSeen = true; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; } if (typeof value === 'boolean') { diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index 8d8579f647..e58b7b8d9b 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -12,9 +12,9 @@ import * as addFormats from 'ajv-formats'; import { debugLogger } from './debugLogger.js'; // Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment const AjvClass = (AjvPkg as any).default || AjvPkg; -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment const Ajv2020Class = (Ajv2020Pkg as any).default || Ajv2020Pkg; const ajvOptions = { @@ -29,12 +29,14 @@ const ajvOptions = { }; // Draft-07 validator (default) +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const ajvDefault: Ajv = new AjvClass(ajvOptions); // Draft-2020-12 validator for MCP servers using rmcp +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const ajv2020: Ajv = new Ajv2020Class(ajvOptions); -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment const addFormatsFunc = (addFormats as any).default || addFormats; addFormatsFunc(ajvDefault); addFormatsFunc(ajv2020); diff --git a/packages/core/src/utils/session.ts b/packages/core/src/utils/session.ts index 96cdbbf48c..2a0ec52115 100644 --- a/packages/core/src/utils/session.ts +++ b/packages/core/src/utils/session.ts @@ -7,3 +7,7 @@ import { randomUUID } from 'node:crypto'; export const sessionId = randomUUID(); + +export function createSessionId(): string { + return randomUUID(); +} diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 7daeb063f5..6f92ec6386 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -479,6 +479,7 @@ function parsePowerShellCommandDetails( hasRedirection?: boolean; } | null = null; try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment parsed = JSON.parse(output); } catch { return { details: [], hasError: true }; diff --git a/packages/core/src/utils/stdio.ts b/packages/core/src/utils/stdio.ts index 8f62906399..66abbe6ade 100644 --- a/packages/core/src/utils/stdio.ts +++ b/packages/core/src/utils/stdio.ts @@ -88,10 +88,13 @@ export function createWorkingStdio() { if (prop === 'write') { return writeToStdout; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value.bind(target); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; }, }); @@ -101,10 +104,13 @@ export function createWorkingStdio() { if (prop === 'write') { return writeToStderr; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value.bind(target); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; }, }); diff --git a/packages/core/src/utils/userAccountManager.ts b/packages/core/src/utils/userAccountManager.ts index 4434a18027..342565a6ca 100644 --- a/packages/core/src/utils/userAccountManager.ts +++ b/packages/core/src/utils/userAccountManager.ts @@ -30,6 +30,7 @@ export class UserAccountManager { return defaultState; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(content); // Inlined validation logic @@ -50,7 +51,9 @@ export class UserAccountManager { } return { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment active: parsed.active ?? null, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment old: parsed.old ?? [], }; } diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts index 81cb909957..7f8fea8d3d 100644 --- a/packages/devtools/src/index.ts +++ b/packages/devtools/src/index.ts @@ -260,6 +260,7 @@ export class DevTools extends EventEmitter { ws.on('message', (data: Buffer) => { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const message = JSON.parse(data.toString()); // Handle registration first diff --git a/packages/sdk/SDK_DESIGN.md b/packages/sdk/SDK_DESIGN.md index d8c8512991..d3031f32b8 100644 --- a/packages/sdk/SDK_DESIGN.md +++ b/packages/sdk/SDK_DESIGN.md @@ -8,7 +8,8 @@ ## `Simple Example` -> **Status:** Implemented. `GeminiCliAgent` supports `cwd` and `sendStream`. +> **Status:** Implemented. `GeminiCliAgent` supports `session()` and +> `resumeSession()`. Equivalent to `gemini -p "what does this project do?"`. Loads all workspace and user settings. @@ -20,10 +21,14 @@ const simpleAgent = new GeminiCliAgent({ cwd: '/path/to/some/dir', }); -for await (const chunk of simpleAgent.sendStream( - 'what does this project do?', -)) { - console.log(chunk); // equivalent to JSON streaming chunks (probably?) for now +// Create a new empty session +const session = simpleAgent.session(); + +// Resume a specific session by ID +// const session = await simpleAgent.resumeSession('some-session-id'); + +for await (const chunk of session.sendStream('what does this project do?')) { + console.log(chunk); // equivalent to JSON streaming chunks } ``` @@ -268,8 +273,9 @@ export interface SessionContext { // helpers to access files and run shell commands while adhering to policies/validation fs: AgentFilesystem; shell: AgentShell; - // the agent itself is passed as context + // the agent and session are passed as context agent: GeminiCliAgent; + session: GeminiCliSession; } export interface AgentFilesystem { diff --git a/packages/sdk/src/agent.integration.test.ts b/packages/sdk/src/agent.integration.test.ts index 5226e30e06..064cd9fad7 100644 --- a/packages/sdk/src/agent.integration.test.ts +++ b/packages/sdk/src/agent.integration.test.ts @@ -30,8 +30,12 @@ describe('GeminiCliAgent Integration', () => { fakeResponses: RECORD_MODE ? undefined : goldenFile, }); + const session = agent.session(); + expect(session.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); const events = []; - const stream = agent.sendStream('Say hello.'); + const stream = session.sendStream('Say hello.'); for await (const event of stream) { events.push(event); @@ -60,9 +64,11 @@ describe('GeminiCliAgent Integration', () => { fakeResponses: RECORD_MODE ? undefined : goldenFile, }); + const session = agent.session(); + // First turn - const stream1 = agent.sendStream('What is the secret number?'); const events1 = []; + const stream1 = session.sendStream('What is the secret number?'); for await (const event of stream1) { events1.push(event); } @@ -72,11 +78,10 @@ describe('GeminiCliAgent Integration', () => { .join(''); expect(responseText1).toContain('1'); - expect(callCount).toBe(1); // Second turn - const stream2 = agent.sendStream('What is the secret number now?'); const events2 = []; + const stream2 = session.sendStream('What is the secret number now?'); for await (const event of stream2) { events2.push(event); } @@ -85,57 +90,60 @@ describe('GeminiCliAgent Integration', () => { .map((e) => (typeof e.value === 'string' ? e.value : '')) .join(''); - // Should still be 1 because instructions are only loaded once per session - expect(responseText2).toContain('1'); - expect(callCount).toBe(1); + expect(responseText2).toContain('2'); }, 30000); - it('handles async dynamic instructions', async () => { - const goldenFile = getGoldenPath('agent-async-instructions'); + it('resumes a session', async () => { + const goldenFile = getGoldenPath('agent-resume-session'); - let callCount = 0; + // Create initial session const agent = new GeminiCliAgent({ - instructions: async (_ctx) => { - await new Promise((resolve) => setTimeout(resolve, 10)); // Simulate async work - callCount++; - return `You are a helpful assistant. The secret number is ${callCount}. Always mention the secret number when asked.`; - }, + instructions: 'You are a memory test. Remember the word "BANANA".', model: 'gemini-2.0-flash', recordResponses: RECORD_MODE ? goldenFile : undefined, fakeResponses: RECORD_MODE ? undefined : goldenFile, }); - // First turn - const stream1 = agent.sendStream('What is the secret number?'); - const events1 = []; - for await (const event of stream1) { - events1.push(event); + const session1 = agent.session({ sessionId: 'resume-test-fixed-id' }); + const sessionId = session1.id; + const stream1 = session1.sendStream('What is the word?'); + for await (const _ of stream1) { + // consume stream } - const responseText1 = events1 - .filter((e) => e.type === 'content') - .map((e) => (typeof e.value === 'string' ? e.value : '')) - .join(''); - expect(responseText1).toContain('1'); - expect(callCount).toBe(1); + // Resume session + // Allow some time for async writes if any + await new Promise((resolve) => setTimeout(resolve, 500)); + + const session2 = await agent.resumeSession(sessionId); + expect(session2.id).toBe(sessionId); - // Second turn - const stream2 = agent.sendStream('What is the secret number now?'); const events2 = []; + const stream2 = session2.sendStream('What is the word again?'); for await (const event of stream2) { events2.push(event); } - const responseText2 = events2 + + const responseText = events2 .filter((e) => e.type === 'content') .map((e) => (typeof e.value === 'string' ? e.value : '')) .join(''); - // Should still be 1 because instructions are only loaded once per session - expect(responseText2).toContain('1'); - expect(callCount).toBe(1); + expect(responseText).toContain('BANANA'); }, 30000); - it('throws when dynamic instructions fail', async () => { + it('throws on invalid instructions', () => { + // Missing instructions should be fine + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => new GeminiCliAgent({} as any).session()).not.toThrow(); + + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new GeminiCliAgent({ instructions: 123 as any }).session(), + ).toThrow('Instructions must be a string or a function.'); + }); + + it('propagates errors from dynamic instructions', async () => { const agent = new GeminiCliAgent({ instructions: () => { throw new Error('Dynamic instruction failure'); @@ -143,7 +151,8 @@ describe('GeminiCliAgent Integration', () => { model: 'gemini-2.0-flash', }); - const stream = agent.sendStream('Say hello.'); + const session = agent.session(); + const stream = session.sendStream('Say hello.'); await expect(async () => { for await (const _event of stream) { diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index de26369cbb..ebae32aa47 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -4,253 +4,102 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as path from 'node:path'; import { - Config, - type ConfigParameters, - AuthType, - PREVIEW_GEMINI_MODEL_AUTO, + Storage, + createSessionId, + type ResumedSessionData, + type ConversationRecord, type ServerGeminiStreamEvent, - getAuthTypeFromEnv, - loadSkillsFromDir, - ActivateSkillTool, - AgentSession, - type AgentConfig, - Scheduler, - ROOT_SCHEDULER_ID, - GeminiEventType, - type ToolCallRequestInfo, } from '@google/gemini-cli-core'; -import { type Tool } from './tool.js'; -import { SdkAgentFilesystem } from './fs.js'; -import { SdkAgentShell } from './shell.js'; -import type { SessionContext } from './types.js'; -import type { SkillReference } from './skills.js'; - -export type SystemInstructions = - | string - | ((context: SessionContext) => string | Promise); - -export interface GeminiCliAgentOptions { - instructions: SystemInstructions; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tools?: Array>; - skills?: SkillReference[]; - model?: string; - cwd?: string; - debug?: boolean; - recordResponses?: string; - fakeResponses?: string; -} +import { GeminiCliSession } from './session.js'; +import type { GeminiCliAgentOptions } from './types.js'; export class GeminiCliAgent { - private readonly config: Config; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly tools: Array>; - private readonly skillRefs: SkillReference[]; - private readonly instructions: SystemInstructions; - private instructionsLoaded = false; - private session: AgentSession | undefined; + private options: GeminiCliAgentOptions; constructor(options: GeminiCliAgentOptions) { - this.instructions = options.instructions; - const cwd = options.cwd || process.cwd(); - this.tools = options.tools || []; - this.skillRefs = options.skills || []; - - const initialMemory = - typeof this.instructions === 'string' ? this.instructions : ''; - - const configParams: ConfigParameters = { - sessionId: `sdk-${Date.now()}`, - targetDir: cwd, - cwd, - debugMode: options.debug ?? false, - model: options.model || PREVIEW_GEMINI_MODEL_AUTO, - userMemory: initialMemory, - // Minimal config - enableHooks: false, - mcpEnabled: false, - extensionsEnabled: false, - recordResponses: options.recordResponses, - fakeResponses: options.fakeResponses, - skillsSupport: true, - adminSkillsEnabled: true, - }; - - this.config = new Config(configParams); + this.options = options; } - private async initialize(): Promise { - if (this.config.getContentGenerator()) { - return; - } + /** + * Creates a new session. + * + * @param options Session options. + * @returns A new GeminiCliSession instance. + */ + session(options?: { sessionId?: string }): GeminiCliSession { + const sessionId = options?.sessionId || createSessionId(); + return new GeminiCliSession(this.options, sessionId, this); + } - const authType = getAuthTypeFromEnv() || AuthType.COMPUTE_ADC; - await this.config.refreshAuth(authType); - await this.config.initialize(); + /** + * Resumes a session by ID. + * + * @param sessionId The ID of the session to resume. + * @returns A GeminiCliSession instance hydrated with session history. + */ + async resumeSession(sessionId: string): Promise { + const cwd = this.options.cwd || process.cwd(); + const storage = new Storage(cwd); + await storage.initialize(); - // Load additional skills from options - if (this.skillRefs.length > 0) { - const skillManager = this.config.getSkillManager(); - const loadPromises = this.skillRefs.map(async (ref) => { - try { - if (ref.type === 'dir') { - return await loadSkillsFromDir(ref.path); - } - } catch (e) { - // eslint-disable-next-line no-console - console.error(`Failed to load skills from ${ref.path}:`, e); - } - return []; - }); + let conversation: ConversationRecord | undefined; + let filePath: string | undefined; - const loadedSkills = (await Promise.all(loadPromises)).flat(); - if (loadedSkills.length > 0) { - skillManager.addSkills(loadedSkills); - } - } + const sessions = await storage.listProjectChatFiles(); - // Re-register ActivateSkillTool if we have skills - const skillManager = this.config.getSkillManager(); - if (skillManager.getSkills().length > 0) { - const registry = this.config.getToolRegistry(); - const toolName = ActivateSkillTool.Name; - if (registry.getTool(toolName)) { - registry.unregisterTool(toolName); - } - registry.registerTool( - new ActivateSkillTool(this.config, this.config.getMessageBus()), + if (sessions.length === 0) { + throw new Error( + `No sessions found in ${path.join(storage.getProjectTempDir(), 'chats')}`, ); } - // Note: SDK-specific Tool instances (this.tools) are still using the SDKTool wrapper - // which binds context. In the new AgentSession, we might need a better way to - // pass these tools. For now, we'll register them in the global registry - // so AgentSession can find them. - const registry = this.config.getToolRegistry(); - const messageBus = this.config.getMessageBus(); - for (const toolDef of this.tools) { - // We'll need a way to provide context to these tools. - // In the legacy loop, it was done per-turn. - // For now, we register them as-is. - // TODO: Improve SDK tool context binding in AgentSession. - const { SdkTool } = await import('./tool.js'); - const sdkTool = new SdkTool(toolDef, messageBus, this); - registry.registerTool(sdkTool); + const truncatedId = sessionId.slice(0, 8); + // Optimization: filenames include first 8 chars of sessionId. + const candidates = sessions.filter((s) => s.filePath.includes(truncatedId)); + const filesToCheck = candidates.length > 0 ? candidates : sessions; + + for (const sessionFile of filesToCheck) { + const loaded = await storage.loadProjectTempFile( + sessionFile.filePath, + ); + if (loaded && loaded.sessionId === sessionId) { + conversation = loaded; + filePath = path.join(storage.getProjectTempDir(), sessionFile.filePath); + break; + } } + + if (!conversation || !filePath) { + throw new Error(`Session with ID ${sessionId} not found`); + } + + const resumedData: ResumedSessionData = { + conversation, + filePath, + }; + + const session = new GeminiCliSession( + this.options, + conversation.sessionId, + this, + resumedData, + ); + await session.initialize(); + return session; } + /** + * Helper for non-session-managed streams. + * @deprecated Use agent.session().sendStream() instead. + */ async *sendStream( prompt: string, signal?: AbortSignal, ): AsyncGenerator { - await this.initialize(); - - const sessionId = this.config.getSessionId(); - const client = this.config.getGeminiClient(); - - if (!this.instructionsLoaded && typeof this.instructions === 'function') { - const fs = new SdkAgentFilesystem(this.config); - const shell = new SdkAgentShell(this.config); - const context: SessionContext = { - sessionId, - transcript: client.getHistory(), - cwd: this.config.getWorkingDir(), - timestamp: new Date().toISOString(), - fs, - shell, - agent: this, - }; - try { - const newInstructions = await this.instructions(context); - this.config.setUserMemory(newInstructions); - client.updateSystemInstruction(); - this.instructionsLoaded = true; - } catch (e) { - throw e instanceof Error - ? e - : new Error(`Error resolving dynamic instructions: ${String(e)}`); - } - } - - const agentConfig: AgentConfig = { - name: 'sdk-agent', - systemInstruction: this.config.getUserMemory(), - model: this.config.getModel(), - capabilities: { - compression: true, - loopDetection: true, - }, - }; - - const useAgentFactory = - this.config.getExperimentalSetting('useAgentFactoryAll') || - this.config.getExperimentalSetting('useAgentFactorySdk'); - - if (useAgentFactory) { - if (!this.session) { - this.session = new AgentSession(sessionId, agentConfig, this.config); - } - - const stream = this.session.prompt(prompt, signal); - - for await (const event of stream) { - // Map AgentEvent back to ServerGeminiStreamEvent if possible, - // or yield as is. The SDK user expects ServerGeminiStreamEvent. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - yield event as unknown as ServerGeminiStreamEvent; - } - } else { - // Legacy Manual Loop logic... - // For now, if flag is off, we might want to fall back to the old logic - // but the old logic was removed in the previous write_file. - // I should probably restore it or keep it gated. - // Since I overwrote it, I'll provide a minimal version or the original one. - yield* this.legacySendStream(prompt, signal); - } - } - - private async *legacySendStream( - prompt: string, - signal?: AbortSignal, - ): AsyncGenerator { - const sessionId = this.config.getSessionId(); - const client = this.config.getGeminiClient(); - const scheduler = new Scheduler({ - config: this.config, - messageBus: this.config.getMessageBus(), - getPreferredEditor: () => undefined, - schedulerId: ROOT_SCHEDULER_ID, - }); - - let currentInput: string | Part[] = prompt; - - while (true) { - const stream = client.sendMessageStream( - Array.isArray(currentInput) ? currentInput : [{ text: currentInput }], - signal ?? new AbortController().signal, - `sdk-${sessionId}`, - ); - - const toolCalls: ToolCallRequestInfo[] = []; - - for await (const event of stream) { - if (event.type === GeminiEventType.ToolCallRequest) { - toolCalls.push(event.value); - } - yield event; - } - - if (toolCalls.length > 0) { - const completedCalls = await scheduler.schedule( - toolCalls, - signal ?? new AbortController().signal, - ); - currentInput = completedCalls.flatMap((c) => c.response.responseParts); - } else { - break; - } - } + const session = this.session(); + yield* session.sendStream(prompt, signal); } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f1b9e020f5..91e7a080f0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -5,6 +5,7 @@ */ export * from './agent.js'; +export * from './session.js'; export * from './tool.js'; export * from './skills.js'; export * from './types.js'; diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts new file mode 100644 index 0000000000..736246a554 --- /dev/null +++ b/packages/sdk/src/session.ts @@ -0,0 +1,326 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Config, + type ConfigParameters, + AuthType, + PREVIEW_GEMINI_MODEL_AUTO, + GeminiEventType, + type ToolCallRequestInfo, + type ServerGeminiStreamEvent, + type GeminiClient, + type Content, + scheduleAgentTools, + getAuthTypeFromEnv, + type ToolRegistry, + loadSkillsFromDir, + ActivateSkillTool, + type ResumedSessionData, + PolicyDecision, + AgentSession, + type AgentConfig, + Scheduler, + ROOT_SCHEDULER_ID, +} from '@google/gemini-cli-core'; + +import { type Tool, SdkTool } from './tool.js'; +import { SdkAgentFilesystem } from './fs.js'; +import { SdkAgentShell } from './shell.js'; +import type { + SessionContext, + GeminiCliAgentOptions, + SystemInstructions, +} from './types.js'; +import type { SkillReference } from './skills.js'; +import type { GeminiCliAgent } from './agent.js'; + +export class GeminiCliSession { + private readonly config: Config; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly tools: Array>; + private readonly skillRefs: SkillReference[]; + private readonly instructions: SystemInstructions | undefined; + private client: GeminiClient | undefined; + private initialized = false; + private agentSession: AgentSession | undefined; + + constructor( + options: GeminiCliAgentOptions, + private readonly sessionId: string, + private readonly agent: GeminiCliAgent, + private readonly resumedData?: ResumedSessionData, + ) { + this.instructions = options.instructions; + const cwd = options.cwd || process.cwd(); + this.tools = options.tools || []; + this.skillRefs = options.skills || []; + + let initialMemory = ''; + if (typeof this.instructions === 'string') { + initialMemory = this.instructions; + } else if (this.instructions && typeof this.instructions !== 'function') { + throw new Error('Instructions must be a string or a function.'); + } + + const configParams: ConfigParameters = { + sessionId: this.sessionId, + targetDir: cwd, + cwd, + debugMode: options.debug ?? false, + model: options.model || PREVIEW_GEMINI_MODEL_AUTO, + userMemory: initialMemory, + // Minimal config + enableHooks: false, + mcpEnabled: false, + extensionsEnabled: false, + recordResponses: options.recordResponses, + fakeResponses: options.fakeResponses, + skillsSupport: true, + adminSkillsEnabled: true, + policyEngineConfig: { + // TODO: Revisit this default when we have a mechanism for wiring up approvals + defaultDecision: PolicyDecision.ALLOW, + }, + }; + + this.config = new Config(configParams); + } + + get id(): string { + return this.sessionId; + } + + async initialize(): Promise { + if (this.initialized) return; + + const authType = getAuthTypeFromEnv() || AuthType.COMPUTE_ADC; + + await this.config.refreshAuth(authType); + await this.config.initialize(); + + // Load additional skills from options + if (this.skillRefs.length > 0) { + const skillManager = this.config.getSkillManager(); + + const loadPromises = this.skillRefs.map(async (ref) => { + try { + if (ref.type === 'dir') { + return await loadSkillsFromDir(ref.path); + } + } catch (e) { + // TODO: refactor this to use a proper logger interface + // eslint-disable-next-line no-console + console.error(`Failed to load skills from ${ref.path}:`, e); + } + return []; + }); + + const loadedSkills = (await Promise.all(loadPromises)).flat(); + + if (loadedSkills.length > 0) { + skillManager.addSkills(loadedSkills); + } + } + + // Re-register ActivateSkillTool if we have skills + const skillManager = this.config.getSkillManager(); + if (skillManager.getSkills().length > 0) { + const registry = this.config.getToolRegistry(); + const toolName = ActivateSkillTool.Name; + if (registry.getTool(toolName)) { + registry.unregisterTool(toolName); + } + registry.registerTool( + new ActivateSkillTool(this.config, this.config.getMessageBus()), + ); + } + + // Register tools + const registry = this.config.getToolRegistry(); + const messageBus = this.config.getMessageBus(); + + for (const toolDef of this.tools) { + const sdkTool = new SdkTool(toolDef, messageBus, this.agent, undefined); + registry.registerTool(sdkTool); + } + + this.client = this.config.getGeminiClient(); + + if (this.resumedData) { + // The AgentSession's resume method is cleaner + // but for now we follow the existing pattern if needed + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.getAgentSession().resume(this.resumedData); + } + + this.initialized = true; + } + + private getAgentSession(): AgentSession { + if (!this.agentSession) { + const agentConfig: AgentConfig = { + name: 'sdk-agent', + systemInstruction: this.config.getUserMemory(), + model: this.config.getModel(), + capabilities: { + compression: true, + loopDetection: true, + }, + }; + this.agentSession = new AgentSession( + this.sessionId, + agentConfig, + this.config, + ); + } + return this.agentSession; + } + + async *sendStream( + prompt: string, + signal?: AbortSignal, + ): AsyncGenerator { + if (!this.initialized || !this.client) { + await this.initialize(); + } + const client = this.client!; + const abortSignal = signal ?? new AbortController().signal; + const sessionId = this.config.getSessionId(); + + const useAgentFactory = + this.config.getExperimentalSetting('useAgentFactoryAll') || + this.config.getExperimentalSetting('useAgentFactorySdk'); + + if (useAgentFactory) { + // Dynamic instructions check before starting + if (typeof this.instructions === 'function') { + const fs = new SdkAgentFilesystem(this.config); + const shell = new SdkAgentShell(this.config); + const context: SessionContext = { + sessionId, + transcript: client.getHistory(), + cwd: this.config.getWorkingDir(), + timestamp: new Date().toISOString(), + fs, + shell, + agent: this.agent, + session: this, + }; + const newInstructions = await this.instructions(context); + this.config.setUserMemory(newInstructions); + client.updateSystemInstruction(); + } + + const stream = this.getAgentSession().prompt(prompt, abortSignal); + for await (const event of stream) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + yield event as unknown as ServerGeminiStreamEvent; + } + } else { + yield* this.legacySendStream(prompt, abortSignal); + } + } + + private async *legacySendStream( + prompt: string, + signal: AbortSignal, + ): AsyncGenerator { + const client = this.client!; + const sessionId = this.sessionId; + const fs = new SdkAgentFilesystem(this.config); + const shell = new SdkAgentShell(this.config); + + let request: Parameters[0] = [ + { text: prompt }, + ]; + + while (true) { + if (typeof this.instructions === 'function') { + const context: SessionContext = { + sessionId, + transcript: client.getHistory(), + cwd: this.config.getWorkingDir(), + timestamp: new Date().toISOString(), + fs, + shell, + agent: this.agent, + session: this, + }; + const newInstructions = await this.instructions(context); + this.config.setUserMemory(newInstructions); + client.updateSystemInstruction(); + } + + const stream = client.sendMessageStream(request, signal, sessionId); + const toolCallsToSchedule: ToolCallRequestInfo[] = []; + + for await (const event of stream) { + yield event; + if (event.type === GeminiEventType.ToolCallRequest) { + const toolCall = event.value; + let args = toolCall.args; + if (typeof args === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + args = JSON.parse(args); + } + toolCallsToSchedule.push({ + ...toolCall, + args, + isClientInitiated: false, + prompt_id: sessionId, + }); + } + } + + if (toolCallsToSchedule.length === 0) { + break; + } + + const transcript: Content[] = client.getHistory(); + const context: SessionContext = { + sessionId, + transcript, + cwd: this.config.getWorkingDir(), + timestamp: new Date().toISOString(), + fs, + shell, + agent: this.agent, + session: this, + }; + + const originalRegistry = this.config.getToolRegistry(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const scopedRegistry: ToolRegistry = Object.create(originalRegistry); + scopedRegistry.getTool = (name: string) => { + const tool = originalRegistry.getTool(name); + if (tool instanceof SdkTool) { + return tool.bindContext(context); + } + return tool; + }; + + const completedCalls = await scheduleAgentTools( + this.config, + toolCallsToSchedule, + { + schedulerId: sessionId, + toolRegistry: scopedRegistry, + signal, + }, + ); + + const functionResponses = completedCalls.flatMap( + (call) => call.response.responseParts, + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + request = functionResponses as unknown as Parameters< + GeminiClient['sendMessageStream'] + >[0]; + } + } +} diff --git a/packages/sdk/src/skills.integration.test.ts b/packages/sdk/src/skills.integration.test.ts index a91480ff30..48f0e0fb06 100644 --- a/packages/sdk/src/skills.integration.test.ts +++ b/packages/sdk/src/skills.integration.test.ts @@ -39,8 +39,9 @@ describe('GeminiCliAgent Skills Integration', () => { // 1. Ask to activate the skill const events = []; + const session = agent.session(); // The prompt explicitly asks to activate the skill by name - const stream = agent.sendStream( + const stream = session.sendStream( 'Activate the pirate-skill and then tell me a joke.', ); @@ -72,7 +73,8 @@ describe('GeminiCliAgent Skills Integration', () => { // 1. Ask to activate the skill const events = []; - const stream = agent.sendStream( + const session = agent.session(); + const stream = session.sendStream( 'Activate the pirate-skill and confirm it is active.', ); diff --git a/packages/sdk/src/tool.integration.test.ts b/packages/sdk/src/tool.integration.test.ts index 1ec9d73abd..cc0a7c2454 100644 --- a/packages/sdk/src/tool.integration.test.ts +++ b/packages/sdk/src/tool.integration.test.ts @@ -45,7 +45,8 @@ describe('GeminiCliAgent Tool Integration', () => { }); const events = []; - const stream = agent.sendStream('What is 5 + 3?'); + const session = agent.session(); + const stream = session.sendStream('What is 5 + 3?'); for await (const event of stream) { events.push(event); @@ -85,9 +86,10 @@ describe('GeminiCliAgent Tool Integration', () => { }); const events = []; + const session = agent.session(); // Force the model to trigger the error first, then hopefully recover or at least acknowledge it. // The prompt is crafted to make the model try 'fail' first. - const stream = agent.sendStream( + const stream = session.sendStream( 'Call the tool with "fail". If it fails, tell me the error message.', ); @@ -128,7 +130,8 @@ describe('GeminiCliAgent Tool Integration', () => { }); const events = []; - const stream = agent.sendStream( + const session = agent.session(); + const stream = session.sendStream( 'Check the system status and report any errors.', ); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index d7e013d66c..9b6bf7093a 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -5,7 +5,26 @@ */ import type { Content } from '@google/gemini-cli-core'; +import type { Tool } from './tool.js'; +import type { SkillReference } from './skills.js'; import type { GeminiCliAgent } from './agent.js'; +import type { GeminiCliSession } from './session.js'; + +export type SystemInstructions = + | string + | ((context: SessionContext) => string | Promise); + +export interface GeminiCliAgentOptions { + instructions: SystemInstructions; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools?: Array>; + skills?: SkillReference[]; + model?: string; + cwd?: string; + debug?: boolean; + recordResponses?: string; + fakeResponses?: string; +} export interface AgentFilesystem { readFile(path: string): Promise; @@ -38,4 +57,5 @@ export interface SessionContext { fs: AgentFilesystem; shell: AgentShell; agent: GeminiCliAgent; + session: GeminiCliSession; } diff --git a/packages/sdk/test-data/agent-dynamic-instructions.json b/packages/sdk/test-data/agent-dynamic-instructions.json index 833467ad84..cbafd27206 100644 --- a/packages/sdk/test-data/agent-dynamic-instructions.json +++ b/packages/sdk/test-data/agent-dynamic-instructions.json @@ -1,4 +1,24 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9831,"totalTokenCount":9831,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9831}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7098,"candidatesTokenCount":8,"totalTokenCount":7106,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7098}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9848,"totalTokenCount":9848,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9848}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7113,"candidatesTokenCount":8,"totalTokenCount":7121,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7113}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9848,"totalTokenCount":9848,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9848}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7113,"candidatesTokenCount":8,"totalTokenCount":7121,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7113}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9853,"totalTokenCount":9853,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9853}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7120,"candidatesTokenCount":8,"totalTokenCount":7128,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7120}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9870,"totalTokenCount":9870,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9870}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7135,"candidatesTokenCount":8,"totalTokenCount":7143,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7135}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9870,"totalTokenCount":9870,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9870}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7135,"candidatesTokenCount":8,"totalTokenCount":7143,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7135}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10028,"totalTokenCount":10028,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10028}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7193,"candidatesTokenCount":7,"totalTokenCount":7200,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7193}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10044,"totalTokenCount":10044,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10044}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7207,"candidatesTokenCount":7,"totalTokenCount":7214,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7207}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":8,"totalTokenCount":7226,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10123,"totalTokenCount":10123,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10123}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7188,"candidatesTokenCount":8,"totalTokenCount":7196,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7188}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10140,"totalTokenCount":10140,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10140}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7203,"candidatesTokenCount":8,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7203}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10123,"totalTokenCount":10123,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10123}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7188,"candidatesTokenCount":8,"totalTokenCount":7196,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7188}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10140,"totalTokenCount":10140,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10140}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7203,"candidatesTokenCount":8,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7203}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10112,"totalTokenCount":10112,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10112}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7177,"candidatesTokenCount":7,"totalTokenCount":7184,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7177}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10128,"totalTokenCount":10128,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10128}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7191,"candidatesTokenCount":7,"totalTokenCount":7198,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7191}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} diff --git a/packages/sdk/test-data/agent-resume-session.json b/packages/sdk/test-data/agent-resume-session.json new file mode 100644 index 0000000000..f9d521898e --- /dev/null +++ b/packages/sdk/test-data/agent-resume-session.json @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"BAN"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10103,"totalTokenCount":10103,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10103}]}},{"candidates":[{"content":{"parts":[{"text":"ANA\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7168,"candidatesTokenCount":3,"totalTokenCount":7171,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7168}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"BANANA\n"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10124,"totalTokenCount":10124,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10124}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7187,"candidatesTokenCount":3,"totalTokenCount":7190,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7187}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} diff --git a/packages/sdk/test-data/agent-static-instructions.json b/packages/sdk/test-data/agent-static-instructions.json index 733c1915e7..216960da38 100644 --- a/packages/sdk/test-data/agent-static-instructions.json +++ b/packages/sdk/test-data/agent-static-instructions.json @@ -1 +1,4 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9828,"totalTokenCount":9828,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9828}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to chart a course through the code?"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7095,"candidatesTokenCount":15,"totalTokenCount":7110,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7095}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":15}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10109,"totalTokenCount":10109,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10109}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to plunder the codebase, are ye?\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7174,"candidatesTokenCount":16,"totalTokenCount":7190,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7174}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":16}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10109,"totalTokenCount":10109,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10109}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to shiver some timbers and get to work?\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7174,"candidatesTokenCount":18,"totalTokenCount":7192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7174}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":18}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10098,"totalTokenCount":10098,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10098}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to chart a course through these here files, are we?\n"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10098,"totalTokenCount":10098,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10098}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7163,"candidatesTokenCount":20,"totalTokenCount":7183,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7163}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":20}]}}]} diff --git a/packages/sdk/test-data/skill-dir-success.json b/packages/sdk/test-data/skill-dir-success.json index f14658426f..63a7267093 100644 --- a/packages/sdk/test-data/skill-dir-success.json +++ b/packages/sdk/test-data/skill-dir-success.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7160,"candidatesTokenCount":8,"totalTokenCount":7168,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7160}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Arrr, why"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10048,"totalTokenCount":10048,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10048}]}},{"candidates":[{"content":{"parts":[{"text":" don't pirates play poker? Because they always have a hidden ace up their sleeves"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10048,"totalTokenCount":10048,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10048}]}},{"candidates":[{"content":{"parts":[{"text":"!\\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7284,"candidatesTokenCount":23,"totalTokenCount":7307,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7284}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":23}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7185,"candidatesTokenCount":8,"totalTokenCount":7193,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7185}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10179,"totalTokenCount":10179,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10179}]}},{"candidates":[{"content":{"parts":[{"text":" sorry, I cannot activate the skill at this time because it requires user confirmation."}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10179,"totalTokenCount":10179,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10179}]}},{"candidates":[{"content":{"parts":[{"text":" Would you like me to proceed with telling you a joke without activating the skill?"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10179,"totalTokenCount":10179,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10179}]}},{"candidates":[{"content":{"parts":[{"text":"\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":35,"totalTokenCount":7253,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":35}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7185,"candidatesTokenCount":8,"totalTokenCount":7193,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7185}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Arrr, why"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10275,"totalTokenCount":10275,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10275}]}},{"candidates":[{"content":{"parts":[{"text":" don't pirates play cards? Because someone's always standin' on the"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10275,"totalTokenCount":10275,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10275}]}},{"candidates":[{"content":{"parts":[{"text":" deck!\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7309,"candidatesTokenCount":24,"totalTokenCount":7333,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7309}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":24}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7174,"candidatesTokenCount":8,"totalTokenCount":7182,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7174}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10264,"totalTokenCount":10264,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10264}]}},{"candidates":[{"content":{"parts":[{"text":"rr, why don't pirates play cards? Because someone always be standin' on the"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10264,"totalTokenCount":10264,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10264}]}},{"candidates":[{"content":{"parts":[{"text":" deck!\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7298,"candidatesTokenCount":23,"totalTokenCount":7321,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7298}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":23}]}}]} diff --git a/packages/sdk/test-data/skill-root-success.json b/packages/sdk/test-data/skill-root-success.json index 1048a2c627..4fe1e3fe6e 100644 --- a/packages/sdk/test-data/skill-root-success.json +++ b/packages/sdk/test-data/skill-root-success.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7170,"candidatesTokenCount":8,"totalTokenCount":7178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7170}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10058,"totalTokenCount":10058,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10058}]}},{"candidates":[{"content":{"parts":[{"text":"rr, the pirate skill be active, matey!"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7294,"candidatesTokenCount":12,"totalTokenCount":7306,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7294}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":12}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":8,"totalTokenCount":7192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10178,"totalTokenCount":10178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10178}]}},{"candidates":[{"content":{"parts":[{"text":" am unable to activate the skill without user confirmation. I will await further instructions.\n"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10178,"totalTokenCount":10178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10178}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7217,"candidatesTokenCount":18,"totalTokenCount":7235,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7217}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":18}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":8,"totalTokenCount":7192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10274,"totalTokenCount":10274,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10274}]}},{"candidates":[{"content":{"parts":[{"text":"rr, the pirate-skill be active, aye!\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7308,"candidatesTokenCount":13,"totalTokenCount":7321,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7308}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":13}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7173,"candidatesTokenCount":8,"totalTokenCount":7181,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7173}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10263,"totalTokenCount":10263,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10263}]}},{"candidates":[{"content":{"parts":[{"text":"rr, pirate skill activated, aye!"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7297,"candidatesTokenCount":9,"totalTokenCount":7306,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7297}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":9}]}}]} diff --git a/packages/sdk/test-data/tool-catchall-error.json b/packages/sdk/test-data/tool-catchall-error.json index 43c3b44d8b..8572c8be42 100644 --- a/packages/sdk/test-data/tool-catchall-error.json +++ b/packages/sdk/test-data/tool-catchall-error.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7070,"candidatesTokenCount":3,"totalTokenCount":7073,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7070}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The system status check"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9850,"totalTokenCount":9850,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9850}]}},{"candidates":[{"content":{"parts":[{"text":" returned an error. It says `Error: Standard error caught`."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7082,"candidatesTokenCount":17,"totalTokenCount":7099,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7082}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":17}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":3,"totalTokenCount":7187,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am unable to"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10183,"totalTokenCount":10183,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10183}]}},{"candidates":[{"content":{"parts":[{"text":" check the system status because it requires user confirmation in interactive mode. Is there anything else I"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10183,"totalTokenCount":10183,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10183}]}},{"candidates":[{"content":{"parts":[{"text":" can help with?"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7213,"candidatesTokenCount":26,"totalTokenCount":7239,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7213}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":26}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":3,"totalTokenCount":7187,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The system status check"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10164,"totalTokenCount":10164,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10164}]}},{"candidates":[{"content":{"parts":[{"text":" reported an error: \"Standard error caught\".\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7194,"candidatesTokenCount":14,"totalTokenCount":7208,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7194}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":14}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":3,"totalTokenCount":7187,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"There"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10164,"totalTokenCount":10164,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10164}]}},{"candidates":[{"content":{"parts":[{"text":" is an error reported in the system status.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7194,"candidatesTokenCount":11,"totalTokenCount":7205,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7194}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":11}]}}]} diff --git a/packages/sdk/test-data/tool-error-recovery.json b/packages/sdk/test-data/tool-error-recovery.json index 4e36d24aa7..9b17bf52d3 100644 --- a/packages/sdk/test-data/tool-error-recovery.json +++ b/packages/sdk/test-data/tool-error-recovery.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7073,"candidatesTokenCount":4,"totalTokenCount":7077,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7073}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9867,"totalTokenCount":9867,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9867}]}},{"candidates":[{"content":{"parts":[{"text":" tool failed visibly with the error message: \"Error: Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7085,"candidatesTokenCount":16,"totalTokenCount":7101,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7085}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":16}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7198,"candidatesTokenCount":4,"totalTokenCount":7202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7198}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10210,"totalTokenCount":10210,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10210}]}},{"candidates":[{"content":{"parts":[{"text":" tool execution for \"failVisible\" requires user confirmation, which is not supported in non-"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10210,"totalTokenCount":10210,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10210}]}},{"candidates":[{"content":{"parts":[{"text":"interactive mode."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7226,"candidatesTokenCount":22,"totalTokenCount":7248,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7226}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":22}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7198,"candidatesTokenCount":4,"totalTokenCount":7202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7198}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10192,"totalTokenCount":10192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10192}]}},{"candidates":[{"content":{"parts":[{"text":" tool failed visibly. The error message is \"Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7208,"candidatesTokenCount":14,"totalTokenCount":7222,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7208}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":14}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7187,"candidatesTokenCount":4,"totalTokenCount":7191,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7187}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The tool failed with"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10181,"totalTokenCount":10181,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10181}]}},{"candidates":[{"content":{"parts":[{"text":" the error message \"Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7197,"candidatesTokenCount":12,"totalTokenCount":7209,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7197}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":12}]}}]} diff --git a/packages/sdk/test-data/tool-success.json b/packages/sdk/test-data/tool-success.json index 1b17993fe4..d90d09cd0d 100644 --- a/packages/sdk/test-data/tool-success.json +++ b/packages/sdk/test-data/tool-success.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7045,"candidatesTokenCount":5,"totalTokenCount":7050,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7045}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9849,"totalTokenCount":9849,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9849}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7053,"candidatesTokenCount":1,"totalTokenCount":7054,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7053}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":1}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"b":3,"a":5}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7179,"candidatesTokenCount":5,"totalTokenCount":7184,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7179}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10204,"totalTokenCount":10204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10204}]}},{"candidates":[{"content":{"parts":[{"text":" am unable to execute the add tool in non-interactive mode.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7206,"candidatesTokenCount":15,"totalTokenCount":7221,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7206}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":15}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7179,"candidatesTokenCount":5,"totalTokenCount":7184,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7179}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7187,"candidatesTokenCount":1,"totalTokenCount":7188,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7187}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":1}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7168,"candidatesTokenCount":5,"totalTokenCount":7173,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7168}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10174,"totalTokenCount":10174,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10174}]}},{"candidates":[{"content":{"parts":[{"text":"\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7176,"candidatesTokenCount":2,"totalTokenCount":7178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7176}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":2}]}}]} diff --git a/packages/test-utils/src/test-rig.ts b/packages/test-utils/src/test-rig.ts index 6e32ec7790..1cd55b84f7 100644 --- a/packages/test-utils/src/test-rig.ts +++ b/packages/test-utils/src/test-rig.ts @@ -208,6 +208,7 @@ export interface ParsedLog { stdout?: string; stderr?: string; error?: string; + error_type?: string; prompt_id?: string; }; scopeMetrics?: { @@ -1255,6 +1256,8 @@ export class TestRig { success: boolean; duration_ms: number; prompt_id?: string; + error?: string; + error_type?: string; }; }[] = []; @@ -1272,6 +1275,8 @@ export class TestRig { success: logData.attributes.success ?? false, duration_ms: logData.attributes.duration_ms ?? 0, prompt_id: logData.attributes.prompt_id, + error: logData.attributes.error, + error_type: logData.attributes.error_type, }, }); } diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index bcfe6e40dd..456ec6e872 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -38,6 +38,7 @@ async function checkForUpdates( isManagedExtensionSurface: boolean, ) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const currentVersion = context.extension.packageJSON.version; // Fetch extension details from the VSCode Marketplace. @@ -75,9 +76,12 @@ async function checkForUpdates( return; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const data = await response.json(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const extension = data?.results?.[0]?.extensions?.[0]; // The versions are sorted by date, so the first one is the latest. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const latestVersion = extension?.versions?.[0]?.version; if ( diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index b160c5df67..2c9f9cf41e 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -105,6 +105,22 @@ }, "additionalProperties": false }, + "plan": { + "title": "Plan", + "description": "Planning features configuration.", + "markdownDescription": "Planning features configuration.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "directory": { + "title": "Plan Directory", + "description": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.", + "markdownDescription": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.\n\n- Category: `General`\n- Requires restart: `yes`", + "type": "string" + } + }, + "additionalProperties": false + }, "enablePromptCompletion": { "title": "Enable Prompt Completion", "description": "Enable AI-powered prompt completion suggestions while typing.", diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts index 600a989936..eea7ef9af3 100644 --- a/scripts/generate-keybindings-doc.ts +++ b/scripts/generate-keybindings-doc.ts @@ -22,7 +22,7 @@ import { const START_MARKER = ''; const END_MARKER = ''; -const OUTPUT_RELATIVE_PATH = ['docs', 'cli', 'keyboard-shortcuts.md']; +const OUTPUT_RELATIVE_PATH = ['docs', 'reference', 'keyboard-shortcuts.md']; const KEY_NAME_OVERRIDES: Record = { return: 'Enter', diff --git a/scripts/generate-settings-doc.ts b/scripts/generate-settings-doc.ts index 32d9d47c0b..1d27eb962a 100644 --- a/scripts/generate-settings-doc.ts +++ b/scripts/generate-settings-doc.ts @@ -47,7 +47,7 @@ export async function main(argv = process.argv.slice(2)) { path.dirname(fileURLToPath(import.meta.url)), '..', ); - const docPath = path.join(repoRoot, 'docs/get-started/configuration.md'); + const docPath = path.join(repoRoot, 'docs/reference/configuration.md'); const cliSettingsDocPath = path.join(repoRoot, 'docs/cli/settings.md'); const { getSettingsSchema } = await loadSettingsSchemaModule();