diff --git a/.github/workflows/eval-guidance.yml b/.github/workflows/eval-guidance.yml deleted file mode 100644 index e1f1ab3168..0000000000 --- a/.github/workflows/eval-guidance.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: 'Evals: PR Guidance' - -on: - pull_request: - paths: - - 'packages/core/src/**/*.ts' - - '!**/*.test.ts' - - '!**/*.test.tsx' - -permissions: - pull-requests: 'write' - contents: 'read' - -jobs: - provide-guidance: - name: 'Model Steering Guidance' - runs-on: 'ubuntu-latest' - if: "github.repository == 'google-gemini/gemini-cli'" - steps: - - name: 'Checkout' - uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4 - with: - fetch-depth: 0 - - - name: 'Set up Node.js' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0 - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: 'Detect Steering Changes' - id: 'detect' - run: | - STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only) - echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT" - - - name: 'Analyze PR Content' - if: "steps.detect.outputs.STEERING_DETECTED == 'true'" - id: 'analysis' - env: - GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - run: | - # Check for behavioral eval changes - EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true) - if [ -z "$EVAL_CHANGES" ]; then - echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT" - fi - - # Check if user is a maintainer (has write/admin access) - USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission') - if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then - echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT" - fi - - - name: 'Post Guidance Comment' - if: "steps.detect.outputs.STEERING_DETECTED == 'true'" - uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3 - with: - comment-tag: 'eval-guidance-bot' - message: | - ### ๐Ÿง  Model Steering Guidance - - This PR modifies files that affect the model's behavior (prompts, tools, or instructions). - - ${{ steps.analysis.outputs.MISSING_EVALS == 'true' && '- โš ๏ธ **Consider adding Evals:** No behavioral evaluations (`evals/*.eval.ts`) were added or updated in this PR. Consider adding a test case to verify the new behavior and prevent regressions.' || '' }} - ${{ steps.analysis.outputs.IS_MAINTAINER == 'true' && '- ๐Ÿš€ **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging.' || '' }} - - --- - *This is an automated guidance message triggered by steering logic signatures.* diff --git a/.github/workflows/eval-pr.yml b/.github/workflows/eval-pr.yml new file mode 100644 index 0000000000..e0f839e667 --- /dev/null +++ b/.github/workflows/eval-pr.yml @@ -0,0 +1,137 @@ +name: 'Evals: PR Evaluation & Regression' + +on: + pull_request: + types: ['opened', 'synchronize', 'reopened', 'ready_for_review'] + paths: + - 'packages/core/src/prompts/**' + - 'packages/core/src/tools/**' + - 'packages/core/src/agents/**' + - 'evals/**' + - '!**/*.test.ts' + - '!**/*.test.tsx' + workflow_dispatch: + +# Prevents multiple runs for the same PR simultaneously (saves tokens) +concurrency: + group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' + cancel-in-progress: true + +permissions: + pull-requests: 'write' + contents: 'read' + actions: 'read' + +jobs: + pr-evaluation: + name: 'Evaluate Steering & Regressions' + runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository))" + # External contributors' PRs will wait for approval in this environment + environment: |- + ${{ (github.event.pull_request.head.repo.full_name == github.repository) && 'internal' || 'external-evals' }} + env: + # CENTRALIZED MODEL LIST + MODEL_LIST: 'gemini-3-flash-preview' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 + with: + fetch-depth: 0 + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Detect Steering Changes' + id: 'detect' + run: | + SHOULD_RUN=$(node scripts/changed_prompt.js) + STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only) + echo "SHOULD_RUN=$SHOULD_RUN" >> "$GITHUB_OUTPUT" + echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT" + + - name: 'Analyze PR Content (Guidance)' + if: "steps.detect.outputs.STEERING_DETECTED == 'true'" + id: 'analysis' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: | + # Check for behavioral eval changes + EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true) + if [ -z "$EVAL_CHANGES" ]; then + echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT" + fi + + # Check if user is a maintainer + USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission') + if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then + echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT" + fi + + - name: 'Execute Regression Check' + if: "steps.detect.outputs.SHOULD_RUN == 'true'" + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + MODEL_LIST: '${{ env.MODEL_LIST }}' + run: | + # Run the regression check loop. The script saves the report to a file. + node scripts/run_eval_regression.js + + # Use the generated report file if it exists + if [[ -f eval_regression_report.md ]]; then + echo "REPORT_FILE=eval_regression_report.md" >> "$GITHUB_ENV" + fi + + - name: 'Post or Update PR Comment' + if: "always() && steps.detect.outputs.STEERING_DETECTED == 'true'" + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: | + # 1. Build the full comment body + { + if [[ -f eval_regression_report.md ]]; then + cat eval_regression_report.md + echo "" + fi + echo "### ๐Ÿง  Model Steering Guidance" + echo "" + echo "This PR modifies files that affect the model's behavior (prompts, tools, or instructions)." + echo "" + + if [[ "${{ steps.analysis.outputs.MISSING_EVALS }}" == "true" ]]; then + echo "- โš ๏ธ **Consider adding Evals:** No behavioral evaluations (\`evals/*.eval.ts\`) were added or updated in this PR. Consider [adding a test case](https://github.com/google-gemini/gemini-cli/blob/main/evals/README.md#creating-an-evaluation) to verify the new behavior and prevent regressions." + fi + + if [[ "${{ steps.analysis.outputs.IS_MAINTAINER }}" == "true" ]]; then + echo "- ๐Ÿš€ **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging." + fi + + echo "" + echo "---" + echo "*This is an automated guidance message triggered by steering logic signatures.*" + echo "" + } > full_comment.md + + # 2. Find if a comment with our unique tag already exists + # We extract the numeric ID from the URL to ensure compatibility with the REST API + COMMENT_ID=$(gh pr view ${{ github.event.pull_request.number }} --json comments --jq '.comments[] | select(.body | contains("")) | .url' | grep -oE "[0-9]+$" | head -n 1) + + # 3. Update or Create the comment + if [ -n "$COMMENT_ID" ]; then + echo "Updating existing comment $COMMENT_ID via API..." + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -F body=@full_comment.md + else + echo "Creating new PR comment..." + gh pr comment ${{ github.event.pull_request.number }} --body-file full_comment.md + fi diff --git a/README.md b/README.md index 03a7be1296..10458b2126 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/). ## ๐Ÿ“ฆ Installation See -[Gemini CLI installation, execution, and releases](./docs/get-started/installation.md) +[Gemini CLI installation, execution, and releases](https://www.geminicli.com/docs/get-started/installation) for recommended system specifications and a detailed installation guide. ### Quick Install @@ -71,9 +71,9 @@ conda activate gemini_env npm install -g @google/gemini-cli ``` -## Release Cadence and Tags +## Release Channels -See [Releases](./docs/releases.md) for more details. +See [Releases](https://www.geminicli.com/docs/changelogs) for more details. ### Preview @@ -209,7 +209,7 @@ gemini ``` For Google Workspace accounts and other authentication methods, see the -[authentication guide](./docs/get-started/authentication.md). +[authentication guide](https://www.geminicli.com/docs/get-started/authentication). ## ๐Ÿš€ Getting Started @@ -278,59 +278,64 @@ gemini ### Getting Started -- [**Quickstart Guide**](./docs/get-started/index.md) - Get up and running - quickly. -- [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed - auth configuration. -- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and - customization. -- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) - +- [**Quickstart Guide**](https://www.geminicli.com/docs/get-started) - Get up + and running quickly. +- [**Authentication Setup**](https://www.geminicli.com/docs/get-started/authentication) - + Detailed auth configuration. +- [**Configuration Guide**](https://www.geminicli.com/docs/reference/configuration) - + Settings and customization. +- [**Keyboard Shortcuts**](https://www.geminicli.com/docs/reference/keyboard-shortcuts) - Productivity tips. ### Core Features -- [**Commands Reference**](./docs/reference/commands.md) - All slash commands - (`/help`, `/chat`, etc). -- [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own - reusable commands. -- [**Context Files (GEMINI.md)**](./docs/cli/gemini-md.md) - Provide persistent - context to Gemini CLI. -- [**Checkpointing**](./docs/cli/checkpointing.md) - Save and resume - conversations. -- [**Token Caching**](./docs/cli/token-caching.md) - Optimize token usage. +- [**Commands Reference**](https://www.geminicli.com/docs/reference/commands) - + All slash commands (`/help`, `/chat`, etc). +- [**Custom Commands**](https://www.geminicli.com/docs/cli/custom-commands) - + Create your own reusable commands. +- [**Context Files (GEMINI.md)**](https://www.geminicli.com/docs/cli/gemini-md) - + Provide persistent context to Gemini CLI. +- [**Checkpointing**](https://www.geminicli.com/docs/cli/checkpointing) - Save + and resume conversations. +- [**Token Caching**](https://www.geminicli.com/docs/cli/token-caching) - + Optimize token usage. ### Tools & Extensions -- [**Built-in Tools Overview**](./docs/reference/tools.md) - - [File System Operations](./docs/tools/file-system.md) - - [Shell Commands](./docs/tools/shell.md) - - [Web Fetch & Search](./docs/tools/web-fetch.md) -- [**MCP Server Integration**](./docs/tools/mcp-server.md) - Extend with custom - tools. -- [**Custom Extensions**](./docs/extensions/index.md) - Build and share your own - commands. +- [**Built-in Tools Overview**](https://www.geminicli.com/docs/reference/tools) + - [File System Operations](https://www.geminicli.com/docs/tools/file-system) + - [Shell Commands](https://www.geminicli.com/docs/tools/shell) + - [Web Fetch & Search](https://www.geminicli.com/docs/tools/web-fetch) +- [**MCP Server Integration**](https://www.geminicli.com/docs/tools/mcp-server) - + Extend with custom tools. +- [**Custom Extensions**](https://geminicli.com/docs/extensions/writing-extensions) - + Build and share your own commands. ### Advanced Topics -- [**Headless Mode (Scripting)**](./docs/cli/headless.md) - Use Gemini CLI in - automated workflows. -- [**IDE Integration**](./docs/ide-integration/index.md) - VS Code companion. -- [**Sandboxing & Security**](./docs/cli/sandbox.md) - Safe execution - environments. -- [**Trusted Folders**](./docs/cli/trusted-folders.md) - Control execution - policies by folder. -- [**Enterprise Guide**](./docs/cli/enterprise.md) - Deploy and manage in a - corporate environment. -- [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking. -- [**Tools reference**](./docs/reference/tools.md) - Built-in tools overview. -- [**Local development**](./docs/local-development.md) - Local development - tooling. +- [**Headless Mode (Scripting)**](https://www.geminicli.com/docs/cli/headless) - + Use Gemini CLI in automated workflows. +- [**IDE Integration**](https://www.geminicli.com/docs/ide-integration) - VS + Code companion. +- [**Sandboxing & Security**](https://www.geminicli.com/docs/cli/sandbox) - Safe + execution environments. +- [**Trusted Folders**](https://www.geminicli.com/docs/cli/trusted-folders) - + Control execution policies by folder. +- [**Enterprise Guide**](https://www.geminicli.com/docs/cli/enterprise) - Deploy + and manage in a corporate environment. +- [**Telemetry & Monitoring**](https://www.geminicli.com/docs/cli/telemetry) - + Usage tracking. +- [**Tools reference**](https://www.geminicli.com/docs/reference/tools) - + Built-in tools overview. +- [**Local development**](https://www.geminicli.com/docs/local-development) - + Local development tooling. ### Troubleshooting & Support -- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common - issues and solutions. -- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions. +- [**Troubleshooting Guide**](https://www.geminicli.com/docs/resources/troubleshooting) - + Common issues and solutions. +- [**FAQ**](https://www.geminicli.com/docs/resources/faq) - Frequently asked + questions. - Use `/bug` command to report issues directly from the CLI. ### Using MCP Servers @@ -344,8 +349,9 @@ custom tools: > @database Run a query to find inactive users ``` -See the [MCP Server Integration guide](./docs/tools/mcp-server.md) for setup -instructions. +See the +[MCP Server Integration guide](https://www.geminicli.com/docs/tools/mcp-server) +for setup instructions. ## ๐Ÿค Contributing @@ -366,7 +372,8 @@ for planned features and priorities. ## ๐Ÿ“– Resources - **[Official Roadmap](./ROADMAP.md)** - See what's coming next. -- **[Changelog](./docs/changelogs/index.md)** - See recent notable updates. +- **[Changelog](https://www.geminicli.com/docs/changelogs)** - See recent + notable updates. - **[NPM Package](https://www.npmjs.com/package/@google/gemini-cli)** - Package registry. - **[GitHub Issues](https://github.com/google-gemini/gemini-cli/issues)** - @@ -376,13 +383,14 @@ for planned features and priorities. ### Uninstall -See the [Uninstall Guide](./docs/resources/uninstall.md) for removal -instructions. +See the [Uninstall Guide](https://www.geminicli.com/docs/resources/uninstall) +for removal instructions. ## ๐Ÿ“„ Legal - **License**: [Apache License 2.0](LICENSE) -- **Terms of Service**: [Terms & Privacy](./docs/resources/tos-privacy.md) +- **Terms of Service**: + [Terms & Privacy](https://www.geminicli.com/docs/resources/tos-privacy) - **Security**: [Security Policy](SECURITY.md) --- diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 5568191d73..e2ec2c41c0 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.36.0-preview.6 +# Preview release: v0.36.0-preview.8 -Released: March 28, 2026 +Released: April 01, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -390,4 +390,4 @@ npm install -g @google/gemini-cli@preview [#23666](https://github.com/google-gemini/gemini-cli/pull/23666) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.6 +https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.8 diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 56895e42b6..d60d5e6f6f 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -123,6 +123,7 @@ These are the only allowed tools: [`glob`](../tools/file-system.md#4-glob-findfiles) - **Search:** [`grep_search`](../tools/file-system.md#5-grep_search-searchtext), [`google_web_search`](../tools/web-search.md), + [`web_fetch`](../tools/web-fetch.md) (requires explicit confirmation), [`get_internal_docs`](../tools/internal-docs.md) - **Research Subagents:** [`codebase_investigator`](../core/subagents.md#codebase-investigator), diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 0f01558d2e..fba2369bf7 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -47,39 +47,39 @@ they appear in the UI. ### UI -| UI Label | Setting | Description | Default | -| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` | -| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` | -| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | -| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` | -| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` | -| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: โ—‡, Action Required: โœ‹, Working: โœฆ) | `true` | -| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | -| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` | -| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` | -| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` | -| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` | -| Compact Tool Output | `ui.compactToolOutput` | Display tool outputs (like directory listings and file reads) in a compact, structured format. | `false` | -| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | -| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` | -| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` | -| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | -| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | -| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` | -| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` | -| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` | -| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | -| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | -| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | -| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` | -| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | -| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | -| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | -| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | -| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` | -| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` | -| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | +| UI Label | Setting | Description | Default | +| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` | +| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` | +| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` | +| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` | +| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` | +| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: โ—‡, Action Required: โœ‹, Working: โœฆ) | `true` | +| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | +| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` | +| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` | +| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` | +| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` | +| Compact Tool Output | `ui.compactToolOutput` | Display tool outputs (like directory listings and file reads) in a compact, structured format. | `false` | +| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | +| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` | +| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` | +| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | +| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | +| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` | +| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` | +| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` | +| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | +| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | +| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | +| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` | +| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | +| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | +| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | +| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | +| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, all, or off. | `"off"` | +| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` | +| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | ### IDE @@ -153,7 +153,7 @@ they appear in the UI. | UI Label | Setting | Description | Default | | --------------------------------- | ------------------------------ | --------------------------------------------- | ------- | -| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits | `false` | +| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits | `true` | ### Experimental diff --git a/docs/core/subagents.md b/docs/core/subagents.md index 70c6f9d7e5..bfd107071e 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -120,10 +120,12 @@ Gemini CLI comes with the following built-in subagents: The browser agent requires: -- **Chrome** version 144 or later (any recent stable release will work). -- **Node.js** with `npx` available (used to launch the - [`chrome-devtools-mcp`](https://www.npmjs.com/package/chrome-devtools-mcp) - server). +- **Chrome** version 144 or later (any recent stable release works). + +The underlying +[`chrome-devtools-mcp`](https://www.npmjs.com/package/chrome-devtools-mcp) +server is bundled with Gemini CLI and launched automatically โ€” no separate +installation is needed. #### Enabling the browser agent @@ -169,26 +171,58 @@ The available modes are: | `isolated` | Launches Chrome with a temporary profile that is deleted after each session. Use this for clean-state automation. | | `existing` | Attaches to an already-running Chrome instance. You must enable remote debugging first by navigating to `chrome://inspect/#remote-debugging` in Chrome. No new browser process is launched. | +#### First-run consent + +The first time the browser agent is invoked, Gemini CLI displays a consent +dialog. You must accept before the browser session starts. This dialog only +appears once. + #### Configuration reference All browser-specific settings go under `agents.browser` in your `settings.json`. +For full details, see the +[`agents.browser` configuration reference](../reference/configuration.md#agents). -| Setting | Type | Default | Description | -| :------------ | :-------- | :------------- | :---------------------------------------------------------------------------------------------- | -| `sessionMode` | `string` | `"persistent"` | How Chrome is managed: `"persistent"`, `"isolated"`, or `"existing"`. | -| `headless` | `boolean` | `false` | Run Chrome in headless mode (no visible window). | -| `profilePath` | `string` | โ€” | Custom path to a browser profile directory. | -| `visualModel` | `string` | โ€” | Model override for the visual agent (for example, `"gemini-2.5-computer-use-preview-10-2025"`). | +| Setting | Type | Default | Description | +| :------------------------ | :--------- | :------------- | :------------------------------------------------------------------------------ | +| `sessionMode` | `string` | `"persistent"` | How Chrome is managed: `"persistent"`, `"isolated"`, or `"existing"`. | +| `headless` | `boolean` | `false` | Run Chrome in headless mode (no visible window). | +| `profilePath` | `string` | โ€” | Custom path to a browser profile directory. | +| `visualModel` | `string` | โ€” | Model override for the visual agent. | +| `allowedDomains` | `string[]` | โ€” | Restrict navigation to specific domains (for example, `["github.com"]`). | +| `disableUserInput` | `boolean` | `true` | Disable user input on the browser window during automation (non-headless only). | +| `maxActionsPerTask` | `number` | `100` | Maximum tool calls per task. The agent is terminated when the limit is reached. | +| `confirmSensitiveActions` | `boolean` | `false` | Require manual confirmation for `upload_file` and `evaluate_script`. | +| `blockFileUploads` | `boolean` | `false` | Hard-block all file upload requests from the agent. | + +#### Automation overlay and input blocking + +In non-headless mode, the browser agent injects a visual overlay into the +browser window to indicate that automation is in progress. By default, user +input (keyboard and mouse) is also blocked to prevent accidental interference. +You can disable this by setting `disableUserInput` to `false`. #### Security -The browser agent enforces the following security restrictions: +The browser agent enforces several layers of security: -- **Blocked URL patterns:** `file://`, `javascript:`, `data:text/html`, - `chrome://extensions`, and `chrome://settings/passwords` are always blocked. -- **Sensitive action confirmation:** Actions like form filling, file uploads, - and form submissions require user confirmation through the standard policy - engine. +- **Domain restrictions:** When `allowedDomains` is set, the agent can only + navigate to the listed domains (and their subdomains when using `*.` prefix). + Attempting to visit a disallowed domain throws a fatal error that immediately + terminates the agent. The agent also attempts to detect and block the use of + allowed domains as proxies (e.g., via query parameters or fragments) to access + restricted content. +- **Blocked URL patterns:** The underlying MCP server blocks dangerous URL + schemes including `file://`, `javascript:`, `data:text/html`, + `chrome://extensions`, and `chrome://settings/passwords`. +- **Sensitive action confirmation:** Form filling (`fill`, `fill_form`) always + requires user confirmation through the policy engine, regardless of approval + mode. When `confirmSensitiveActions` is `true`, `upload_file` and + `evaluate_script` also require confirmation. +- **File upload blocking:** Set `blockFileUploads` to `true` to hard-block all + file upload requests, preventing the agent from uploading any files. +- **Action rate limiting:** The `maxActionsPerTask` setting (default: 100) + limits the total number of tool calls per task to prevent runaway execution. #### Visual agent @@ -332,6 +366,7 @@ it yourself; just report it. | `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. | | `kind` | string | No | `local` (default) or `remote`. | | `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** | +| `mcpServers` | object | No | Configuration for inline Model Context Protocol (MCP) servers isolated to this specific agent. | | `model` | string | No | Specific model to use (e.g., `gemini-3-preview`). Defaults to `inherit` (uses the main session model). | | `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. | | `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. | @@ -359,6 +394,78 @@ Each subagent runs in its own isolated context loop. This means: subagents **cannot** call other subagents. If a subagent is granted the `*` tool wildcard, it will still be unable to see or invoke other agents. +## Subagent tool isolation + +Subagent tool isolation moves Gemini CLI away from a single global tool +registry. By providing isolated execution environments, you can ensure that +subagents only interact with the parts of the system they are designed for. This +prevents unintended side effects, improves reliability by avoiding state +contamination, and enables fine-grained permission control. + +With this feature, you can: + +- **Specify tool access:** Define exactly which tools an agent can access using + a `tools` list in the agent definition. +- **Define inline MCP servers:** Configure Model Context Protocol (MCP) servers + (which provide a standardized way to connect AI models to external tools and + data sources) directly in the subagent's markdown frontmatter, isolating them + to that specific agent. +- **Maintain state isolation:** Ensure that subagents only interact with their + own set of tools and servers, preventing side effects and state contamination. +- **Apply subagent-specific policies:** Enforce granular rules in your + [Policy Engine](../reference/policy-engine.md) TOML configuration based on the + executing subagent's name. + +### Configuring isolated tools and servers + +You can configure tool isolation for a subagent by updating its markdown +frontmatter. This allows you to explicitly state which tools the subagent can +use, rather than relying on the global registry. + +Add an `mcpServers` object to define inline MCP servers that are unique to the +agent. + +**Example:** + +```yaml +--- +name: my-isolated-agent +tools: + - grep_search + - read_file +mcpServers: + my-custom-server: + command: 'node' + args: ['path/to/server.js'] +--- +``` + +### Subagent-specific policies + +You can enforce fine-grained control over subagents using the +[Policy Engine's](../reference/policy-engine.md) TOML configuration. This allows +you to grant or restrict permissions specifically for an agent, without +affecting the rest of your CLI session. + +To restrict a policy rule to a specific subagent, add the `subagent` property to +the `[[rules]]` block in your `policy.toml` file. + +**Example:** + +```toml +[[rules]] +name = "Allow pr-creator to push code" +subagent = "pr-creator" +description = "Permit pr-creator to push branches automatically." +action = "allow" +toolName = "run_shell_command" +commandPrefix = "git push" +``` + +In this configuration, the policy rule only triggers if the executing subagent's +name matches `pr-creator`. Rules without the `subagent` property apply +universally to all agents. + ## Managing subagents You can manage subagents interactively using the `/agents` command or diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 87433ef4f1..15ea47c82e 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -62,11 +62,13 @@ locations for these files: **Note on environment variables in settings:** String values within your `settings.json` and `gemini-extension.json` files can reference environment -variables using either `$VAR_NAME` or `${VAR_NAME}` syntax. These variables will -be automatically resolved when the settings are loaded. For example, if you have -an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like -this: `"apiKey": "$MY_API_TOKEN"`. Additionally, each extension can have its own -`.env` file in its directory, which will be loaded automatically. +variables using `$VAR_NAME`, `${VAR_NAME}`, or `${VAR_NAME:-DEFAULT_VALUE}` +syntax. These variables will be automatically resolved when the settings are +loaded. For example, if you have an environment variable `MY_API_TOKEN`, you +could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`. If you +want to provide a fallback value, use `${MY_API_TOKEN:-default-token}`. +Additionally, each extension can have its own `.env` file in its directory, +which will be loaded automatically. **Note for Enterprise Users:** For guidance on deploying and managing Gemini CLI in a corporate environment, please see the @@ -354,8 +356,8 @@ their corresponding top-level category object in your `settings.json` file. - **`ui.loadingPhrases`** (enum): - **Description:** What to show while the model is working: tips, witty - comments, both, or nothing. - - **Default:** `"tips"` + comments, all, or off. + - **Default:** `"off"` - **Values:** `"tips"`, `"witty"`, `"all"`, `"off"` - **`ui.errorVerbosity`** (enum): @@ -1242,7 +1244,8 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes - **`agents.browser.visualModel`** (string): - - **Description:** Model override for the visual agent. + - **Description:** Model for the visual agent's analyze_screenshot tool. When + set, enables the tool. - **Default:** `undefined` - **Requires restart:** Yes @@ -1565,7 +1568,7 @@ their corresponding top-level category object in your `settings.json` file. - **`advanced.autoConfigureMemory`** (boolean): - **Description:** Automatically configure Node.js memory limits - - **Default:** `false` + - **Default:** `true` - **Requires restart:** Yes - **`advanced.dnsResolutionOrder`** (string): @@ -1587,6 +1590,11 @@ their corresponding top-level category object in your `settings.json` file. #### `experimental` +- **`experimental.adk.agentSessionNoninteractiveEnabled`** (boolean): + - **Description:** Enable non-interactive agent sessions. + - **Default:** `false` + - **Requires restart:** Yes + - **`experimental.enableAgents`** (boolean): - **Description:** Enable local and remote subagents. - **Default:** `true` diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index bb00f30f77..597e74f111 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -29,13 +29,12 @@ To create your first policy: ```toml [[rule]] toolName = "run_shell_command" - commandPrefix = "git status" - decision = "allow" + commandPrefix = "rm -rf" + decision = "deny" priority = 100 ``` 3. **Run a command** that triggers the policy (e.g., ask Gemini CLI to - `git status`). The tool will now execute automatically without prompting for - confirmation. + `rm -rf /`). The tool will now be blocked automatically. ## Core concepts @@ -143,25 +142,26 @@ engine transforms this into a final priority using the following formula: This system guarantees that: -- Admin policies always override User, Workspace, and Default policies. +- Admin policies always override User, Workspace, and Default policies (defined + in policy TOML files). - 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: 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`. +- A `priority: 50` rule in a Default policy TOML becomes `1.050`. +- A `priority: 10` rule in a Workspace policy TOML becomes `2.010`. +- A `priority: 100` rule in a User policy TOML becomes `3.100`. +- A `priority: 20` rule in an Admin policy TOML becomes `4.020`. ### Approval modes Approval modes allow the policy engine to apply different sets of rules based on -the CLI's operational mode. A rule can be associated with one or more modes -(e.g., `yolo`, `autoEdit`, `plan`). The rule will only be active if the CLI is -running in one of its specified modes. If a rule has no modes specified, it is -always active. +the CLI's operational mode. A rule in a TOML policy file can be associated with +one or more modes (e.g., `yolo`, `autoEdit`, `plan`). The rule will only be +active if the CLI is running in one of its specified modes. If a rule has no +modes specified, it is always active. - `default`: The standard interactive mode where most write tools require confirmation. @@ -179,8 +179,8 @@ outcome. A rule matches a tool call if all of its conditions are met: -1. **Tool name**: The `toolName` in the rule must match the name of the tool - being called. +1. **Tool name**: The `toolName` in the TOML rule must match the name of the + tool being called. - **Wildcards**: You can use wildcards like `*`, `mcp_server_*`, or `mcp_*_toolName` to match multiple tools. See [Tool Name](#tool-name) for details. @@ -264,7 +264,7 @@ toolName = "run_shell_command" # (Optional) The name of a subagent. If provided, the rule only applies to tool # calls made by this specific subagent. -subagent = "generalist" +subagent = "codebase_investigator" # (Optional) The name of an MCP server. Can be combined with toolName # to form a composite FQN internally like "mcp_mcpName_toolName". diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 09f0518c07..91c626fa69 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -115,10 +115,10 @@ each tool. ### Web -| Tool | Kind | Description | -| :-------------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information. | -| [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts. | +| Tool | Kind | Description | +| :-------------------------------------------- | :------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information. | +| [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts. In Plan Mode, this tool requires explicit user confirmation. | ## Under the hood diff --git a/docs/tools/web-fetch.md b/docs/tools/web-fetch.md index bde0232abc..66d8f4a570 100644 --- a/docs/tools/web-fetch.md +++ b/docs/tools/web-fetch.md @@ -17,6 +17,9 @@ specific operations like summarization or extraction. ## Technical behavior - **Confirmation:** Triggers a confirmation dialog showing the converted URLs. +- **Plan Mode:** In [Plan Mode](../cli/plan-mode.md), `web_fetch` is available + but always requires explicit user confirmation (`ask_user`) due to security + implications of accessing external or private network addresses. - **Processing:** Uses the Gemini API's `urlContext` for retrieval. - **Fallback:** If API access fails, the tool attempts to fetch raw content directly from your local machine. diff --git a/esbuild.config.js b/esbuild.config.js index f0d55e3ca6..63d5d9f00a 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -13,7 +13,7 @@ import { wasmLoader } from 'esbuild-plugin-wasm'; let esbuild; try { esbuild = (await import('esbuild')).default; -} catch (_error) { +} catch { console.error('esbuild not available - cannot build bundle'); process.exit(1); } diff --git a/eslint.config.js b/eslint.config.js index e827f9b236..aa3b5ae195 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,6 +41,11 @@ const commonRestrictedSyntaxRules = [ message: 'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.', }, + { + selector: 'CatchClause > Identifier[name=/^_/]', + message: + 'Do not use underscored identifiers in catch blocks. If the error is unused, use "catch {}". If it is used, remove the underscore.', + }, ]; export default tseslint.config( @@ -129,7 +134,7 @@ export default tseslint.config( { argsIgnorePattern: '^_', varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', + caughtErrors: 'all', }, ], // Prevent async errors from bypassing catch handlers @@ -336,7 +341,7 @@ export default tseslint.config( { argsIgnorePattern: '^_', varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', + caughtErrors: 'all', }, ], }, @@ -360,7 +365,7 @@ export default tseslint.config( { argsIgnorePattern: '^_', varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', + caughtErrors: 'all', }, ], }, @@ -422,7 +427,7 @@ export default tseslint.config( { argsIgnorePattern: '^_', varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', + caughtErrors: 'all', }, ], }, diff --git a/evals/README.md b/evals/README.md index 9e3697a6b8..aebfe38ebc 100644 --- a/evals/README.md +++ b/evals/README.md @@ -212,6 +212,56 @@ The nightly workflow executes the full evaluation suite multiple times (currently 3 attempts) to account for non-determinism. These results are aggregated into a **Nightly Summary** attached to the workflow run. +## Regression Check Scripts + +The project includes several scripts to automate high-signal regression checking +in Pull Requests. These can also be run locally for debugging. + +- **`scripts/get_trustworthy_evals.js`**: Analyzes nightly history to identify + stable tests (80%+ aggregate pass rate). +- **`scripts/run_regression_check.js`**: Runs a specific set of tests using the + "Best-of-4" logic and "Dynamic Baseline Verification". +- **`scripts/run_eval_regression.js`**: The main orchestrator that loops through + models and generates the final PR report. + +### Running Regression Checks Locally + +You can simulate the PR regression check locally to verify your changes before +pushing: + +```bash +# Run the full regression loop for a specific model +MODEL_LIST=gemini-3-flash-preview node scripts/run_eval_regression.js +``` + +To debug a specific failing test with the same logic used in CI: + +```bash +# 1. Get the Vitest pattern for trustworthy tests +OUTPUT=$(node scripts/get_trustworthy_evals.js "gemini-3-flash-preview") + +# 2. Run the regression logic for those tests +node scripts/run_regression_check.js "gemini-3-flash-preview" "$OUTPUT" +``` + +### The Regression Quality Bar + +Because LLMs are non-deterministic, the PR regression check uses a high-signal +probabilistic approach rather than a 100% pass requirement: + +1. **Trustworthiness (60/80 Filter):** Only tests with a proven track record + are run. A test must score at least **60% (2/3)** every single night and + maintain an **80% aggregate** pass rate over the last 6 days. +2. **The 50% Pass Rule:** In a PR, a test is considered a **Pass** if the model + correctly performs the behavior at least half the time (**2 successes** out + of up to 4 attempts). +3. **Dynamic Baseline Verification:** If a test fails in a PR (e.g., 0/3), the + system automatically checks the `main` branch. If it fails there too, it is + marked as **Pre-existing** and cleared for the PR, ensuring you are only + blocked by regressions caused by your specific changes. + +## Fixing Evaluations + #### How to interpret the report: - **Pass Rate (%)**: Each cell represents the percentage of successful runs for diff --git a/evals/background_processes.eval.ts b/evals/background_processes.eval.ts new file mode 100644 index 0000000000..039a416ae9 --- /dev/null +++ b/evals/background_processes.eval.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +describe('Background Process Monitoring', () => { + evalTest('USUALLY_PASSES', { + name: 'should naturally use read output tool to find token', + prompt: + "Run the script using 'bash generate_token.sh'. It will emit a token after a short delay and continue running. Find the token and tell me what it is.", + files: { + 'generate_token.sh': `#!/bin/bash +sleep 2 +echo "TOKEN=xyz123" +sleep 100 +`, + }, + setup: async (rig) => { + // Create .gemini directory to avoid file system error in test rig + if (rig.homeDir) { + const geminiDir = path.join(rig.homeDir, '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + } + }, + assert: async (rig, result) => { + const toolCalls = rig.readToolLogs(); + + // Check if read_background_output was called + const hasReadCall = toolCalls.some( + (call) => call.toolRequest.name === 'read_background_output', + ); + + expect( + hasReadCall, + 'Expected agent to call read_background_output to find the token', + ).toBe(true); + + // Verify that the agent found the correct token + expect( + result.includes('xyz123'), + `Expected agent to find the token xyz123. Agent output: ${result}`, + ).toBe(true); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should naturally use list tool to verify multiple processes', + prompt: + "Start three background processes that run 'sleep 100', 'sleep 200', and 'sleep 300' respectively. Verify that all three are currently running.", + setup: async (rig) => { + // Create .gemini directory to avoid file system error in test rig + if (rig.homeDir) { + const geminiDir = path.join(rig.homeDir, '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + } + }, + assert: async (rig, result) => { + const toolCalls = rig.readToolLogs(); + + // Check if list_background_processes was called + const hasListCall = toolCalls.some( + (call) => call.toolRequest.name === 'list_background_processes', + ); + + expect( + hasListCall, + 'Expected agent to call list_background_processes', + ).toBe(true); + }, + }); +}); diff --git a/evals/tracker.eval.ts b/evals/tracker.eval.ts index 7afb41dbec..49bc903b0a 100644 --- a/evals/tracker.eval.ts +++ b/evals/tracker.eval.ts @@ -113,4 +113,21 @@ describe('tracker_mode', () => { assertModelHasOutput(result); }, }); + + evalTest('USUALLY_PASSES', { + name: 'should correctly identify the task tracker storage location from the system prompt', + params: { + settings: { experimental: { taskTracker: true } }, + }, + prompt: + 'Where is my task tracker storage located? Please provide the absolute path in your response.', + assert: async (rig, result) => { + // The rig sets GEMINI_CLI_HOME to rig.homeDir + const homeDir = rig.homeDir!; + // The response should contain the dynamic path which includes the home directory + // and follows the .gemini/tmp/.../tracker structure. + expect(result).toContain(homeDir); + expect(result).toMatch(/\.gemini\/tmp\/.*\/tracker/); + }, + }); }); diff --git a/integration-tests/browser-agent-localhost.dynamic.responses b/integration-tests/browser-agent-localhost.dynamic.responses new file mode 100644 index 0000000000..bade94af88 --- /dev/null +++ b/integration-tests/browser-agent-localhost.dynamic.responses @@ -0,0 +1,6 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll check the dynamic content page on the localhost server."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to http://127.0.0.1:18923/dynamic.html, wait for the dynamic content to load, then capture the accessibility tree and report what content appeared"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"http://127.0.0.1:18923/dynamic.html"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"wait_for","args":{"selector":"#dynamic-content","state":"visible","timeout":5000}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":25,"totalTokenCount":175}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":180,"candidatesTokenCount":15,"totalTokenCount":195}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Dynamic content loaded successfully. Found heading 'Content Loaded', message 'This content was loaded dynamically via JavaScript.', and a list with items: Item Alpha, Item Beta, Item Gamma."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":40,"totalTokenCount":240}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The dynamic content page loaded successfully:\n\n- **Heading**: Content Loaded\n- **Message**: This content was loaded dynamically via JavaScript\n- **Items**: Item Alpha, Item Beta, Item Gamma\n\nThe JavaScript-rendered content appeared after the initial page load, replacing the 'Loading...' indicator."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":60,"totalTokenCount":260}}]} diff --git a/integration-tests/browser-agent-localhost.form.responses b/integration-tests/browser-agent-localhost.form.responses new file mode 100644 index 0000000000..119d1ff46f --- /dev/null +++ b/integration-tests/browser-agent-localhost.form.responses @@ -0,0 +1,9 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll fill out the contact form on the localhost server."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to http://127.0.0.1:18923/form.html, fill in the name field with 'Test User', the email field with 'test@example.com', the message field with 'Hello World', and submit the form"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"http://127.0.0.1:18923/form.html"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"fill","args":{"selector":"#name","value":"Test User"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":25,"totalTokenCount":175}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"fill","args":{"selector":"#email","value":"test@example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":180,"candidatesTokenCount":25,"totalTokenCount":205}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"fill","args":{"selector":"#message","value":"Hello World"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":210,"candidatesTokenCount":25,"totalTokenCount":235}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"click","args":{"selector":"#submit-btn"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":240,"candidatesTokenCount":20,"totalTokenCount":260}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":270,"candidatesTokenCount":15,"totalTokenCount":285}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Successfully filled and submitted the contact form. Navigated to form-result.html which shows 'Form Submitted Successfully'."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":40,"totalTokenCount":340}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully filled out and submitted the contact form:\n\n- **Name**: Test User\n- **Email**: test@example.com\n- **Message**: Hello World\n\nAfter submission, the page navigated to the result page showing 'Form Submitted Successfully'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":60,"totalTokenCount":360}}]} diff --git a/integration-tests/browser-agent-localhost.multistep.responses b/integration-tests/browser-agent-localhost.multistep.responses new file mode 100644 index 0000000000..37fc8d438c --- /dev/null +++ b/integration-tests/browser-agent-localhost.multistep.responses @@ -0,0 +1,9 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll go through the multi-step flow on the localhost server."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to http://127.0.0.1:18923/multi-step/step1.html, fill in 'testuser' as the username, click Next, then on step 2 select 'Option B' and click Finish. Report the final result page content."}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"http://127.0.0.1:18923/multi-step/step1.html"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"fill","args":{"selector":"#username","value":"testuser"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":25,"totalTokenCount":175}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"click","args":{"selector":"#next-btn"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":180,"candidatesTokenCount":20,"totalTokenCount":200}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":210,"candidatesTokenCount":15,"totalTokenCount":225}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"click","args":{"selector":"#finish-btn"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":240,"candidatesTokenCount":20,"totalTokenCount":260}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":270,"candidatesTokenCount":15,"totalTokenCount":285}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Completed all steps. Step 1: entered username 'testuser'. Step 2: selected default option. Final result page shows 'Multi-Step Complete' with 'โœ“ Complete' status badge."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":40,"totalTokenCount":340}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've completed the multi-step flow:\n\n1. **Step 1**: Entered 'testuser' as username and clicked Next\n2. **Step 2**: Confirmed selection and clicked Finish\n3. **Result**: Final page shows 'Multi-Step Complete' with a 'โœ“ Complete' status badge\n\nAll steps were successfully navigated."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":60,"totalTokenCount":360}}]} diff --git a/integration-tests/browser-agent-localhost.navigate.responses b/integration-tests/browser-agent-localhost.navigate.responses new file mode 100644 index 0000000000..676696bf6b --- /dev/null +++ b/integration-tests/browser-agent-localhost.navigate.responses @@ -0,0 +1,5 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to the localhost page and read its content using the browser agent."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to http://127.0.0.1:18923/index.html and tell me the page title and list all links on the page"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"http://127.0.0.1:18923/index.html"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":20,"totalTokenCount":170}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Page title is 'Test Fixture - Home'. Found 3 links: Contact Form (/form.html), Multi-Step Flow (/multi-step/step1.html), Dynamic Content (/dynamic.html)."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":40,"totalTokenCount":240}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The localhost test fixture page has:\n\n**Title**: Test Fixture - Home\n\n**Links**:\n1. Contact Form (form.html)\n2. Multi-Step Flow (multi-step/step1.html)\n3. Dynamic Content (dynamic.html)\n\nThe page also has a heading 'Test Fixture Home Page' and footer content."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":60,"totalTokenCount":260}}]} diff --git a/integration-tests/browser-agent-localhost.screenshot.responses b/integration-tests/browser-agent-localhost.screenshot.responses new file mode 100644 index 0000000000..762b560697 --- /dev/null +++ b/integration-tests/browser-agent-localhost.screenshot.responses @@ -0,0 +1,5 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll take a screenshot of the localhost test page."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to http://127.0.0.1:18923/index.html and take a screenshot of the page"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"http://127.0.0.1:18923/index.html"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_screenshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":15,"totalTokenCount":165}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Screenshot captured of the localhost test fixture home page showing the heading, navigation links, and footer.","data":{"screenshotTaken":true}}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":40,"totalTokenCount":240}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've captured a screenshot of the localhost test fixture page. The screenshot shows the 'Test Fixture Home Page' heading with navigation links to the Contact Form, Multi-Step Flow, and Dynamic Content pages, along with the footer section."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]} diff --git a/integration-tests/browser-agent-localhost.test.ts b/integration-tests/browser-agent-localhost.test.ts new file mode 100644 index 0000000000..2de37ba7a9 --- /dev/null +++ b/integration-tests/browser-agent-localhost.test.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig, assertModelHasOutput } from './test-helper.js'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +describe('browser-agent-localhost', () => { + let rig: TestRig; + + const browserSettings = { + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: true, + sessionMode: 'isolated' as const, + }, + }, + }; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should navigate to localhost fixture and read page content', async () => { + rig.setup('localhost-navigate', { + fakeResponsesPath: join( + __dirname, + 'browser-agent-localhost.navigate.responses', + ), + settings: browserSettings, + }); + + const result = await rig.run({ + args: 'Navigate to http://127.0.0.1:18923/index.html and tell me the page title and list all links.', + }); + + assertModelHasOutput(result); + + const toolLogs = rig.readToolLogs(); + const browserAgentCall = toolLogs.find( + (t) => t.toolRequest.name === 'browser_agent', + ); + expect( + browserAgentCall, + 'Expected browser_agent to be called', + ).toBeDefined(); + }); + + it('should fill out and submit a form on localhost', async () => { + rig.setup('localhost-form', { + fakeResponsesPath: join( + __dirname, + 'browser-agent-localhost.form.responses', + ), + settings: browserSettings, + }); + + const result = await rig.run({ + args: "Navigate to http://127.0.0.1:18923/form.html, fill in name='Test User', email='test@example.com', message='Hello World', and submit the form.", + }); + + assertModelHasOutput(result); + + const toolLogs = rig.readToolLogs(); + const browserAgentCall = toolLogs.find( + (t) => t.toolRequest.name === 'browser_agent', + ); + expect( + browserAgentCall, + 'Expected browser_agent to be called', + ).toBeDefined(); + }); + + it('should navigate through a multi-step flow', async () => { + rig.setup('localhost-multistep', { + fakeResponsesPath: join( + __dirname, + 'browser-agent-localhost.multistep.responses', + ), + settings: browserSettings, + }); + + const result = await rig.run({ + args: "Go to http://127.0.0.1:18923/multi-step/step1.html, fill in 'testuser' as username, click Next, then click Finish on step 2. Report the result.", + }); + + assertModelHasOutput(result); + + const toolLogs = rig.readToolLogs(); + const browserAgentCall = toolLogs.find( + (t) => t.toolRequest.name === 'browser_agent', + ); + expect( + browserAgentCall, + 'Expected browser_agent to be called', + ).toBeDefined(); + }); + + it('should handle dynamically loaded content', async () => { + rig.setup('localhost-dynamic', { + fakeResponsesPath: join( + __dirname, + 'browser-agent-localhost.dynamic.responses', + ), + settings: browserSettings, + }); + + const result = await rig.run({ + args: 'Navigate to http://127.0.0.1:18923/dynamic.html, wait for content to load, and tell me what items appear.', + }); + + assertModelHasOutput(result); + + const toolLogs = rig.readToolLogs(); + const browserAgentCall = toolLogs.find( + (t) => t.toolRequest.name === 'browser_agent', + ); + expect( + browserAgentCall, + 'Expected browser_agent to be called', + ).toBeDefined(); + }); + + it('should take a screenshot of localhost page', async () => { + rig.setup('localhost-screenshot', { + fakeResponsesPath: join( + __dirname, + 'browser-agent-localhost.screenshot.responses', + ), + settings: browserSettings, + }); + + const result = await rig.run({ + args: 'Navigate to http://127.0.0.1:18923/index.html and take a screenshot.', + }); + + assertModelHasOutput(result); + + const toolLogs = rig.readToolLogs(); + const browserCalls = toolLogs.filter( + (t) => t.toolRequest.name === 'browser_agent', + ); + expect(browserCalls.length).toBeGreaterThan(0); + }); +}); diff --git a/integration-tests/browser-agent.cleanup.responses b/integration-tests/browser-agent.cleanup.responses index 9cf7a7b356..e99c757793 100644 --- a/integration-tests/browser-agent.cleanup.responses +++ b/integration-tests/browser-agent.cleanup.responses @@ -1,4 +1,5 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have opened the page and the title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The task is complete. The page title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":20,"totalTokenCount":320}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Done."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":400,"candidatesTokenCount":5,"totalTokenCount":405}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":20,"totalTokenCount":170}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"The page title is 'Example Domain'."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have opened the page and the title is 'Example Domain'. The browser session has been cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]} diff --git a/integration-tests/browser-agent.interaction.responses b/integration-tests/browser-agent.interaction.responses index 98474d6b59..0b4a1d84f7 100644 --- a/integration-tests/browser-agent.interaction.responses +++ b/integration-tests/browser-agent.interaction.responses @@ -1,2 +1,5 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and analyze the links on the page."},{"functionCall":{"name":"browser_agent","args":{"task":"Go to https://example.com and find all links on the page, then describe them"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":20,"totalTokenCount":170}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Found one link on https://example.com: 'More information...' linking to the IANA website for details about reserved domains."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":40,"totalTokenCount":240}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"After analyzing https://example.com, I found the following links:\n\n1. **\"More information...\"** - This is the main link on the page that points to the IANA (Internet Assigned Numbers Authority) website for more details about reserved domains.\n\nThe page is quite minimal with just this single informational link, which is typical for example domains used in documentation."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":70,"totalTokenCount":270}}]} diff --git a/integration-tests/browser-agent.navigate-snapshot.responses b/integration-tests/browser-agent.navigate-snapshot.responses index 481520234d..e9c9490a21 100644 --- a/integration-tests/browser-agent.navigate-snapshot.responses +++ b/integration-tests/browser-agent.navigate-snapshot.responses @@ -1,2 +1,5 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you open https://example.com and analyze the page. Let me use the browser agent to navigate and capture the page information."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and capture the accessibility tree to get the page title and main content"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":30,"totalTokenCount":130}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":20,"totalTokenCount":170}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Successfully navigated to https://example.com. The page title is 'Example Domain' and the main content states: 'This domain is for use in illustrative examples in documents.'"}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":40,"totalTokenCount":240}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Based on the browser analysis of https://example.com:\n\n**Page Title**: Example Domain\n\n**Main Content**: The page contains a simple heading \"Example Domain\" and explanatory text stating \"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.\"\n\nThe page has a clean, minimal layout typical of placeholder domains used for documentation and examples."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]} diff --git a/integration-tests/browser-agent.screenshot.responses b/integration-tests/browser-agent.screenshot.responses index c9abaa4268..84467f517a 100644 --- a/integration-tests/browser-agent.screenshot.responses +++ b/integration-tests/browser-agent.screenshot.responses @@ -1,2 +1,5 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and take a screenshot for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and take a screenshot of the page"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_screenshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":20,"totalTokenCount":170}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Successfully navigated to https://example.com and captured a screenshot. The page shows the Example Domain header with explanatory text."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":40,"totalTokenCount":240}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully navigated to https://example.com and captured a screenshot. The page shows the familiar \"Example Domain\" header with explanatory text below it. The screenshot captures the clean, minimal layout of this demonstration website."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]} diff --git a/integration-tests/browser-agent.sequential.responses b/integration-tests/browser-agent.sequential.responses index 74732998aa..6dc1e96268 100644 --- a/integration-tests/browser-agent.sequential.responses +++ b/integration-tests/browser-agent.sequential.responses @@ -1,2 +1,6 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you navigate to https://example.com and perform both operations - capturing the accessibility tree and taking a screenshot."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":45,"totalTokenCount":145}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":20,"totalTokenCount":170}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_screenshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":20,"totalTokenCount":220}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Successfully navigated to https://example.com, captured accessibility tree snapshot and took a screenshot. Page title is 'Example Domain'."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":250,"candidatesTokenCount":40,"totalTokenCount":290}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully completed both operations on https://example.com:\n\n**Accessibility Tree Snapshot**: The page has a clear structure with the main heading \"Example Domain\" and descriptive text about the domain's purpose for documentation examples.\n\n**Screenshot**: Captured a visual representation of the page showing the clean, minimal layout with the heading and explanatory text.\n\nBoth the accessibility data and visual screenshot confirm this is the standard example domain page used for documentation purposes."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]} diff --git a/integration-tests/browser-agent.test.ts b/integration-tests/browser-agent.test.ts index f9f07d4c9e..6545040e98 100644 --- a/integration-tests/browser-agent.test.ts +++ b/integration-tests/browser-agent.test.ts @@ -77,7 +77,12 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => { ), settings: { agents: { - browser_agent: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { headless: true, sessionMode: 'isolated', }, @@ -106,7 +111,12 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => { fakeResponsesPath: join(__dirname, 'browser-agent.screenshot.responses'), settings: { agents: { - browser_agent: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { headless: true, sessionMode: 'isolated', }, @@ -132,7 +142,12 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => { fakeResponsesPath: join(__dirname, 'browser-agent.interaction.responses'), settings: { agents: { - browser_agent: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { headless: true, sessionMode: 'isolated', }, @@ -161,7 +176,12 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => { fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'), settings: { agents: { - browser_agent: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { headless: true, sessionMode: 'isolated', }, @@ -182,7 +202,12 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => { fakeResponsesPath: join(__dirname, 'browser-agent.sequential.responses'), settings: { agents: { - browser_agent: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { headless: true, sessionMode: 'isolated', }, @@ -212,7 +237,12 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => { ), settings: { agents: { - browser_agent: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { headless: true, sessionMode: 'isolated', }, diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index 5f963f7459..9dad51f9b3 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -9,16 +9,80 @@ if (process.env['NO_COLOR'] !== undefined) { delete process.env['NO_COLOR']; } -import { mkdir, readdir, rm } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; +import { mkdir, readdir, rm, readFile } from 'node:fs/promises'; +import { join, dirname, extname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js'; import { disableMouseTracking } from '@google/gemini-cli-core'; +import { createServer, type Server } from 'node:http'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); const integrationTestsDir = join(rootDir, '.integration-tests'); let runDir = ''; // Make runDir accessible in teardown +let fixtureServer: Server | undefined; + +const FIXTURE_PORT = 18923; +const FIXTURE_DIR = join(__dirname, 'test-fixtures'); + +const MIME_TYPES: Record = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', +}; + +async function startFixtureServer(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(async (req, res) => { + const urlPath = req.url?.split('?')[0] || '/'; + const relativePath = urlPath === '/' ? 'index.html' : urlPath; + const filePath = join(FIXTURE_DIR, relativePath); + + if (!filePath.startsWith(FIXTURE_DIR)) { + res.writeHead(403, { 'Content-Type': 'text/html' }); + res.end('

403 Forbidden

'); + return; + } + + try { + const content = await readFile(filePath); + const ext = extname(filePath); + res.writeHead(200, { + 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream', + }); + res.end(content); + } catch { + res.writeHead(404, { 'Content-Type': 'text/html' }); + res.end('

404 Not Found

'); + } + }); + + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + console.warn( + `Port ${FIXTURE_PORT} in use, trying ${FIXTURE_PORT + 1}...`, + ); + server.listen(FIXTURE_PORT + 1, '127.0.0.1'); + } else { + reject(err); + } + }); + + server.on('listening', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : FIXTURE_PORT; + fixtureServer = server; + console.log(`Test fixture server listening on http://127.0.0.1:${port}`); + resolve(port); + }); + + server.listen(FIXTURE_PORT, '127.0.0.1'); + }); +} export async function setup() { runDir = join(integrationTestsDir, `${Date.now()}`); @@ -40,6 +104,10 @@ export async function setup() { throw new Error('Failed to download ripgrep binary'); } + // Start the test fixture server + const port = await startFixtureServer(); + process.env['TEST_FIXTURE_PORT'] = String(port); + // Clean up old test runs, but keep the latest few for debugging try { const testRuns = await readdir(integrationTestsDir); @@ -73,6 +141,14 @@ export async function setup() { } export async function teardown() { + // Stop the fixture server + if (fixtureServer) { + await new Promise((resolve) => { + fixtureServer!.close(() => resolve()); + }); + fixtureServer = undefined; + } + // Disable mouse tracking if (process.stdout.isTTY) { disableMouseTracking(); diff --git a/integration-tests/shell-background.responses b/integration-tests/shell-background.responses new file mode 100644 index 0000000000..652b82a8e0 --- /dev/null +++ b/integration-tests/shell-background.responses @@ -0,0 +1,5 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I will run the command in the background for you."},{"functionCall":{"name":"run_shell_command","args":{"command":"sleep 10 && echo hello-from-background","is_background":true}}}],"role":"model"},"finishReason":"STOP","index":0}]}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The background process has been started. Now I will list the background processes to verify."},{"functionCall":{"name":"list_background_processes","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}]}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I see the background process 'sleep 10 && echo hello-from-background' is running. Would you like me to read its output?"}],"role":"model"},"finishReason":"STOP","index":0}]}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I will read the output for you."},{"functionCall":{"name":"read_background_output","args":{"pid":12345}}}],"role":"model"},"finishReason":"STOP","index":0}]}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The output of the background process is:\nhello-from-background"}],"role":"model"},"finishReason":"STOP","index":0}]}]} diff --git a/integration-tests/shell-background.test.ts b/integration-tests/shell-background.test.ts new file mode 100644 index 0000000000..f28120e7e4 --- /dev/null +++ b/integration-tests/shell-background.test.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +describe('shell-background-tools', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => await rig.cleanup()); + + it('should run a command in the background, list it, and read its output', async () => { + // We use a fake responses file to make the test deterministic and run in CI. + rig.setup('shell-background-workflow', { + fakeResponsesPath: join(__dirname, 'shell-background.responses'), + settings: { + tools: { + core: [ + 'run_shell_command', + 'list_background_processes', + 'read_background_output', + ], + }, + hooksConfig: { + enabled: true, + }, + hooks: { + BeforeTool: [ + { + matcher: 'run_shell_command', + hooks: [ + { + type: 'command', + // This hook intercepts run_shell_command. + // If is_background is true, it returns a mock result with PID 12345. + // It also creates the mock log file that read_background_output expects. + command: `node -e " + const fs = require('fs'); + const path = require('path'); + const input = JSON.parse(fs.readFileSync(0, 'utf-8')); + const args = JSON.parse(input.tool_call.args); + + if (args.is_background) { + const logDir = path.join(process.env.GEMINI_CLI_HOME, 'background-processes'); + if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true }); + fs.writeFileSync(path.join(logDir, 'background-12345.log'), 'hello-from-background\\n'); + + console.log(JSON.stringify({ + decision: 'replace', + hookSpecificOutput: { + result: { + llmContent: 'Command moved to background (PID: 12345). Output hidden. Press Ctrl+B to view.', + data: { pid: 12345, command: args.command } + } + } + })); + } else { + console.log(JSON.stringify({ decision: 'allow' })); + } + "`, + }, + ], + }, + ], + }, + }, + }); + + const run = await rig.runInteractive({ approvalMode: 'yolo' }); + + // 1. Start a background process + // We use a command that stays alive for a bit to ensure it shows up in lists + await run.type( + "Run 'sleep 10 && echo hello-from-background' in the background.", + ); + await run.type('\r'); + + // Wait for the model's canned response acknowledging the start + await run.expectText('background', 30000); + + // 2. List background processes + await run.type('List my background processes.'); + await run.type('\r'); + // Wait for the model's canned response showing the list + await run.expectText('hello-from-background', 30000); + + // 3. Read the output + await run.type('Read the output of that process.'); + await run.type('\r'); + // Wait for the model's canned response showing the output + await run.expectText('hello-from-background', 30000); + }, 60000); +}); diff --git a/integration-tests/test-fixtures/dynamic.html b/integration-tests/test-fixtures/dynamic.html new file mode 100644 index 0000000000..73a99b56e4 --- /dev/null +++ b/integration-tests/test-fixtures/dynamic.html @@ -0,0 +1,29 @@ + + + + + Test Fixture - Dynamic Content + + +

Dynamic Content Page

+
Loading...
+ + + + diff --git a/integration-tests/test-fixtures/form-result.html b/integration-tests/test-fixtures/form-result.html new file mode 100644 index 0000000000..182ed70128 --- /dev/null +++ b/integration-tests/test-fixtures/form-result.html @@ -0,0 +1,15 @@ + + + + + Test Fixture - Form Result + + +

Form Submitted Successfully

+

Thank you for your submission.

+
+

Your form data has been received.

+
+ Back to Home + + diff --git a/integration-tests/test-fixtures/form.html b/integration-tests/test-fixtures/form.html new file mode 100644 index 0000000000..848cbe47e8 --- /dev/null +++ b/integration-tests/test-fixtures/form.html @@ -0,0 +1,37 @@ + + + + + Test Fixture - Contact Form + + +

Contact Form

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + diff --git a/integration-tests/test-fixtures/index.html b/integration-tests/test-fixtures/index.html new file mode 100644 index 0000000000..0298ab929d --- /dev/null +++ b/integration-tests/test-fixtures/index.html @@ -0,0 +1,27 @@ + + + + + Test Fixture - Home + + +

Test Fixture Home Page

+

+ This is a test fixture page for browser agent integration tests. +

+ +
+

Footer content for testing.

+
+ + diff --git a/integration-tests/test-fixtures/multi-step/result.html b/integration-tests/test-fixtures/multi-step/result.html new file mode 100644 index 0000000000..f2386215d5 --- /dev/null +++ b/integration-tests/test-fixtures/multi-step/result.html @@ -0,0 +1,15 @@ + + + + + Test Fixture - Result + + +

Multi-Step Complete

+

You have completed all steps successfully.

+
+ โœ“ Complete +
+ Back to Home + + diff --git a/integration-tests/test-fixtures/multi-step/step1.html b/integration-tests/test-fixtures/multi-step/step1.html new file mode 100644 index 0000000000..d6d620d4a0 --- /dev/null +++ b/integration-tests/test-fixtures/multi-step/step1.html @@ -0,0 +1,16 @@ + + + + + Test Fixture - Step 1 + + +

Step 1: Enter Your Details

+

Please provide your name to continue.

+
+ + + +
+ + diff --git a/integration-tests/test-fixtures/multi-step/step2.html b/integration-tests/test-fixtures/multi-step/step2.html new file mode 100644 index 0000000000..f0571a7a8e --- /dev/null +++ b/integration-tests/test-fixtures/multi-step/step2.html @@ -0,0 +1,22 @@ + + + + + Test Fixture - Step 2 + + +

Step 2: Confirm Your Selection

+

Choose your preference below.

+
+
+ + +
+ +
+ + diff --git a/packages/a2a-server/src/commands/restore.ts b/packages/a2a-server/src/commands/restore.ts index c7567a3b24..7a5205c66b 100644 --- a/packages/a2a-server/src/commands/restore.ts +++ b/packages/a2a-server/src/commands/restore.ts @@ -98,7 +98,7 @@ export class RestoreCommand implements Command { name: this.name, data: restoreResult, }; - } catch (_error) { + } catch { return { name: this.name, data: { @@ -142,7 +142,7 @@ export class ListCheckpointsCommand implements Command { content: JSON.stringify(checkpointInfoList), }, }; - } catch (_error) { + } catch { return { name: this.name, data: { diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index 14295954dd..f077b0ef4b 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -27,6 +27,7 @@ import { type MessageBus, LlmRole, type GitService, + type ModelRouterService, processSingleFileContent, InvalidStreamError, } from '@google/gemini-cli-core'; @@ -102,17 +103,7 @@ vi.mock( ...actual, updatePolicy: vi.fn(), createPolicyUpdater: vi.fn(), - ReadManyFilesTool: vi.fn().mockImplementation(() => ({ - name: 'read_many_files', - kind: 'read', - build: vi.fn().mockReturnValue({ - getDescription: () => 'Read files', - toolLocations: () => [], - execute: vi.fn().mockResolvedValue({ - llmContent: ['--- file.txt ---\n\nFile content\n\n'], - }), - }), - })), + ReadManyFilesTool: vi.fn(), logToolCall: vi.fn(), LlmRole: { MAIN: 'main', @@ -421,6 +412,26 @@ describe('GeminiAgent', () => { ); }); + it('should include gemini-3.1-flash-lite when useGemini31FlashLite is true', async () => { + mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true); + mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true); + mockConfig.getGemini31FlashLiteLaunchedSync = vi.fn().mockReturnValue(true); + + const response = await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }); + + expect(response.models?.availableModels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + modelId: 'gemini-3.1-flash-lite-preview', + name: 'gemini-3.1-flash-lite-preview', + }), + ]), + ); + }); + it('should return modes with plan mode when plan is enabled', async () => { mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ apiKey: 'test-key', @@ -646,6 +657,7 @@ describe('Session', () => { sendMessageStream: vi.fn(), addHistory: vi.fn(), recordCompletedToolCalls: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), } as unknown as Mocked; mockTool = { kind: 'read', @@ -667,6 +679,9 @@ describe('Session', () => { mockConfig = { getModel: vi.fn().mockReturnValue('gemini-pro'), getActiveModel: vi.fn().mockReturnValue('gemini-pro'), + getModelRouterService: vi.fn().mockReturnValue({ + route: vi.fn().mockResolvedValue({ model: 'resolved-model' }), + }), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), getMcpServers: vi.fn(), getFileService: vi.fn().mockReturnValue({ @@ -713,10 +728,22 @@ describe('Session', () => { }, errors: [], } as unknown as LoadedSettings); + + (ReadManyFilesTool as unknown as Mock).mockImplementation(() => ({ + name: 'read_many_files', + kind: 'read', + build: vi.fn().mockReturnValue({ + getDescription: () => 'Read files', + toolLocations: () => [], + execute: vi.fn().mockResolvedValue({ + llmContent: ['--- file.txt ---\n\nFile content\n\n'], + }), + }), + })); }); afterEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); }); it('should send available commands', async () => { @@ -786,6 +813,42 @@ describe('Session', () => { expect(result).toMatchObject({ stopReason: 'end_turn' }); }); + it('should use model router to determine model', async () => { + const mockRouter = { + route: vi.fn().mockResolvedValue({ model: 'routed-model' }), + } as unknown as ModelRouterService; + mockConfig.getModelRouterService.mockReturnValue(mockRouter); + + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'Hello' }] } }], + }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Hi' }], + }); + + expect(mockRouter.route).toHaveBeenCalledWith( + expect.objectContaining({ + requestedModel: 'gemini-pro', + request: [{ text: 'Hi' }], + }), + ); + expect(mockChat.sendMessageStream).toHaveBeenCalledWith( + expect.objectContaining({ model: 'routed-model' }), + expect.any(Array), + expect.any(String), + expect.any(Object), + expect.any(String), + ); + }); + it('should handle prompt with empty response (InvalidStreamError)', async () => { mockChat.sendMessageStream.mockRejectedValue( new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'), diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index 6b76ffdc7a..14761d7162 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -28,7 +28,7 @@ import { debugLogger, ReadManyFilesTool, REFERENCE_CONTENT_START, - resolveModel, + type RoutingContext, createWorkingStdio, startupProfiler, Kind, @@ -42,6 +42,7 @@ import { DEFAULT_GEMINI_FLASH_LITE_MODEL, PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_MODEL_AUTO, @@ -758,10 +759,15 @@ export class Session { const functionCalls: FunctionCall[] = []; try { - const model = resolveModel( - this.context.config.getModel(), - (await this.context.config.getGemini31Launched?.()) ?? false, - ); + const routingContext: RoutingContext = { + history: chat.getHistory(/*curated=*/ true), + request: nextMessage?.parts ?? [], + signal: pendingSend.signal, + requestedModel: this.context.config.getModel(), + }; + + const router = this.context.config.getModelRouterService(); + const { model } = await router.route(routingContext); const responseStream = await chat.sendMessageStream( { model }, nextMessage?.parts ?? [], @@ -2009,10 +2015,31 @@ function buildAvailableModels( const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO; const shouldShowPreviewModels = config.getHasAccessToPreviewModel(); const useGemini31 = config.getGemini31LaunchedSync?.() ?? false; + const useGemini31FlashLite = + config.getGemini31FlashLiteLaunchedSync?.() ?? false; const selectedAuthType = settings.merged.security.auth.selectedType; const useCustomToolModel = useGemini31 && selectedAuthType === AuthType.USE_GEMINI; + // --- DYNAMIC PATH --- + if ( + config.getExperimentalDynamicModelConfiguration?.() === true && + config.getModelConfigService + ) { + const options = config.getModelConfigService().getAvailableModelOptions({ + useGemini3_1: useGemini31, + useGemini3_1FlashLite: useGemini31FlashLite, + useCustomTools: useCustomToolModel, + hasAccessToPreview: shouldShowPreviewModels, + }); + + return { + availableModels: options, + currentModelId: preferredModel, + }; + } + + // --- LEGACY PATH --- const mainOptions = [ { value: DEFAULT_GEMINI_MODEL_AUTO, @@ -2056,7 +2083,7 @@ function buildAvailableModels( ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL : previewProModel; - manualOptions.unshift( + const previewOptions = [ { value: previewProValue, title: getDisplayString(previewProModel), @@ -2065,7 +2092,16 @@ function buildAvailableModels( value: PREVIEW_GEMINI_FLASH_MODEL, title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL), }, - ); + ]; + + if (useGemini31FlashLite) { + previewOptions.push({ + value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, + title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL), + }); + } + + manualOptions.unshift(...previewOptions); } const scaleOptions = ( diff --git a/packages/cli/src/acp/commands/extensions.ts b/packages/cli/src/acp/commands/extensions.ts index a6e08f9bbc..7ebe922402 100644 --- a/packages/cli/src/acp/commands/extensions.ts +++ b/packages/cli/src/acp/commands/extensions.ts @@ -284,7 +284,7 @@ export class LinkExtensionCommand implements Command { try { await stat(sourceFilepath); - } catch (_error) { + } catch { return { name: this.name, data: `Invalid source: ${sourceFilepath}` }; } diff --git a/packages/cli/src/acp/commands/restore.ts b/packages/cli/src/acp/commands/restore.ts index 6898cff2e1..4ffc5dfba2 100644 --- a/packages/cli/src/acp/commands/restore.ts +++ b/packages/cli/src/acp/commands/restore.ts @@ -130,7 +130,7 @@ export class ListCheckpointsCommand implements Command { const checkpointDir = config.storage.getProjectTempCheckpointsDir(); try { await fs.mkdir(checkpointDir, { recursive: true }); - } catch (_e) { + } catch { // Ignore } @@ -169,7 +169,7 @@ export class ListCheckpointsCommand implements Command { name: this.name, data: `Available Checkpoints:\n${formatted}`, }; - } catch (_error) { + } catch { return { name: this.name, data: 'An unexpected error occurred while listing checkpoints.', diff --git a/packages/cli/src/commands/extensions/new.ts b/packages/cli/src/commands/extensions/new.ts index e5507194d0..2ff97834c3 100644 --- a/packages/cli/src/commands/extensions/new.ts +++ b/packages/cli/src/commands/extensions/new.ts @@ -25,7 +25,7 @@ async function pathExists(path: string) { try { await access(path); return true; - } catch (_e) { + } catch { return false; } } diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts index 715786859b..eae9614cf3 100644 --- a/packages/cli/src/commands/mcp.test.ts +++ b/packages/cli/src/commands/mcp.test.ts @@ -32,7 +32,7 @@ describe('mcp command', () => { try { await parser.parse('mcp'); - } catch (_error) { + } catch { // yargs might throw an error when demandCommand is not met } diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index 8154e3b7bf..2747c77f00 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -121,7 +121,7 @@ async function testMCPConnection( try { // Use the same transport creation logic as core transport = await createTransport(serverName, config, false, mcpContext); - } catch (_error) { + } catch { await client.close(); return MCPServerStatus.DISCONNECTED; } @@ -135,7 +135,7 @@ async function testMCPConnection( await client.close(); return MCPServerStatus.CONNECTED; - } catch (_error) { + } catch { await transport.close(); return MCPServerStatus.DISCONNECTED; } diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index ff2f1f9d25..7a5c438215 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1009,6 +1009,7 @@ export async function loadCliConfig( format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, gemmaModelRouter: settings.experimental?.gemmaModelRouter, + adk: settings.experimental?.adk, fakeResponses: argv.fakeResponses, recordResponses: argv.recordResponses, retryFetchErrors: settings.general?.retryFetchErrors, @@ -1063,7 +1064,7 @@ async function resolveWorktreeSettings( if (isGeminiWorktree(toplevel, projectRoot)) { worktreePath = toplevel; } - } catch (_e) { + } catch { return undefined; } diff --git a/packages/cli/src/config/extension-manager-permissions.test.ts b/packages/cli/src/config/extension-manager-permissions.test.ts index 662f30d430..6d6e848fef 100644 --- a/packages/cli/src/config/extension-manager-permissions.test.ts +++ b/packages/cli/src/config/extension-manager-permissions.test.ts @@ -33,7 +33,7 @@ describe('copyExtension permissions', () => { makeWritableSync(path.join(p, child)), ); } - } catch (_e) { + } catch { // Ignore errors during cleanup } }; diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts index 6c20737be9..33c335c16b 100644 --- a/packages/cli/src/config/extension-manager.test.ts +++ b/packages/cli/src/config/extension-manager.test.ts @@ -101,7 +101,7 @@ describe('ExtensionManager', () => { themeManager.clearExtensionThemes(); try { fs.rmSync(tempHomeDir, { recursive: true, force: true }); - } catch (_e) { + } catch { // Ignore } }); diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 564c4fbb6f..20a7073464 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -63,7 +63,7 @@ export function loadInstallMetadata( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const metadata = JSON.parse(configContent) as ExtensionInstallMetadata; return metadata; - } catch (_e) { + } catch { return undefined; } } diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index 156fe78309..06cf344a0d 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -151,7 +151,7 @@ export async function fetchReleaseFromGithub( return await fetchJson( `https://api.github.com/repos/${owner}/${repo}/releases/latest`, ); - } catch (_) { + } catch { // This can fail if there is no release marked latest. In that case // we want to just try the pre-release logic below. } diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 7eec1c61b8..40d275e79e 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -612,7 +612,7 @@ export function loadEnvironment( } } } - } catch (_e) { + } catch { // Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`. } } diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 7deb1f533f..27639fa031 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -87,7 +87,7 @@ describe('SettingsSchema', () => { const definition = getSettingsSchema().ui?.properties?.loadingPhrases; expect(definition).toBeDefined(); expect(definition?.type).toBe('enum'); - expect(definition?.default).toBe('tips'); + expect(definition?.default).toBe('off'); expect(definition?.options?.map((o) => o.value)).toEqual([ 'tips', 'witty', @@ -505,6 +505,31 @@ describe('SettingsSchema', () => { 'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.', ); }); + + it('should have adk setting in schema', () => { + const adk = getSettingsSchema().experimental.properties.adk; + expect(adk).toBeDefined(); + expect(adk.type).toBe('object'); + expect(adk.category).toBe('Experimental'); + expect(adk.default).toEqual({}); + expect(adk.requiresRestart).toBe(true); + expect(adk.showInDialog).toBe(false); + expect(adk.description).toBe( + 'Settings for the Agent Development Kit (ADK).', + ); + + const agentSessionNoninteractiveEnabled = + adk.properties.agentSessionNoninteractiveEnabled; + expect(agentSessionNoninteractiveEnabled).toBeDefined(); + expect(agentSessionNoninteractiveEnabled.type).toBe('boolean'); + expect(agentSessionNoninteractiveEnabled.category).toBe('Experimental'); + expect(agentSessionNoninteractiveEnabled.default).toBe(false); + expect(agentSessionNoninteractiveEnabled.requiresRestart).toBe(true); + expect(agentSessionNoninteractiveEnabled.showInDialog).toBe(false); + expect(agentSessionNoninteractiveEnabled.description).toBe( + 'Enable non-interactive agent sessions.', + ); + }); }); it('has JSON schema definitions for every referenced ref', () => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 371be2afd1..04f9ff5724 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -776,9 +776,9 @@ const SETTINGS_SCHEMA = { label: 'Loading Phrases', category: 'UI', requiresRestart: false, - default: 'tips', + default: 'off', description: - 'What to show while the model is working: tips, witty comments, both, or nothing.', + 'What to show while the model is working: tips, witty comments, all, or off.', showInDialog: true, options: [ { value: 'tips', label: 'Tips' }, @@ -1202,7 +1202,8 @@ const SETTINGS_SCHEMA = { category: 'Advanced', requiresRestart: true, default: undefined as string | undefined, - description: 'Model override for the visual agent.', + description: + "Model for the visual agent's analyze_screenshot tool. When set, enables the tool.", showInDialog: false, }, allowedDomains: { @@ -1887,7 +1888,7 @@ const SETTINGS_SCHEMA = { label: 'Auto Configure Max Old Space Size', category: 'Advanced', requiresRestart: true, - default: false, + default: true, description: 'Automatically configure Node.js memory limits', showInDialog: true, }, @@ -1933,6 +1934,26 @@ const SETTINGS_SCHEMA = { description: 'Setting to enable experimental features', showInDialog: false, properties: { + adk: { + type: 'object', + label: 'ADK', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Settings for the Agent Development Kit (ADK).', + showInDialog: false, + properties: { + agentSessionNoninteractiveEnabled: { + type: 'boolean', + label: 'Agent Session Non-interactive Enabled', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enable non-interactive agent sessions.', + showInDialog: false, + }, + }, + }, enableAgents: { type: 'boolean', label: 'Enable Agents', diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 4e45b0f188..6adf1e22ef 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -1712,7 +1712,7 @@ describe('runNonInteractive', () => { input, prompt_id: promptId, }); - } catch (_error) { + } catch { // Expected exit } diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 6eda7f3109..9a1156e5cb 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -61,6 +61,7 @@ export const createMockCommandContext = ( toggleCorgiMode: vi.fn(), toggleShortcutsHelp: vi.fn(), toggleVimEnabled: vi.fn(), + reloadCommands: vi.fn(), openAgentConfigDialog: vi.fn(), closeAgentConfigDialog: vi.fn(), extensionsUpdateState: new Map(), diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index daf109d928..57ddd83141 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -38,6 +38,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), })), + isMemoryManagerEnabled: vi.fn(() => false), getListExtensions: vi.fn(() => false), getExtensions: vi.fn(() => []), getListSessions: vi.fn(() => false), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4da8acfdb7..d5d0a1759a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -83,6 +83,7 @@ import { logBillingEvent, ApiKeyUpdatedEvent, type InjectionSource, + startMemoryService, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -447,6 +448,13 @@ export const AppContainer = (props: AppContainerProps) => { setConfigInitialized(true); startupProfiler.flush(config); + // Fire-and-forget memory service (skill extraction from past sessions) + if (config.isMemoryManagerEnabled()) { + startMemoryService(config).catch((e) => { + debugLogger.error('Failed to start memory service:', e); + }); + } + const sessionStartSource = resumedSessionData ? SessionStartSource.Resume : SessionStartSource.Startup; @@ -1422,8 +1430,7 @@ Logging in with Google... Restarting Gemini CLI to continue. (streamingState === StreamingState.Idle || streamingState === StreamingState.Responding || streamingState === StreamingState.WaitingForConfirmation) && - !proQuotaRequest && - !copyModeEnabled; + !proQuotaRequest; const observerRef = useRef(null); const [controlsHeight, setControlsHeight] = useState(0); diff --git a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg index 97b01f3025..b83d79928c 100644 --- a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg +++ b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg @@ -4,16 +4,14 @@ - - โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ - - - > - - Can you edit InputPrompt.tsx for me? - - - โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ + + + > + + Can you edit InputPrompt.tsx for me? + + + โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ Action Required @@ -55,7 +53,7 @@ true ; โ”‚ - โ–ˆ + โ–„ โ”‚ 48 const diff --git a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap index 98853434df..6841182785 100644 --- a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = ` -"โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ - > Can you edit InputPrompt.tsx for me? +" > Can you edit InputPrompt.tsx for me? โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ + โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ Action Required โ”‚ โ”‚ โ”‚ @@ -12,7 +12,7 @@ exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation bo โ”‚ ... first 44 lines hidden (Ctrl+O to show) ... โ”‚ โ”‚ 45 const line45 = true; โ”‚ โ”‚ 46 const line46 = true; โ”‚ -โ”‚ 47 const line47 = true; โ”‚โ–ˆ +โ”‚ 47 const line47 = true; โ”‚โ–„ โ”‚ 48 const line48 = true; โ”‚โ–ˆ โ”‚ 49 const line49 = true; โ”‚โ–ˆ โ”‚ 50 const line50 = true; โ”‚โ–ˆ diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index e7a33672f3..05fd081dfb 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -65,7 +65,7 @@ const getSavedChatTags = async ( ); return chatDetails; - } catch (_err) { + } catch { return []; } }; diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 4106efa97b..718012c494 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -198,7 +198,7 @@ export const directoryCommand: SlashCommand = { alreadyAdded.push(trimmedPath); continue; } - } catch (_e) { + } catch { // Path might not exist or be inaccessible. // We'll let batchAddDirectories handle it later. } diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 7a3ada83e0..6c0f3529a2 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -321,7 +321,7 @@ async function exploreAction( }); try { await open(extensionsUrl); - } catch (_error) { + } catch { context.ui.addItem({ type: MessageType.ERROR, text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index cf18836c20..3796456ff8 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -151,7 +151,7 @@ async function completion( const files = await fs.readdir(checkpointDir); const jsonFiles = files.filter((file) => file.endsWith('.json')); return getTruncatedCheckpointNames(jsonFiles); - } catch (_err) { + } catch { return []; } } diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index afc9b7210e..ff290c27fb 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -76,7 +76,7 @@ export async function updateGitignore(gitRepoRoot: string): Promise { let fileExists = true; try { existingContent = await fs.promises.readFile(gitignorePath, 'utf8'); - } catch (_error) { + } catch { // File doesn't exist fileExists = false; } @@ -168,8 +168,8 @@ async function downloadFiles({ async function createDirectory(dirPath: string): Promise { try { await fs.promises.mkdir(dirPath, { recursive: true }); - } catch (_error) { - debugLogger.debug(`Failed to create ${dirPath} directory:`, _error); + } catch (error) { + debugLogger.debug(`Failed to create ${dirPath} directory:`, error); throw new Error( `Unable to create ${dirPath} directory. Do you have file permissions in the current directory?`, ); @@ -222,8 +222,8 @@ export const setupGithubCommand: SlashCommand = { let gitRepoRoot: string; try { gitRepoRoot = getGitRepoRoot(); - } catch (_error) { - debugLogger.debug(`Failed to get git repo root:`, _error); + } catch (error) { + debugLogger.debug(`Failed to get git repo root:`, error); throw new Error( 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', ); diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index 120ba01ed7..438f09b182 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -528,6 +528,7 @@ describe('skillsCommand', () => { await actionPromise; expect(reloadSkillsMock).toHaveBeenCalled(); + expect(context.ui.reloadCommands).toHaveBeenCalled(); expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 8c8db2fca5..ea1888db40 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -285,6 +285,8 @@ async function reloadAction( context.ui.setPendingItem(null); } + context.ui.reloadCommands(); + const afterSkills = skillManager.getSkills(); const afterNames = new Set(afterSkills.map((s) => s.name)); diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index 758361be0a..6331c149a8 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -156,4 +156,30 @@ describe('', () => { expect(lastFrame()).toBeDefined(); unmount(); }); + + describe('robustness', () => { + it('does NOT crash when data is undefined', async () => { + const { lastFrame, unmount } = await render( + , + ); + expect(lastFrame({ allowEmpty: true }).trim()).toBe(''); + unmount(); + }); + + it('does NOT crash when data is an object but not an array', async () => { + const { lastFrame, unmount } = await render( + , + ); + expect(lastFrame({ allowEmpty: true }).trim()).toBe(''); + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index a1b30b0856..617740d4ad 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -35,14 +35,16 @@ export const AnsiOutputText: React.FC = ({ ? Math.min(availableHeightLimit, maxLines) : (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT); - const lastLines = disableTruncation - ? data - : numLinesRetained === 0 - ? [] - : data.slice(-numLinesRetained); + const lastLines = Array.isArray(data) + ? disableTruncation + ? data + : numLinesRetained === 0 + ? [] + : data.slice(-numLinesRetained) + : []; return ( - {lastLines.map((line: AnsiLine, lineIndex: number) => ( + {(lastLines as AnsiLine[]).map((line: AnsiLine, lineIndex: number) => ( diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 590d1e9c6b..66b54a70f3 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -172,9 +172,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { {showUiDetails && !settings.merged.ui.hideFooter && - !isScreenReaderEnabled && ( -