mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78b22bed61 |
@@ -1,29 +0,0 @@
|
||||
description = "Promote behavioral evals that have a 100% success rate over the last 7 nightly runs."
|
||||
prompt = """
|
||||
You are an expert at analyzing and promoting behavioral evaluations.
|
||||
|
||||
1. **Investigate**:
|
||||
- Use 'gh' cli to fetch the results from the most recent run from the main branch: https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml.
|
||||
- DO NOT push any changes or start any runs. The rest of your evaluation will be local.
|
||||
- Evals are in evals/ directory and are documented by evals/README.md.
|
||||
- Identify tests that have passed 100% of the time for ALL enabled models across the past 7 runs in a row.
|
||||
- NOTE: the results summary from the most recent run contains the last 7 runs test results. 100% means the test passed 3/3 times for that model and run.
|
||||
- If a test meets this criteria, it is a candidate for promotion.
|
||||
|
||||
2. **Promote**:
|
||||
- For each candidate test, locate the test file in the evals/ directory.
|
||||
- Promote the test according to the project's standard promotion process (e.g., moving it to a stable suite, updating its tags, or removing skip/flaky annotations).
|
||||
- Ensure you follow any guidelines in evals/README.md for stable tests.
|
||||
- Your **final** change should be **minimal and targeted** to just promoting the test status.
|
||||
|
||||
3. **Verify**:
|
||||
- Run the promoted tests locally to validate that they still execute correctly. Be sure to run vitest in non-interactive mode.
|
||||
- Check that the test is now part of the expected standard or stable test suites.
|
||||
|
||||
4. **Report**:
|
||||
- Provide a summary of the tests that were promoted.
|
||||
- Include the success rate evidence (7/7 runs passed for all models) for each promoted test.
|
||||
- If no tests met the criteria for promotion, clearly state that and summarize the closest candidates.
|
||||
|
||||
{{args}}
|
||||
"""
|
||||
@@ -9,5 +9,4 @@ code_review:
|
||||
help: false
|
||||
summary: true
|
||||
code_review: true
|
||||
include_drafts: false
|
||||
ignore_patterns: []
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: 'Deploy GitHub Pages'
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
pages: 'write'
|
||||
id-token: 'write'
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run
|
||||
# in-progress and latest queued. However, do NOT cancel in-progress runs as we
|
||||
# want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && !contains(github.ref_name, 'nightly')"
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
|
||||
- name: 'Setup Pages'
|
||||
uses: 'actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b' # ratchet:actions/configure-pages@v5
|
||||
|
||||
- name: 'Build with Jekyll'
|
||||
uses: 'actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697' # ratchet:actions/jekyll-build-pages@v1
|
||||
with:
|
||||
source: './'
|
||||
destination: './_site'
|
||||
|
||||
- name: 'Upload artifact'
|
||||
uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3
|
||||
|
||||
deploy:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
environment:
|
||||
name: 'github-pages'
|
||||
url: '${{ steps.deployment.outputs.page_url }}'
|
||||
runs-on: 'ubuntu-latest'
|
||||
needs: 'build'
|
||||
steps:
|
||||
- name: 'Deploy to GitHub Pages'
|
||||
id: 'deployment'
|
||||
uses: 'actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e' # ratchet:actions/deploy-pages@v4
|
||||
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.30.1
|
||||
# Latest stable release: v0.30.0
|
||||
|
||||
Released: February 27, 2026
|
||||
Released: February 25, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -28,9 +28,6 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 58df1c6 to release/v0.30.0-pr-20374 [CONFLICTS] by
|
||||
@gemini-cli-robot in
|
||||
[#20567](https://github.com/google-gemini/gemini-cli/pull/20567)
|
||||
- feat(ux): added text wrapping capabilities to markdown tables by @devr0306 in
|
||||
[#18240](https://github.com/google-gemini/gemini-cli/pull/18240)
|
||||
- Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" by
|
||||
@@ -333,4 +330,4 @@ npm install -g @google/gemini-cli
|
||||
[#20112](https://github.com/google-gemini/gemini-cli/pull/20112)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.29.7...v0.30.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.29.7...v0.30.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.31.0-preview.1
|
||||
# Preview release: v0.31.0-preview.0
|
||||
|
||||
Released: February 27, 2026
|
||||
Released: February 25, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -33,10 +33,6 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 58df1c6 to release/v0.31.0-preview.0-pr-20374 to patch
|
||||
version v0.31.0-preview.0 and create version 0.31.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
[#20568](https://github.com/google-gemini/gemini-cli/pull/20568)
|
||||
- Use ranged reads and limited searches and fuzzy editing improvements by
|
||||
@gundermanc in
|
||||
[#19240](https://github.com/google-gemini/gemini-cli/pull/19240)
|
||||
@@ -413,4 +409,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#20240](https://github.com/google-gemini/gemini-cli/pull/20240)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.30.0-preview.6...v0.31.0-preview.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.30.0-preview.6...v0.31.0-preview.0
|
||||
|
||||
+14
-5
@@ -19,15 +19,24 @@ Use the following command in Gemini CLI:
|
||||
|
||||
Running this command will open a dialog with your options:
|
||||
|
||||
| Option | Description | Models |
|
||||
| ----------------- | -------------------------------------------------------------- | -------------------------------------------- |
|
||||
| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview, gemini-3-flash-preview |
|
||||
| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash |
|
||||
| Manual | Select a specific model. | Any available model. |
|
||||
| Option | Description | Models |
|
||||
| ----------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview (if enabled), gemini-3-flash-preview (if enabled) |
|
||||
| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash |
|
||||
| Manual | Select a specific model. | Any available model. |
|
||||
|
||||
We recommend selecting one of the above **Auto** options. However, you can
|
||||
select **Manual** to select a specific model from those available.
|
||||
|
||||
### Gemini 3 and preview features
|
||||
|
||||
> **Note:** Gemini 3 is not currently available on all account types. To learn
|
||||
> more about Gemini 3 access, refer to
|
||||
> [Gemini 3 on Gemini CLI](../get-started/gemini-3.md).
|
||||
|
||||
To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable
|
||||
[**Preview Features** by using the `settings` command](../cli/settings.md).
|
||||
|
||||
You can also use the `--model` flag to specify a particular Gemini model on
|
||||
startup. For more details, refer to the
|
||||
[configuration documentation](../reference/configuration.md).
|
||||
|
||||
+4
-14
@@ -91,21 +91,11 @@ Built-in profiles (set via `SEATBELT_PROFILE` env var):
|
||||
### Custom sandbox flags
|
||||
|
||||
For container-based sandboxing, you can inject custom flags into the `docker` or
|
||||
`podman` command using the `tools.sandboxFlags` setting in your `settings.json`
|
||||
or the `SANDBOX_FLAGS` environment variable. This is useful for advanced
|
||||
configurations, such as disabling security features for specific use cases.
|
||||
`podman` command using the `SANDBOX_FLAGS` environment variable. This is useful
|
||||
for advanced configurations, such as disabling security features for specific
|
||||
use cases.
|
||||
|
||||
**Example (`settings.json`)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"sandboxFlags": "--security-opt label=disable"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example (Environment variable)**:
|
||||
**Example (Podman)**:
|
||||
|
||||
To disable SELinux labeling for volume mounts, you can set the following:
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ they appear in the UI.
|
||||
| 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` |
|
||||
|
||||
### IDE
|
||||
@@ -81,12 +80,6 @@ they appear in the UI.
|
||||
| -------- | ------------- | ---------------------------- | ------- |
|
||||
| IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` |
|
||||
|
||||
### Billing
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Overage Strategy | `billing.overageStrategy` | How to handle quota exhaustion when AI credits are available. 'ask' prompts each time, 'always' automatically uses credits, 'never' disables credit usage. | `"ask"` |
|
||||
|
||||
### Model
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
@@ -113,7 +106,6 @@ they appear in the UI.
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Sandbox Flags | `tools.sandboxFlags` | Additional flags to pass to the sandbox container engine (Docker or Podman). Environment variables can be used and will be expanded. | `""` |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Gemini CLI installation, execution, and releases
|
||||
|
||||
This document provides an overview of Gemini CLI's system requirements,
|
||||
This document provides an overview of Gemini CLI's sytem requriements,
|
||||
installation methods, and release types.
|
||||
|
||||
## Recommended system specifications
|
||||
|
||||
@@ -322,12 +322,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `"tips"`
|
||||
- **Values:** `"tips"`, `"witty"`, `"all"`, `"off"`
|
||||
|
||||
- **`ui.errorVerbosity`** (enum):
|
||||
- **Description:** Controls whether recoverable errors are hidden (low) or
|
||||
fully shown (full).
|
||||
- **Default:** `"low"`
|
||||
- **Values:** `"low"`, `"full"`
|
||||
|
||||
- **`ui.customWittyPhrases`** (array):
|
||||
- **Description:** Custom witty phrases to display during loading. When
|
||||
provided, the CLI cycles through these instead of the defaults.
|
||||
@@ -363,15 +357,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `billing`
|
||||
|
||||
- **`billing.overageStrategy`** (enum):
|
||||
- **Description:** How to handle quota exhaustion when AI credits are
|
||||
available. 'ask' prompts each time, 'always' automatically uses credits,
|
||||
'never' disables credit usage.
|
||||
- **Default:** `"ask"`
|
||||
- **Values:** `"ask"`, `"always"`, `"never"`
|
||||
|
||||
#### `model`
|
||||
|
||||
- **`model.name`** (string):
|
||||
@@ -756,12 +741,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.sandboxFlags`** (string):
|
||||
- **Description:** Additional flags to pass to the sandbox container engine
|
||||
(Docker or Podman). Environment variables can be used and will be expanded.
|
||||
- **Default:** `""`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.shell.enableInteractiveShell`** (boolean):
|
||||
- **Description:** Use node-pty for an interactive shell experience. Fallback
|
||||
to child_process still applies.
|
||||
|
||||
+6
-10
@@ -5,18 +5,14 @@ problems encountered while using Gemini CLI.
|
||||
|
||||
## General issues
|
||||
|
||||
This section addresses common questions about Gemini CLI usage, security, and
|
||||
troubleshooting general errors.
|
||||
|
||||
### Why can't I use third-party software (e.g. Claude Code, OpenClaw, OpenCode) with Gemini CLI?
|
||||
|
||||
Using third-party software, tools, or services to harvest or piggyback on Gemini
|
||||
CLI's OAuth authentication to access our backend services is a direct violation
|
||||
of our [applicable terms and policies](tos-privacy.md). Doing so bypasses our
|
||||
intended authentication and security structures, and such actions may be grounds
|
||||
for immediate suspension or termination of your account. If you would like to
|
||||
use a third-party coding agent with Gemini, the supported and secure method is
|
||||
to use a Vertex AI or Google AI Studio API key.
|
||||
Using third-party software, tools, or services to access Gemini CLI is a
|
||||
violation of our [applicable terms and policies](tos-privacy.md), and severely
|
||||
degrades the experience for legitimate product users. Such actions may be
|
||||
grounds for suspension or termination of your account. If you would like to use
|
||||
a third-party coding agent with Gemini, we recommend using a Vertex or AI Studio
|
||||
API key.
|
||||
|
||||
### Why am I getting an `API error: 429 - Resource exhausted`?
|
||||
|
||||
|
||||
@@ -1066,11 +1066,6 @@ command has no flags.
|
||||
gemini mcp list
|
||||
```
|
||||
|
||||
> **Note on Trust:** For security, `stdio` MCP servers (those using the
|
||||
> `command` property) are only tested and displayed as "Connected" if the
|
||||
> current folder is trusted. If the folder is untrusted, they will show as
|
||||
> "Disconnected". Use `gemini trust` to trust the current folder.
|
||||
|
||||
**Example output:**
|
||||
|
||||
```sh
|
||||
@@ -1079,23 +1074,6 @@ gemini mcp list
|
||||
✗ sse-server: https://api.example.com/sse (sse) - Disconnected
|
||||
```
|
||||
|
||||
## Troubleshooting and Diagnostics
|
||||
|
||||
To minimize noise during startup, MCP connection errors for background servers
|
||||
are "silent by default." If issues are detected during startup, a single
|
||||
informational hint will be shown: _"MCP issues detected. Run /mcp list for
|
||||
status."_
|
||||
|
||||
Detailed, actionable diagnostics for a specific server are automatically
|
||||
re-enabled when:
|
||||
|
||||
1. You run an interactive command like `/mcp list`, `/mcp auth`, etc.
|
||||
2. The model attempts to execute a tool from that server.
|
||||
3. You invoke an MCP prompt from that server.
|
||||
|
||||
You can also use `gemini mcp list` from your shell to see connection errors for
|
||||
all configured servers.
|
||||
|
||||
### Removing a server (`gemini mcp remove`)
|
||||
|
||||
To delete a server from your configuration, use the `remove` command with the
|
||||
|
||||
+20
-7
@@ -55,8 +55,26 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
// Rules for packages/*/src (TS/TSX)
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'],
|
||||
// Import specific config
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'], // Target all TS/TSX in the packages
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: true,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...importPlugin.configs.recommended.rules,
|
||||
...importPlugin.configs.typescript.rules,
|
||||
'import/no-default-export': 'warn',
|
||||
'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths
|
||||
},
|
||||
},
|
||||
{
|
||||
// General overrides and rules for the project (TS/TSX files)
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
@@ -77,11 +95,6 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...importPlugin.configs.recommended.rules,
|
||||
...importPlugin.configs.typescript.rules,
|
||||
'import/no-default-export': 'warn',
|
||||
'import/no-unresolved': 'off',
|
||||
'import/no-duplicates': 'error',
|
||||
// General Best Practice Rules (subset adapted for flat config)
|
||||
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
|
||||
'arrow-body-style': ['error', 'as-needed'],
|
||||
|
||||
+8
-71
@@ -46,20 +46,18 @@ two arguments:
|
||||
|
||||
#### Policies
|
||||
|
||||
Policies control how strictly a test is validated.
|
||||
Policies control how strictly a test is validated. Tests should generally use
|
||||
the ALWAYS_PASSES policy to offer the strictest guarantees.
|
||||
|
||||
USUALLY_PASSES exists to enable assertion of less consistent or aspirational
|
||||
behaviors.
|
||||
|
||||
- `ALWAYS_PASSES`: Tests expected to pass 100% of the time. These are typically
|
||||
trivial and test basic functionality. These run in every CI and can block PRs
|
||||
on failure.
|
||||
trivial and test basic functionality. These run in every CI.
|
||||
- `USUALLY_PASSES`: Tests expected to pass most of the time but may have some
|
||||
flakiness due to non-deterministic behaviors. These are run nightly and used
|
||||
to track the health of the product from build to build.
|
||||
|
||||
**All new behavioral evaluations must be created with the `USUALLY_PASSES`
|
||||
policy.** A subset that prove to be highly stable over time may be promoted to
|
||||
`ALWAYS_PASSES`. For more information, see
|
||||
[Test promotion process](#test-promotion-process).
|
||||
|
||||
#### `EvalCase` Properties
|
||||
|
||||
- `name`: The name of the evaluation case.
|
||||
@@ -78,8 +76,7 @@ import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('my_feature', () => {
|
||||
// New tests MUST start as USUALLY_PASSES and be promoted via /promote-behavioral-eval
|
||||
evalTest('USUALLY_PASSES', {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should do something',
|
||||
prompt: 'do it',
|
||||
assert: async (rig, result) => {
|
||||
@@ -117,39 +114,6 @@ npm run test:all_evals
|
||||
This command sets the `RUN_EVALS` environment variable to `1`, which enables the
|
||||
`USUALLY_PASSES` tests.
|
||||
|
||||
## Ensuring Eval is Stable Prior to Check-in
|
||||
|
||||
The
|
||||
[Evals: Nightly](https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml)
|
||||
run is considered to be the source of truth for the quality of an eval test.
|
||||
Each run of it executes a test 3 times in a row, for each supported model. The
|
||||
result is then scored 0%, 33%, 66%, or 100% respectively, to indicate how many
|
||||
of the individual executions passed.
|
||||
|
||||
Googlers can schedule a manual run against their branch by clicking the link
|
||||
above.
|
||||
|
||||
Tests should score at least 66% with key models including Gemini 3.1 pro, Gemini
|
||||
3.0 pro, and Gemini 3 flash prior to check in and they must pass 100% of the
|
||||
time before they are promoted.
|
||||
|
||||
## Test promotion process
|
||||
|
||||
To maintain a stable and reliable CI, all new behavioral evaluations follow a
|
||||
mandatory deflaking process.
|
||||
|
||||
1. **Incubation**: You must create all new tests with the `USUALLY_PASSES`
|
||||
policy. This lets them be monitored in the nightly runs without blocking PRs.
|
||||
2. **Monitoring**: The test must complete at least 10 nightly runs across all
|
||||
supported models.
|
||||
3. **Promotion**: Promotion to `ALWAYS_PASSES` happens exclusively through the
|
||||
`/promote-behavioral-eval` slash command. This command verifies the 100%
|
||||
success rate requirement is met across many runs before updating the test
|
||||
policy.
|
||||
|
||||
This promotion process is essential for preventing the introduction of flaky
|
||||
evaluations into the CI.
|
||||
|
||||
## Reporting
|
||||
|
||||
Results for evaluations are available on GitHub Actions:
|
||||
@@ -171,7 +135,7 @@ aggregated into a **Nightly Summary** attached to the workflow run.
|
||||
|
||||
- **Pass Rate (%)**: Each cell represents the percentage of successful runs for
|
||||
a specific test in that workflow instance.
|
||||
- **History**: The table shows the pass rates for the last 7 nightly runs,
|
||||
- **History**: The table shows the pass rates for the last 10 nightly runs,
|
||||
allowing you to identify if a model's behavior is trending towards
|
||||
instability.
|
||||
- **Total Pass Rate**: An aggregate metric of all evaluations run in that batch.
|
||||
@@ -220,35 +184,8 @@ gemini /fix-behavioral-eval https://github.com/google-gemini/gemini-cli/actions/
|
||||
When investigating failures manually, you can also enable verbose agent logs by
|
||||
setting the `GEMINI_DEBUG_LOG_FILE` environment variable.
|
||||
|
||||
### Best practices
|
||||
|
||||
It's highly recommended to manually review and/or ask the agent to iterate on
|
||||
any prompt changes, even if they pass all evals. The prompt should prefer
|
||||
positive traits ('do X') and resort to negative traits ('do not do X') only when
|
||||
unable to accomplish the goal with positive traits. Gemini is quite good at
|
||||
instrospecting on its prompt when asked the right questions.
|
||||
|
||||
## Promoting evaluations
|
||||
|
||||
Evaluations must be promoted from `USUALLY_PASSES` to `ALWAYS_PASSES`
|
||||
exclusively using the `/promote-behavioral-eval` slash command. Manual promotion
|
||||
is not allowed to ensure that the 100% success rate requirement is empirically
|
||||
met.
|
||||
|
||||
### `/promote-behavioral-eval`
|
||||
|
||||
This command automates the promotion of stable tests by:
|
||||
|
||||
1. **Investigating**: Analyzing the results of the last 7 nightly runs on the
|
||||
`main` branch using the `gh` CLI.
|
||||
2. **Criteria Check**: Identifying tests that have passed 100% of the time for
|
||||
ALL enabled models across the entire 7-run history.
|
||||
3. **Promotion**: Updating the test file's policy from `USUALLY_PASSES` to
|
||||
`ALWAYS_PASSES`.
|
||||
4. **Verification**: Running the promoted test locally to ensure correctness.
|
||||
|
||||
To run it:
|
||||
|
||||
```bash
|
||||
gemini /promote-behavioral-eval
|
||||
```
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('Answer vs. ask eval', () => {
|
||||
* Ensures that when the user asks a general question, the agent does NOT
|
||||
* automatically modify the file.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not edit files when asked a general question',
|
||||
prompt: 'How does app.ts work?',
|
||||
files: FILES,
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('git repo eval', () => {
|
||||
* The phrasing is intentionally chosen to evoke 'complete' to help the test
|
||||
* be more consistent.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not git add commit changes unprompted',
|
||||
prompt:
|
||||
'Finish this up for me by just making a targeted fix for the bug in index.ts. Do not build, install anything, or add tests',
|
||||
|
||||
@@ -86,7 +86,7 @@ Provide the answer as an XML block like this:
|
||||
});
|
||||
|
||||
const extensionVsGlobalTest = 'Extension memory wins over Global memory';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: extensionVsGlobalTest,
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('plan_mode', () => {
|
||||
experimental: { plan: true },
|
||||
};
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should refuse file modification when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
@@ -57,7 +57,7 @@ describe('plan_mode', () => {
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should refuse saving new documentation to the repo when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingCommandAlias = 'Agent remembers custom command aliases';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCommandAlias,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -178,7 +178,7 @@ describe('save_memory', () => {
|
||||
|
||||
const rememberingCodingStyle =
|
||||
"Agent remembers user's coding style preference";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCodingStyle,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -260,7 +260,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingBirthday = "Agent remembers user's birthday";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingBirthday,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('Shell Efficiency', () => {
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled',
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -12,22 +12,23 @@ import type {
|
||||
RequestContext,
|
||||
ExecutionEventBus,
|
||||
} from '@a2a-js/sdk/server';
|
||||
import type { ToolCallRequestInfo, Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
GeminiEventType,
|
||||
SimpleExtensionLoader,
|
||||
type ToolCallRequestInfo,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type {
|
||||
StateChange,
|
||||
AgentSettings,
|
||||
PersistedStateMetadata,
|
||||
} from '../types.js';
|
||||
import {
|
||||
CoderAgentEvent,
|
||||
getPersistedState,
|
||||
setPersistedState,
|
||||
type StateChange,
|
||||
type AgentSettings,
|
||||
type PersistedStateMetadata,
|
||||
getContextIdFromMetadata,
|
||||
getAgentSettingsFromMetadata,
|
||||
} from '../types.js';
|
||||
|
||||
@@ -14,15 +14,17 @@ import {
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { Task } from './task.js';
|
||||
import type {
|
||||
ToolCall,
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
GitService,
|
||||
CompletedToolCall,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
GeminiEventType,
|
||||
ApprovalMode,
|
||||
ToolConfirmationOutcome,
|
||||
type Config,
|
||||
type ToolCallRequestInfo,
|
||||
type GitService,
|
||||
type CompletedToolCall,
|
||||
type ToolCall,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createMockConfig } from '../utils/testing_utils.js';
|
||||
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
|
||||
|
||||
@@ -31,10 +31,7 @@ import {
|
||||
EDIT_TOOL_NAMES,
|
||||
processRestorableToolCalls,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type ExecutionEventBus,
|
||||
type RequestContext,
|
||||
} from '@a2a-js/sdk/server';
|
||||
import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
|
||||
import type {
|
||||
TaskStatusUpdateEvent,
|
||||
TaskArtifactUpdateEvent,
|
||||
@@ -47,16 +44,16 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
CoderAgentEvent,
|
||||
type CoderAgentMessage,
|
||||
type StateChange,
|
||||
type ToolCallUpdate,
|
||||
type TextContent,
|
||||
type TaskMetadata,
|
||||
type Thought,
|
||||
type ThoughtSummary,
|
||||
type Citation,
|
||||
import { CoderAgentEvent } from '../types.js';
|
||||
import type {
|
||||
CoderAgentMessage,
|
||||
StateChange,
|
||||
ToolCallUpdate,
|
||||
TextContent,
|
||||
TaskMetadata,
|
||||
Thought,
|
||||
ThoughtSummary,
|
||||
Citation,
|
||||
} from '../types.js';
|
||||
import type { PartUnion, Part as genAiPart } from '@google/genai';
|
||||
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { InitCommand } from './init.js';
|
||||
import {
|
||||
performInit,
|
||||
type CommandActionReturn,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { performInit } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { CoderAgentExecutor } from '../agent/executor.js';
|
||||
@@ -18,6 +14,7 @@ import { CoderAgentEvent } from '../types.js';
|
||||
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
|
||||
import { createMockConfig } from '../utils/testing_utils.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
import type { CommandActionReturn, Config } from '@google/gemini-cli-core';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
|
||||
@@ -9,9 +9,6 @@ import {
|
||||
listMemoryFiles,
|
||||
refreshMemory,
|
||||
showMemory,
|
||||
type AnyDeclarativeTool,
|
||||
type Config,
|
||||
type ToolRegistry,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
@@ -22,6 +19,11 @@ import {
|
||||
ShowMemoryCommand,
|
||||
} from './memory.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
import type {
|
||||
AnyDeclarativeTool,
|
||||
Config,
|
||||
ToolRegistry,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock the core functions
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
|
||||
@@ -8,6 +8,11 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
import type {
|
||||
TelemetryTarget,
|
||||
ConfigParameters,
|
||||
ExtensionLoader,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
AuthType,
|
||||
Config,
|
||||
@@ -23,9 +28,6 @@ import {
|
||||
fetchAdminControlsOnce,
|
||||
getCodeAssistServer,
|
||||
ExperimentFlags,
|
||||
type TelemetryTarget,
|
||||
type ConfigParameters,
|
||||
type ExtensionLoader,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
GeminiEventType,
|
||||
ApprovalMode,
|
||||
type Config,
|
||||
type ToolCallConfirmationDetails,
|
||||
import type {
|
||||
Config,
|
||||
ToolCallConfirmationDetails,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
TaskStatusUpdateEvent,
|
||||
SendStreamingMessageSuccessResponse,
|
||||
|
||||
@@ -11,16 +11,8 @@ import { gzipSync, gunzipSync } from 'node:zlib';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Task as SDKTask } from '@a2a-js/sdk';
|
||||
import type { TaskStore } from '@a2a-js/sdk/server';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
vi,
|
||||
type Mocked,
|
||||
type MockedClass,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import type { Mocked, MockedClass, Mock } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
import { GCSTaskStore, NoOpTaskStore } from './gcs.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { Message } from '@a2a-js/sdk';
|
||||
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { CoderAgentEvent, type StateChange } from '../types.js';
|
||||
import { CoderAgentEvent } from '../types.js';
|
||||
import type { StateChange } from '../types.js';
|
||||
|
||||
export async function pushTaskStateFailed(
|
||||
error: unknown,
|
||||
|
||||
@@ -18,10 +18,9 @@ import {
|
||||
HookSystem,
|
||||
PolicyDecision,
|
||||
tmpdir,
|
||||
type Config,
|
||||
type Storage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
|
||||
import type { Config, Storage } from '@google/gemini-cli-core';
|
||||
import { expect, vi } from 'vitest';
|
||||
|
||||
export function createMockConfig(
|
||||
|
||||
@@ -4,15 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
|
||||
import { listMcpServers } from './list.js';
|
||||
import { loadSettings, mergeSettings } from '../../config/settings.js';
|
||||
import { createTransport, debugLogger } from '@google/gemini-cli-core';
|
||||
@@ -114,10 +106,6 @@ describe('mcp list command', () => {
|
||||
mockedGetUserExtensionsDir.mockReturnValue('/mocked/extensions/dir');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should display message when no servers configured', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
@@ -145,7 +133,6 @@ describe('mcp list command', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
@@ -212,7 +199,6 @@ describe('mcp list command', () => {
|
||||
'config-server': { command: '/config/server' },
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockExtensionManager.loadExtensions.mockReturnValue([
|
||||
@@ -280,28 +266,4 @@ describe('mcp list command', () => {
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show stdio servers as disconnected in untrusted folders', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
isTrusted: false,
|
||||
});
|
||||
|
||||
// createTransport will throw in core if not trusted
|
||||
mockedCreateTransport.mockRejectedValue(new Error('Folder not trusted'));
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'test-server: /test/server (stdio) - Disconnected',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,11 @@ import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const COLOR_GREEN = '\u001b[32m';
|
||||
const COLOR_YELLOW = '\u001b[33m';
|
||||
const COLOR_RED = '\u001b[31m';
|
||||
const RESET_COLOR = '\u001b[0m';
|
||||
|
||||
export async function getMcpServersFromConfig(
|
||||
settings?: MergedSettings,
|
||||
@@ -62,56 +66,27 @@ async function testMCPConnection(
|
||||
serverName: string,
|
||||
config: MCPServerConfig,
|
||||
): Promise<MCPServerStatus> {
|
||||
const settings = loadSettings();
|
||||
|
||||
// SECURITY: Only test connection if workspace is trusted or if it's a remote server.
|
||||
// stdio servers execute local commands and must never run in untrusted workspaces.
|
||||
const isStdio = !!config.command;
|
||||
if (isStdio && !settings.isTrusted) {
|
||||
return MCPServerStatus.DISCONNECTED;
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
name: 'mcp-test-client',
|
||||
version: '0.0.1',
|
||||
});
|
||||
|
||||
const mcpContext = {
|
||||
sanitizationConfig: {
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars,
|
||||
},
|
||||
emitMcpDiagnostic: (
|
||||
severity: 'info' | 'warning' | 'error',
|
||||
message: string,
|
||||
error?: unknown,
|
||||
serverName?: string,
|
||||
) => {
|
||||
// In non-interactive list, we log everything through debugLogger for consistency
|
||||
if (severity === 'error') {
|
||||
debugLogger.error(
|
||||
chalk.red(`Error${serverName ? ` (${serverName})` : ''}: ${message}`),
|
||||
error,
|
||||
);
|
||||
} else if (severity === 'warning') {
|
||||
debugLogger.warn(
|
||||
chalk.yellow(
|
||||
`Warning${serverName ? ` (${serverName})` : ''}: ${message}`,
|
||||
),
|
||||
error,
|
||||
);
|
||||
} else {
|
||||
debugLogger.log(message, error);
|
||||
}
|
||||
},
|
||||
isTrustedFolder: () => settings.isTrusted,
|
||||
const settings = loadSettings();
|
||||
const sanitizationConfig = {
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars,
|
||||
};
|
||||
|
||||
let transport;
|
||||
try {
|
||||
// Use the same transport creation logic as core
|
||||
transport = await createTransport(serverName, config, false, mcpContext);
|
||||
transport = await createTransport(
|
||||
serverName,
|
||||
config,
|
||||
false,
|
||||
sanitizationConfig,
|
||||
);
|
||||
} catch (_error) {
|
||||
await client.close();
|
||||
return MCPServerStatus.DISCONNECTED;
|
||||
@@ -150,7 +125,7 @@ export async function listMcpServers(settings?: MergedSettings): Promise<void> {
|
||||
blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
debugLogger.log(chalk.yellow(message + '\n'));
|
||||
debugLogger.log(COLOR_YELLOW + message + RESET_COLOR + '\n');
|
||||
}
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
@@ -171,16 +146,16 @@ export async function listMcpServers(settings?: MergedSettings): Promise<void> {
|
||||
let statusText = '';
|
||||
switch (status) {
|
||||
case MCPServerStatus.CONNECTED:
|
||||
statusIndicator = chalk.green('✓');
|
||||
statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR;
|
||||
statusText = 'Connected';
|
||||
break;
|
||||
case MCPServerStatus.CONNECTING:
|
||||
statusIndicator = chalk.yellow('…');
|
||||
statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR;
|
||||
statusText = 'Connecting';
|
||||
break;
|
||||
case MCPServerStatus.DISCONNECTED:
|
||||
default:
|
||||
statusIndicator = chalk.red('✗');
|
||||
statusIndicator = COLOR_RED + '✗' + RESET_COLOR;
|
||||
statusText = 'Disconnected';
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,5 @@ export async function loadSandboxConfig(
|
||||
const image =
|
||||
process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri;
|
||||
|
||||
const flags = settings.tools?.sandboxFlags;
|
||||
|
||||
return command && image ? { command, image, flags } : undefined;
|
||||
return command && image ? { command, image } : undefined;
|
||||
}
|
||||
|
||||
@@ -96,14 +96,6 @@ describe('SettingsSchema', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should have errorVerbosity enum property', () => {
|
||||
const definition = getSettingsSchema().ui?.properties?.errorVerbosity;
|
||||
expect(definition).toBeDefined();
|
||||
expect(definition?.type).toBe('enum');
|
||||
expect(definition?.default).toBe('low');
|
||||
expect(definition?.options?.map((o) => o.value)).toEqual(['low', 'full']);
|
||||
});
|
||||
|
||||
it('should have checkpointing nested properties', () => {
|
||||
expect(
|
||||
getSettingsSchema().general?.properties?.checkpointing.properties
|
||||
|
||||
@@ -719,20 +719,6 @@ const SETTINGS_SCHEMA = {
|
||||
{ value: 'off', label: 'Off' },
|
||||
],
|
||||
},
|
||||
errorVerbosity: {
|
||||
type: 'enum',
|
||||
label: 'Error Verbosity',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 'low',
|
||||
description:
|
||||
'Controls whether recoverable errors are hidden (low) or fully shown (full).',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'full', label: 'Full' },
|
||||
],
|
||||
},
|
||||
customWittyPhrases: {
|
||||
type: 'array',
|
||||
label: 'Custom Witty Phrases',
|
||||
@@ -842,36 +828,6 @@ const SETTINGS_SCHEMA = {
|
||||
ref: 'TelemetrySettings',
|
||||
},
|
||||
|
||||
billing: {
|
||||
type: 'object',
|
||||
label: 'Billing',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Billing and AI credits settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
overageStrategy: {
|
||||
type: 'enum',
|
||||
label: 'Overage Strategy',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: 'ask',
|
||||
description: oneLine`
|
||||
How to handle quota exhaustion when AI credits are available.
|
||||
'ask' prompts each time, 'always' automatically uses credits,
|
||||
'never' disables credit usage.
|
||||
`,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'ask', label: 'Ask each time' },
|
||||
{ value: 'always', label: 'Always use credits' },
|
||||
{ value: 'never', label: 'Never use credits' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
model: {
|
||||
type: 'object',
|
||||
label: 'Model',
|
||||
@@ -1245,18 +1201,6 @@ const SETTINGS_SCHEMA = {
|
||||
`,
|
||||
showInDialog: false,
|
||||
},
|
||||
sandboxFlags: {
|
||||
type: 'string',
|
||||
label: 'Sandbox Flags',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: '',
|
||||
description: oneLine`
|
||||
Additional flags to pass to the sandbox container engine (Docker or Podman).
|
||||
Environment variables can be used and will be expanded.
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
shell: {
|
||||
type: 'object',
|
||||
label: 'Shell',
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getErrorMessage: (e: unknown) => (e as Error).message,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -31,7 +32,7 @@ describe('auth', () => {
|
||||
|
||||
it('should return null if authType is undefined', async () => {
|
||||
const result = await performInitialAuth(mockConfig, undefined);
|
||||
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
|
||||
expect(result).toBeNull();
|
||||
expect(mockConfig.refreshAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -40,7 +41,7 @@ describe('auth', () => {
|
||||
mockConfig,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
|
||||
expect(result).toBeNull();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
@@ -53,10 +54,7 @@ describe('auth', () => {
|
||||
mockConfig,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
authError: 'Failed to login. Message: Auth failed',
|
||||
accountSuspensionInfo: null,
|
||||
});
|
||||
expect(result).toBe('Failed to login. Message: Auth failed');
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
@@ -70,48 +68,7 @@ describe('auth', () => {
|
||||
mockConfig,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return accountSuspensionInfo for 403 TOS_VIOLATION error', async () => {
|
||||
vi.mocked(mockConfig.refreshAuth).mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
error: {
|
||||
code: 403,
|
||||
message:
|
||||
'This service has been disabled for violation of Terms of Service.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'TOS_VIOLATION',
|
||||
domain: 'example.googleapis.com',
|
||||
metadata: {
|
||||
appeal_url: 'https://example.com/appeal',
|
||||
appeal_url_link_text: 'Appeal Here',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await performInitialAuth(
|
||||
mockConfig,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
authError: null,
|
||||
accountSuspensionInfo: {
|
||||
message:
|
||||
'This service has been disabled for violation of Terms of Service.',
|
||||
appealUrl: 'https://example.com/appeal',
|
||||
appealLinkText: 'Appeal Here',
|
||||
},
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
|
||||
@@ -9,28 +9,20 @@ import {
|
||||
type Config,
|
||||
getErrorMessage,
|
||||
ValidationRequiredError,
|
||||
isAccountSuspendedError,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
|
||||
|
||||
export interface InitialAuthResult {
|
||||
authError: string | null;
|
||||
accountSuspensionInfo: AccountSuspensionInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the initial authentication flow.
|
||||
* @param config The application config.
|
||||
* @param authType The selected auth type.
|
||||
* @returns The auth result with error message and account suspension status.
|
||||
* @returns An error message if authentication fails, otherwise null.
|
||||
*/
|
||||
export async function performInitialAuth(
|
||||
config: Config,
|
||||
authType: AuthType | undefined,
|
||||
): Promise<InitialAuthResult> {
|
||||
): Promise<string | null> {
|
||||
if (!authType) {
|
||||
return { authError: null, accountSuspensionInfo: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -41,24 +33,10 @@ export async function performInitialAuth(
|
||||
if (e instanceof ValidationRequiredError) {
|
||||
// Don't treat validation required as a fatal auth error during startup.
|
||||
// This allows the React UI to load and show the ValidationDialog.
|
||||
return { authError: null, accountSuspensionInfo: null };
|
||||
return null;
|
||||
}
|
||||
const suspendedError = isAccountSuspendedError(e);
|
||||
if (suspendedError) {
|
||||
return {
|
||||
authError: null,
|
||||
accountSuspensionInfo: {
|
||||
message: suspendedError.message,
|
||||
appealUrl: suspendedError.appealUrl,
|
||||
appealLinkText: suspendedError.appealLinkText,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
authError: `Failed to login. Message: ${getErrorMessage(e)}`,
|
||||
accountSuspensionInfo: null,
|
||||
};
|
||||
return `Failed to login. Message: ${getErrorMessage(e)}`;
|
||||
}
|
||||
|
||||
return { authError: null, accountSuspensionInfo: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -72,10 +72,7 @@ describe('initializer', () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue(
|
||||
mockIdeClient as unknown as IdeClient,
|
||||
);
|
||||
vi.mocked(performInitialAuth).mockResolvedValue({
|
||||
authError: null,
|
||||
accountSuspensionInfo: null,
|
||||
});
|
||||
vi.mocked(performInitialAuth).mockResolvedValue(null);
|
||||
vi.mocked(validateTheme).mockReturnValue(null);
|
||||
});
|
||||
|
||||
@@ -87,7 +84,6 @@ describe('initializer', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
authError: null,
|
||||
accountSuspensionInfo: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 5,
|
||||
@@ -107,7 +103,6 @@ describe('initializer', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
authError: null,
|
||||
accountSuspensionInfo: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 5,
|
||||
@@ -121,10 +116,7 @@ describe('initializer', () => {
|
||||
});
|
||||
|
||||
it('should handle auth error', async () => {
|
||||
vi.mocked(performInitialAuth).mockResolvedValue({
|
||||
authError: 'Auth failed',
|
||||
accountSuspensionInfo: null,
|
||||
});
|
||||
vi.mocked(performInitialAuth).mockResolvedValue('Auth failed');
|
||||
const result = await initializeApp(
|
||||
mockConfig as unknown as Config,
|
||||
mockSettings,
|
||||
|
||||
@@ -17,11 +17,9 @@ import {
|
||||
import { type LoadedSettings } from '../config/settings.js';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import { validateTheme } from './theme.js';
|
||||
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
|
||||
|
||||
export interface InitializationResult {
|
||||
authError: string | null;
|
||||
accountSuspensionInfo: AccountSuspensionInfo | null;
|
||||
themeError: string | null;
|
||||
shouldOpenAuthDialog: boolean;
|
||||
geminiMdFileCount: number;
|
||||
@@ -39,7 +37,7 @@ export async function initializeApp(
|
||||
settings: LoadedSettings,
|
||||
): Promise<InitializationResult> {
|
||||
const authHandle = startupProfiler.start('authenticate');
|
||||
const { authError, accountSuspensionInfo } = await performInitialAuth(
|
||||
const authError = await performInitialAuth(
|
||||
config,
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
@@ -62,7 +60,6 @@ export async function initializeApp(
|
||||
|
||||
return {
|
||||
authError,
|
||||
accountSuspensionInfo,
|
||||
themeError,
|
||||
shouldOpenAuthDialog,
|
||||
geminiMdFileCount: config.getGeminiMdFileCount(),
|
||||
|
||||
@@ -1202,7 +1202,6 @@ describe('startInteractiveUI', () => {
|
||||
const mockWorkspaceRoot = '/root';
|
||||
const mockInitializationResult = {
|
||||
authError: null,
|
||||
accountSuspensionInfo: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
|
||||
@@ -104,8 +104,6 @@ vi.mock('../ui/auth/useAuth.js', () => ({
|
||||
onAuthError: vi.fn(),
|
||||
apiKeyDefaultValue: 'test-api-key',
|
||||
reloadApiKey: vi.fn().mockResolvedValue('test-api-key'),
|
||||
accountSuspensionInfo: null,
|
||||
setAccountSuspensionInfo: vi.fn(),
|
||||
}),
|
||||
validateAuthMethodWithSettings: () => null,
|
||||
}));
|
||||
@@ -389,7 +387,6 @@ export class AppRig {
|
||||
version="test-version"
|
||||
initializationResult={{
|
||||
authError: null,
|
||||
accountSuspensionInfo: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
|
||||
@@ -141,10 +141,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getMcpClientManager: vi.fn().mockReturnValue({
|
||||
getMcpInstructions: vi.fn().mockReturnValue(''),
|
||||
getMcpServers: vi.fn().mockReturnValue({}),
|
||||
getLastError: vi.fn().mockReturnValue(undefined),
|
||||
}),
|
||||
setUserInteractedWithMcp: vi.fn(),
|
||||
emitMcpDiagnostic: vi.fn(),
|
||||
getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false),
|
||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||
|
||||
@@ -591,8 +591,6 @@ const mockUIActions: UIActions = {
|
||||
handleClearScreen: vi.fn(),
|
||||
handleProQuotaChoice: vi.fn(),
|
||||
handleValidationChoice: vi.fn(),
|
||||
handleOverageMenuChoice: vi.fn(),
|
||||
handleEmptyWalletChoice: vi.fn(),
|
||||
setQueueErrorMessage: vi.fn(),
|
||||
popAllMessages: vi.fn(),
|
||||
handleApiKeySubmit: vi.fn(),
|
||||
@@ -615,7 +613,6 @@ const mockUIActions: UIActions = {
|
||||
handleRestart: vi.fn(),
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
getPreferredEditor: vi.fn(),
|
||||
clearAccountSuspension: vi.fn(),
|
||||
};
|
||||
|
||||
let capturedOverflowState: OverflowState | undefined;
|
||||
|
||||
@@ -2544,136 +2544,6 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Expansion Persistence', () => {
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof render>['stdin'];
|
||||
|
||||
const setupExpansionPersistenceTest = async (
|
||||
HighPriorityChild?: React.FC,
|
||||
) => {
|
||||
const getTree = () => (
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<KeypressProvider config={mockConfig}>
|
||||
<OverflowProvider>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
{HighPriorityChild && <HighPriorityChild />}
|
||||
</OverflowProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
const renderResult = render(getTree());
|
||||
stdin = renderResult.stdin;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
rerender = () => renderResult.rerender(getTree());
|
||||
unmount = () => renderResult.unmount();
|
||||
};
|
||||
|
||||
const writeStdin = async (sequence: string) => {
|
||||
await act(async () => {
|
||||
stdin.write(sequence);
|
||||
// Advance timers to allow escape sequence parsing and broadcasting
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
rerender();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should reset expansion when a key is NOT handled by anyone', async () => {
|
||||
await setupExpansionPersistenceTest();
|
||||
|
||||
// Expand first
|
||||
act(() => capturedUIActions.setConstrainHeight(false));
|
||||
rerender();
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// Press a random key that no one handles (hits Low priority fallback)
|
||||
await writeStdin('x');
|
||||
|
||||
// Should be reset to true (collapsed)
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should toggle expansion when Ctrl+O is pressed', async () => {
|
||||
await setupExpansionPersistenceTest();
|
||||
|
||||
// Initial state is collapsed
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
// Press Ctrl+O to expand (Ctrl+O is sequence \x0f)
|
||||
await writeStdin('\x0f');
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// Press Ctrl+O again to collapse
|
||||
await writeStdin('\x0f');
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT collapse when a high-priority component handles the key (e.g., up/down arrows)', async () => {
|
||||
const NavigationHandler = () => {
|
||||
// use real useKeypress
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (key.name === 'up' || key.name === 'down') {
|
||||
return true; // Handle navigation
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true, priority: true }, // High priority
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
await setupExpansionPersistenceTest(NavigationHandler);
|
||||
|
||||
// Expand first
|
||||
act(() => capturedUIActions.setConstrainHeight(false));
|
||||
rerender();
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// 1. Simulate Up arrow (handled by high priority child)
|
||||
// CSI A is Up arrow
|
||||
await writeStdin('\u001b[A');
|
||||
|
||||
// Should STILL be expanded
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// 2. Simulate Down arrow (handled by high priority child)
|
||||
// CSI B is Down arrow
|
||||
await writeStdin('\u001b[B');
|
||||
|
||||
// Should STILL be expanded
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// 3. Sanity check: press an unhandled key
|
||||
await writeStdin('x');
|
||||
|
||||
// Should finally collapse
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shortcuts Help Visibility', () => {
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let mockedUseKeypress: Mock;
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
type IdeInfo,
|
||||
type IdeContext,
|
||||
type UserTierId,
|
||||
type GeminiUserTier,
|
||||
type UserFeedbackPayload,
|
||||
type AgentDefinition,
|
||||
type ApprovalMode,
|
||||
@@ -83,8 +82,6 @@ import {
|
||||
CoreToolCallStatus,
|
||||
generateSteeringAckMessage,
|
||||
buildUserSteeringHintPrompt,
|
||||
logBillingEvent,
|
||||
ApiKeyUpdatedEvent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
@@ -394,9 +391,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
? { remaining, limit, resetTime }
|
||||
: undefined;
|
||||
});
|
||||
const [paidTier, setPaidTier] = useState<GeminiUserTier | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
||||
|
||||
@@ -675,14 +669,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
onAuthError,
|
||||
apiKeyDefaultValue,
|
||||
reloadApiKey,
|
||||
accountSuspensionInfo,
|
||||
setAccountSuspensionInfo,
|
||||
} = useAuthCommand(
|
||||
settings,
|
||||
config,
|
||||
initializationResult.authError,
|
||||
initializationResult.accountSuspensionInfo,
|
||||
);
|
||||
} = useAuthCommand(settings, config, initializationResult.authError);
|
||||
const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>(
|
||||
{},
|
||||
);
|
||||
@@ -699,20 +686,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
handleProQuotaChoice,
|
||||
validationRequest,
|
||||
handleValidationChoice,
|
||||
// G1 AI Credits
|
||||
overageMenuRequest,
|
||||
handleOverageMenuChoice,
|
||||
emptyWalletRequest,
|
||||
handleEmptyWalletChoice,
|
||||
} = useQuotaAndFallback({
|
||||
config,
|
||||
historyManager,
|
||||
userTier,
|
||||
paidTier,
|
||||
settings,
|
||||
setModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: () => setAuthState(AuthState.Updating),
|
||||
errorVerbosity: settings.merged.ui.errorVerbosity,
|
||||
});
|
||||
|
||||
// Derive auth state variables for backward compatibility with UIStateContext
|
||||
@@ -750,8 +729,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const handleAuthSelect = useCallback(
|
||||
async (authType: AuthType | undefined, scope: LoadableSettingScope) => {
|
||||
if (authType) {
|
||||
const previousAuthType =
|
||||
config.getContentGeneratorConfig()?.authType ?? 'unknown';
|
||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
setAuthContext({ requiresRestart: true });
|
||||
} else {
|
||||
@@ -764,10 +741,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
config.setRemoteAdminSettings(undefined);
|
||||
await config.refreshAuth(authType);
|
||||
setAuthState(AuthState.Authenticated);
|
||||
logBillingEvent(
|
||||
config,
|
||||
new ApiKeyUpdatedEvent(previousAuthType, authType),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof ChangeAuthRequestedError) {
|
||||
return;
|
||||
@@ -830,7 +803,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
// Only sync when not currently authenticating
|
||||
if (authState === AuthState.Authenticated) {
|
||||
setUserTier(config.getUserTier());
|
||||
setPaidTier(config.getUserPaidTier());
|
||||
}
|
||||
}, [config, authState]);
|
||||
|
||||
@@ -1689,7 +1661,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
retryStatus,
|
||||
loadingPhrasesMode: settings.merged.ui.loadingPhrases,
|
||||
customWittyPhrases: settings.merged.ui.customWittyPhrases,
|
||||
errorVerbosity: settings.merged.ui.errorVerbosity,
|
||||
});
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
@@ -1896,10 +1867,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleGlobalKeypress, {
|
||||
isActive: true,
|
||||
priority: KeypressPriority.Low,
|
||||
});
|
||||
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
|
||||
|
||||
useKeypress(
|
||||
() => {
|
||||
@@ -2038,8 +2006,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
showIdeRestartPrompt ||
|
||||
!!proQuotaRequest ||
|
||||
!!validationRequest ||
|
||||
!!overageMenuRequest ||
|
||||
!!emptyWalletRequest ||
|
||||
isSessionBrowserOpen ||
|
||||
authState === AuthState.AwaitingApiKeyInput ||
|
||||
!!newAgents;
|
||||
@@ -2067,8 +2033,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
hasLoopDetectionConfirmationRequest ||
|
||||
!!proQuotaRequest ||
|
||||
!!validationRequest ||
|
||||
!!overageMenuRequest ||
|
||||
!!emptyWalletRequest ||
|
||||
!!customDialog;
|
||||
|
||||
const allowPlanMode =
|
||||
@@ -2209,7 +2173,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isAuthenticating,
|
||||
isConfigInitialized,
|
||||
authError,
|
||||
accountSuspensionInfo,
|
||||
isAuthDialogOpen,
|
||||
isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput,
|
||||
apiKeyDefaultValue,
|
||||
@@ -2280,9 +2243,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
stats: quotaStats,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
// G1 AI Credits dialog state
|
||||
overageMenuRequest,
|
||||
emptyWalletRequest,
|
||||
},
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
@@ -2341,7 +2301,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isAuthenticating,
|
||||
isConfigInitialized,
|
||||
authError,
|
||||
accountSuspensionInfo,
|
||||
isAuthDialogOpen,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
@@ -2408,8 +2367,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
quotaStats,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
overageMenuRequest,
|
||||
emptyWalletRequest,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
@@ -2491,9 +2448,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleClearScreen,
|
||||
handleProQuotaChoice,
|
||||
handleValidationChoice,
|
||||
// G1 AI Credits handlers
|
||||
handleOverageMenuChoice,
|
||||
handleEmptyWalletChoice,
|
||||
openSessionBrowser,
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
@@ -2551,10 +2505,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setNewAgents(null);
|
||||
},
|
||||
getPreferredEditor,
|
||||
clearAccountSuspension: () => {
|
||||
setAccountSuspensionInfo(null);
|
||||
setAuthState(AuthState.Updating);
|
||||
},
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -2584,8 +2534,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleClearScreen,
|
||||
handleProQuotaChoice,
|
||||
handleValidationChoice,
|
||||
handleOverageMenuChoice,
|
||||
handleEmptyWalletChoice,
|
||||
openSessionBrowser,
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
@@ -2605,7 +2553,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
setAuthContext,
|
||||
setAccountSuspensionInfo,
|
||||
newAgents,
|
||||
config,
|
||||
historyManager,
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { BannedAccountDialog } from './BannedAccountDialog.js';
|
||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import {
|
||||
openBrowserSecurely,
|
||||
shouldLaunchBrowser,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Text } from 'ink';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
openBrowserSecurely: vi.fn(),
|
||||
shouldLaunchBrowser: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../utils/cleanup.js', () => ({
|
||||
runExitCleanup: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/shared/RadioButtonSelect.js', () => ({
|
||||
RadioButtonSelect: vi.fn(({ items }) => (
|
||||
<>
|
||||
{items.map((item: { value: string; label: string }) => (
|
||||
<Text key={item.value}>{item.label}</Text>
|
||||
))}
|
||||
</>
|
||||
)),
|
||||
}));
|
||||
|
||||
const mockedRadioButtonSelect = RadioButtonSelect as Mock;
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedOpenBrowser = openBrowserSecurely as Mock;
|
||||
const mockedShouldLaunchBrowser = shouldLaunchBrowser as Mock;
|
||||
const mockedRunExitCleanup = runExitCleanup as Mock;
|
||||
|
||||
const DEFAULT_SUSPENSION_INFO: AccountSuspensionInfo = {
|
||||
message:
|
||||
'This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product.',
|
||||
appealUrl: 'https://example.com/appeal',
|
||||
appealLinkText: 'Appeal Here',
|
||||
};
|
||||
|
||||
describe('BannedAccountDialog', () => {
|
||||
let onExit: Mock;
|
||||
let onChangeAuth: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockedShouldLaunchBrowser.mockReturnValue(true);
|
||||
mockedOpenBrowser.mockResolvedValue(undefined);
|
||||
mockedRunExitCleanup.mockResolvedValue(undefined);
|
||||
onExit = vi.fn();
|
||||
onChangeAuth = vi.fn();
|
||||
});
|
||||
|
||||
it('renders the suspension message from accountSuspensionInfo', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
onChangeAuth={onChangeAuth}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Account Suspended');
|
||||
expect(frame).toContain('violation of Terms of Service');
|
||||
expect(frame).toContain('Escape to exit');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders menu options with appeal link text from response', async () => {
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
onChangeAuth={onChangeAuth}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].label).toBe('Appeal Here');
|
||||
expect(items[1].label).toBe('Change authentication');
|
||||
expect(items[2].label).toBe('Exit');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides form option when no appealUrl is provided', async () => {
|
||||
const infoWithoutUrl: AccountSuspensionInfo = {
|
||||
message: 'Account suspended.',
|
||||
};
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={infoWithoutUrl}
|
||||
onExit={onExit}
|
||||
onChangeAuth={onChangeAuth}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].label).toBe('Change authentication');
|
||||
expect(items[1].label).toBe('Exit');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('uses default label when appealLinkText is not provided', async () => {
|
||||
const infoWithoutLinkText: AccountSuspensionInfo = {
|
||||
message: 'Account suspended.',
|
||||
appealUrl: 'https://example.com/appeal',
|
||||
};
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={infoWithoutLinkText}
|
||||
onExit={onExit}
|
||||
onChangeAuth={onChangeAuth}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items[0].label).toBe('Open the Google Form');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('opens browser when appeal option is selected', async () => {
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
onChangeAuth={onChangeAuth}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await onSelect('open_form');
|
||||
expect(mockedOpenBrowser).toHaveBeenCalledWith(
|
||||
'https://example.com/appeal',
|
||||
);
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows URL when browser cannot be launched', async () => {
|
||||
mockedShouldLaunchBrowser.mockReturnValue(false);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
onChangeAuth={onChangeAuth}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
onSelect('open_form');
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Please open this URL in a browser');
|
||||
});
|
||||
expect(mockedOpenBrowser).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('calls onExit when "Exit" is selected', async () => {
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
onChangeAuth={onChangeAuth}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await onSelect('exit');
|
||||
expect(mockedRunExitCleanup).toHaveBeenCalled();
|
||||
expect(onExit).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('calls onChangeAuth when "Change authentication" is selected', async () => {
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
onChangeAuth={onChangeAuth}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
onSelect('change_auth');
|
||||
expect(onChangeAuth).toHaveBeenCalled();
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('exits on escape key', async () => {
|
||||
const { waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
onChangeAuth={onChangeAuth}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
const result = keypressHandler({ name: 'escape' });
|
||||
expect(result).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders snapshot correctly', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||
onExit={onExit}
|
||||
onChangeAuth={onChangeAuth}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import {
|
||||
openBrowserSecurely,
|
||||
shouldLaunchBrowser,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
|
||||
|
||||
interface BannedAccountDialogProps {
|
||||
accountSuspensionInfo: AccountSuspensionInfo;
|
||||
onExit: () => void;
|
||||
onChangeAuth: () => void;
|
||||
}
|
||||
|
||||
export function BannedAccountDialog({
|
||||
accountSuspensionInfo,
|
||||
onExit,
|
||||
onChangeAuth,
|
||||
}: BannedAccountDialogProps): React.JSX.Element {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const appealUrl = accountSuspensionInfo.appealUrl;
|
||||
const appealLinkText =
|
||||
accountSuspensionInfo.appealLinkText ?? 'Open the Google Form';
|
||||
|
||||
const items = useMemo(() => {
|
||||
const menuItems = [];
|
||||
if (appealUrl) {
|
||||
menuItems.push({
|
||||
label: appealLinkText,
|
||||
value: 'open_form' as const,
|
||||
key: 'open_form',
|
||||
});
|
||||
}
|
||||
menuItems.push(
|
||||
{
|
||||
label: 'Change authentication',
|
||||
value: 'change_auth' as const,
|
||||
key: 'change_auth',
|
||||
},
|
||||
{
|
||||
label: 'Exit',
|
||||
value: 'exit' as const,
|
||||
key: 'exit',
|
||||
},
|
||||
);
|
||||
return menuItems;
|
||||
}, [appealUrl, appealLinkText]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
void handleExit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const handleExit = useCallback(async () => {
|
||||
await runExitCleanup();
|
||||
onExit();
|
||||
}, [onExit]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (choice: string) => {
|
||||
if (choice === 'open_form' && appealUrl) {
|
||||
if (!shouldLaunchBrowser()) {
|
||||
setErrorMessage(`Please open this URL in a browser: ${appealUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await openBrowserSecurely(appealUrl);
|
||||
} catch {
|
||||
setErrorMessage(`Failed to open browser. Please visit: ${appealUrl}`);
|
||||
}
|
||||
} else if (choice === 'change_auth') {
|
||||
onChangeAuth();
|
||||
} else {
|
||||
await handleExit();
|
||||
}
|
||||
},
|
||||
[handleExit, onChangeAuth, appealUrl],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text bold color={theme.status.error}>
|
||||
Error: Account Suspended
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{accountSuspensionInfo.message}</Text>
|
||||
</Box>
|
||||
|
||||
{appealUrl && (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text>Appeal URL:</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.link}>[{appealUrl}]</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.error}>{errorMessage}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
onSelect={(choice) => void handleSelect(choice)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Escape to exit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`BannedAccountDialog > renders snapshot correctly 1`] = `
|
||||
"
|
||||
Error: Account Suspended
|
||||
|
||||
This service has been disabled in this account for violation of Terms of Service. Please submit an
|
||||
appeal to continue using this product.
|
||||
|
||||
Appeal URL:
|
||||
[https://example.com/appeal]
|
||||
|
||||
Appeal HereChange authenticationExit
|
||||
|
||||
Escape to exit
|
||||
"
|
||||
`;
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type Config,
|
||||
loadApiKey,
|
||||
debugLogger,
|
||||
isAccountSuspendedError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { AuthState } from '../types.js';
|
||||
@@ -35,21 +34,16 @@ export function validateAuthMethodWithSettings(
|
||||
return validateAuthMethod(authType);
|
||||
}
|
||||
|
||||
import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js';
|
||||
|
||||
export const useAuthCommand = (
|
||||
settings: LoadedSettings,
|
||||
config: Config,
|
||||
initialAuthError: string | null = null,
|
||||
initialAccountSuspensionInfo: AccountSuspensionInfo | null = null,
|
||||
) => {
|
||||
const [authState, setAuthState] = useState<AuthState>(
|
||||
initialAuthError ? AuthState.Updating : AuthState.Unauthenticated,
|
||||
);
|
||||
|
||||
const [authError, setAuthError] = useState<string | null>(initialAuthError);
|
||||
const [accountSuspensionInfo, setAccountSuspensionInfo] =
|
||||
useState<AccountSuspensionInfo | null>(initialAccountSuspensionInfo);
|
||||
const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
@@ -136,16 +130,7 @@ export const useAuthCommand = (
|
||||
setAuthError(null);
|
||||
setAuthState(AuthState.Authenticated);
|
||||
} catch (e) {
|
||||
const suspendedError = isAccountSuspendedError(e);
|
||||
if (suspendedError) {
|
||||
setAccountSuspensionInfo({
|
||||
message: suspendedError.message,
|
||||
appealUrl: suspendedError.appealUrl,
|
||||
appealLinkText: suspendedError.appealLinkText,
|
||||
});
|
||||
} else {
|
||||
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
|
||||
}
|
||||
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
@@ -165,7 +150,5 @@ export const useAuthCommand = (
|
||||
onAuthError,
|
||||
apiKeyDefaultValue,
|
||||
reloadApiKey,
|
||||
accountSuspensionInfo,
|
||||
setAccountSuspensionInfo,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mcpCommand } from './mcpCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import {
|
||||
@@ -77,8 +77,6 @@ describe('mcpCommand', () => {
|
||||
getGeminiClient: ReturnType<typeof vi.fn>;
|
||||
getMcpClientManager: ReturnType<typeof vi.fn>;
|
||||
getResourceRegistry: ReturnType<typeof vi.fn>;
|
||||
setUserInteractedWithMcp: ReturnType<typeof vi.fn>;
|
||||
getLastMcpError: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -106,15 +104,12 @@ describe('mcpCommand', () => {
|
||||
}),
|
||||
getGeminiClient: vi.fn(),
|
||||
getMcpClientManager: vi.fn().mockImplementation(() => ({
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getMcpServers: vi.fn().mockReturnValue({}),
|
||||
getLastError: vi.fn().mockReturnValue(undefined),
|
||||
getBlockedMcpServers: vi.fn(),
|
||||
getMcpServers: vi.fn(),
|
||||
})),
|
||||
getResourceRegistry: vi.fn().mockReturnValue({
|
||||
getAllResources: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
setUserInteractedWithMcp: vi.fn(),
|
||||
getLastMcpError: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
@@ -124,10 +119,6 @@ describe('mcpCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should show an error if config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
@@ -170,7 +161,6 @@ describe('mcpCommand', () => {
|
||||
mockConfig.getMcpClientManager = vi.fn().mockReturnValue({
|
||||
getMcpServers: vi.fn().mockReturnValue(mockMcpServers),
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getLastError: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -52,8 +52,6 @@ const authCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
config.setUserInteractedWithMcp();
|
||||
|
||||
const mcpServers = config.getMcpClientManager()?.getMcpServers() ?? {};
|
||||
|
||||
if (!serverName) {
|
||||
@@ -186,8 +184,6 @@ const listAction = async (
|
||||
};
|
||||
}
|
||||
|
||||
config.setUserInteractedWithMcp();
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (!toolRegistry) {
|
||||
return {
|
||||
@@ -254,13 +250,6 @@ const listAction = async (
|
||||
enablementState[serverName] =
|
||||
await enablementManager.getDisplayState(serverName);
|
||||
}
|
||||
const errors: Record<string, string> = {};
|
||||
for (const serverName of serverNames) {
|
||||
const error = config.getMcpClientManager()?.getLastError(serverName);
|
||||
if (error) {
|
||||
errors[serverName] = error;
|
||||
}
|
||||
}
|
||||
|
||||
const mcpStatusItem: HistoryItemMcpStatus = {
|
||||
type: MessageType.MCP_STATUS,
|
||||
@@ -285,19 +274,16 @@ const listAction = async (
|
||||
})),
|
||||
authStatus,
|
||||
enablementState,
|
||||
errors,
|
||||
blockedServers: blockedMcpServers.map((s) => ({
|
||||
name: s.name,
|
||||
extensionName: s.extensionName,
|
||||
})),
|
||||
blockedServers: blockedMcpServers,
|
||||
discoveryInProgress,
|
||||
connectingServers,
|
||||
showDescriptions: Boolean(showDescriptions),
|
||||
showSchema: Boolean(showSchema),
|
||||
showDescriptions,
|
||||
showSchema,
|
||||
};
|
||||
|
||||
context.ui.addItem(mcpStatusItem);
|
||||
};
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
altNames: ['ls', 'nodesc', 'nodescription'],
|
||||
@@ -386,8 +372,6 @@ async function handleEnableDisable(
|
||||
};
|
||||
}
|
||||
|
||||
config.setUserInteractedWithMcp();
|
||||
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const isSession = parts.includes('--session');
|
||||
const serverName = parts.filter((p) => p !== '--session')[0];
|
||||
|
||||
@@ -39,18 +39,11 @@ describe('statsCommand', () => {
|
||||
mockContext.session.stats.sessionStartTime = startTime;
|
||||
});
|
||||
|
||||
it('should display general session stats when run with no subcommand', async () => {
|
||||
it('should display general session stats when run with no subcommand', () => {
|
||||
if (!statsCommand.action) throw new Error('Command has no action');
|
||||
|
||||
mockContext.services.config = {
|
||||
refreshUserQuota: vi.fn(),
|
||||
refreshAvailableCredits: vi.fn(),
|
||||
getUserTierName: vi.fn(),
|
||||
getUserPaidTier: vi.fn(),
|
||||
getModel: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
await statsCommand.action(mockContext, '');
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
statsCommand.action(mockContext, '');
|
||||
|
||||
const expectedDuration = formatDuration(
|
||||
endTime.getTime() - startTime.getTime(),
|
||||
@@ -62,7 +55,6 @@ describe('statsCommand', () => {
|
||||
tier: undefined,
|
||||
userEmail: 'mock@example.com',
|
||||
currentModel: undefined,
|
||||
creditBalance: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,8 +78,6 @@ describe('statsCommand', () => {
|
||||
getQuotaRemaining: mockGetQuotaRemaining,
|
||||
getQuotaLimit: mockGetQuotaLimit,
|
||||
getQuotaResetTime: mockGetQuotaResetTime,
|
||||
getUserPaidTier: vi.fn(),
|
||||
refreshAvailableCredits: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
await statsCommand.action(mockContext, '');
|
||||
|
||||
@@ -11,10 +11,7 @@ import type {
|
||||
} from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import {
|
||||
UserAccountManager,
|
||||
getG1CreditBalance,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { UserAccountManager } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
@@ -30,10 +27,8 @@ function getUserIdentity(context: CommandContext) {
|
||||
const userEmail = cachedAccount ?? undefined;
|
||||
|
||||
const tier = context.services.config?.getUserTierName();
|
||||
const paidTier = context.services.config?.getUserPaidTier();
|
||||
const creditBalance = getG1CreditBalance(paidTier) ?? undefined;
|
||||
|
||||
return { selectedAuthType, userEmail, tier, creditBalance };
|
||||
return { selectedAuthType, userEmail, tier };
|
||||
}
|
||||
|
||||
async function defaultSessionView(context: CommandContext) {
|
||||
@@ -48,8 +43,7 @@ async function defaultSessionView(context: CommandContext) {
|
||||
}
|
||||
const wallDuration = now.getTime() - sessionStartTime.getTime();
|
||||
|
||||
const { selectedAuthType, userEmail, tier, creditBalance } =
|
||||
getUserIdentity(context);
|
||||
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
|
||||
const currentModel = context.services.config?.getModel();
|
||||
|
||||
const statsItem: HistoryItemStats = {
|
||||
@@ -59,14 +53,10 @@ async function defaultSessionView(context: CommandContext) {
|
||||
userEmail,
|
||||
tier,
|
||||
currentModel,
|
||||
creditBalance,
|
||||
};
|
||||
|
||||
if (context.services.config) {
|
||||
const [quota] = await Promise.all([
|
||||
context.services.config.refreshUserQuota(),
|
||||
context.services.config.refreshAvailableCredits(),
|
||||
]);
|
||||
const quota = await context.services.config.refreshUserQuota();
|
||||
if (quota) {
|
||||
statsItem.quotas = quota;
|
||||
statsItem.pooledRemaining = context.services.config.getQuotaRemaining();
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { ConsoleMessageItem } from '../types.js';
|
||||
import { Box } from 'ink';
|
||||
import type React from 'react';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
|
||||
vi.mock('./shared/ScrollableList.js', () => ({
|
||||
ScrollableList: ({
|
||||
@@ -30,18 +29,13 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
|
||||
describe('DetailedMessagesDisplay', () => {
|
||||
it('renders nothing when messages are empty', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<DetailedMessagesDisplay
|
||||
messages={[]}
|
||||
maxHeight={10}
|
||||
width={80}
|
||||
hasFocus={false}
|
||||
/>,
|
||||
{
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'full' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
@@ -56,18 +50,13 @@ describe('DetailedMessagesDisplay', () => {
|
||||
{ type: 'debug', content: 'Debug message', count: 1 },
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<DetailedMessagesDisplay
|
||||
messages={messages}
|
||||
maxHeight={20}
|
||||
width={80}
|
||||
hasFocus={true}
|
||||
/>,
|
||||
{
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'full' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
@@ -76,69 +65,18 @@ describe('DetailedMessagesDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides the F12 hint in low error verbosity mode', async () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<DetailedMessagesDisplay
|
||||
messages={messages}
|
||||
maxHeight={20}
|
||||
width={80}
|
||||
hasFocus={true}
|
||||
/>,
|
||||
{
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'low' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('(F12 to close)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows the F12 hint in full error verbosity mode', async () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<DetailedMessagesDisplay
|
||||
messages={messages}
|
||||
maxHeight={20}
|
||||
width={80}
|
||||
hasFocus={true}
|
||||
/>,
|
||||
{
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'full' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(F12 to close)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders message counts', async () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'log', content: 'Repeated message', count: 5 },
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
<DetailedMessagesDisplay
|
||||
messages={messages}
|
||||
maxHeight={10}
|
||||
width={80}
|
||||
hasFocus={false}
|
||||
/>,
|
||||
{
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'full' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
interface DetailedMessagesDisplayProps {
|
||||
messages: ConsoleMessageItem[];
|
||||
@@ -29,10 +27,6 @@ export const DetailedMessagesDisplay: React.FC<
|
||||
DetailedMessagesDisplayProps
|
||||
> = ({ messages, maxHeight, width, hasFocus }) => {
|
||||
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const showHotkeyHint =
|
||||
settings.merged.ui.errorVerbosity === 'full' || config.getDebugMode();
|
||||
|
||||
const borderAndPadding = 3;
|
||||
|
||||
@@ -71,10 +65,7 @@ export const DetailedMessagesDisplay: React.FC<
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Debug Console{' '}
|
||||
{showHotkeyHint && (
|
||||
<Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||
)}
|
||||
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={maxHeight} width={width - borderAndPadding}>
|
||||
|
||||
@@ -80,8 +80,6 @@ describe('DialogManager', () => {
|
||||
stats: undefined,
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
},
|
||||
shouldShowIdePrompt: false,
|
||||
isFolderTrustDialogOpen: false,
|
||||
@@ -134,8 +132,6 @@ describe('DialogManager', () => {
|
||||
resolve: vi.fn(),
|
||||
},
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
},
|
||||
},
|
||||
'ProQuotaDialog',
|
||||
|
||||
@@ -13,14 +13,11 @@ import { ThemeDialog } from './ThemeDialog.js';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { AuthInProgress } from '../auth/AuthInProgress.js';
|
||||
import { AuthDialog } from '../auth/AuthDialog.js';
|
||||
import { BannedAccountDialog } from '../auth/BannedAccountDialog.js';
|
||||
import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
import { ValidationDialog } from './ValidationDialog.js';
|
||||
import { OverageMenuDialog } from './OverageMenuDialog.js';
|
||||
import { EmptyWalletDialog } from './EmptyWalletDialog.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
|
||||
import { SessionBrowser } from './SessionBrowser.js';
|
||||
@@ -155,28 +152,6 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quota.overageMenuRequest) {
|
||||
return (
|
||||
<OverageMenuDialog
|
||||
failedModel={uiState.quota.overageMenuRequest.failedModel}
|
||||
fallbackModel={uiState.quota.overageMenuRequest.fallbackModel}
|
||||
resetTime={uiState.quota.overageMenuRequest.resetTime}
|
||||
creditBalance={uiState.quota.overageMenuRequest.creditBalance}
|
||||
onChoice={uiActions.handleOverageMenuChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quota.emptyWalletRequest) {
|
||||
return (
|
||||
<EmptyWalletDialog
|
||||
failedModel={uiState.quota.emptyWalletRequest.failedModel}
|
||||
fallbackModel={uiState.quota.emptyWalletRequest.fallbackModel}
|
||||
resetTime={uiState.quota.emptyWalletRequest.resetTime}
|
||||
onGetCredits={uiState.quota.emptyWalletRequest.onGetCredits}
|
||||
onChoice={uiActions.handleEmptyWalletChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shouldShowIdePrompt) {
|
||||
return (
|
||||
<IdeIntegrationNudge
|
||||
@@ -321,21 +296,6 @@ export const DialogManager = ({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.accountSuspensionInfo) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<BannedAccountDialog
|
||||
accountSuspensionInfo={uiState.accountSuspensionInfo}
|
||||
onExit={() => {
|
||||
process.exit(1);
|
||||
}}
|
||||
onChangeAuth={() => {
|
||||
uiActions.clearAccountSuspension();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isAuthenticating) {
|
||||
return (
|
||||
<AuthInProgress
|
||||
@@ -358,7 +318,6 @@ export const DialogManager = ({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isAuthDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { act } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EmptyWalletDialog } from './EmptyWalletDialog.js';
|
||||
|
||||
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
act(() => {
|
||||
stdin.write(key);
|
||||
});
|
||||
};
|
||||
|
||||
describe('EmptyWalletDialog', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
const mockOnGetCredits = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should match snapshot with fallback available', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<EmptyWalletDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-3-flash-preview"
|
||||
resetTime="2:00 PM"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should match snapshot without fallback', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<EmptyWalletDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the model name and usage limit message', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<EmptyWalletDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('gemini-2.5-pro');
|
||||
expect(output).toContain('Usage limit reached');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display purchase prompt and credits update notice', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<EmptyWalletDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('purchase more AI Credits');
|
||||
expect(output).toContain(
|
||||
'Newly purchased AI credits may take a few minutes to update',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display reset time when provided', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<EmptyWalletDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
resetTime="3:45 PM"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('3:45 PM');
|
||||
expect(output).toContain('Access resets at');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not display reset time when not provided', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<EmptyWalletDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).not.toContain('Access resets at');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display slash command hints', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<EmptyWalletDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('/stats');
|
||||
expect(output).toContain('/model');
|
||||
expect(output).toContain('/auth');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChoice handling', () => {
|
||||
it('should call onGetCredits and onChoice when get_credits is selected', async () => {
|
||||
// get_credits is the first item, so just press Enter
|
||||
const { unmount, stdin, waitUntilReady } = renderWithProviders(
|
||||
<EmptyWalletDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
onChoice={mockOnChoice}
|
||||
onGetCredits={mockOnGetCredits}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnGetCredits).toHaveBeenCalled();
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('get_credits');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => {
|
||||
const { unmount, stdin, waitUntilReady } = renderWithProviders(
|
||||
<EmptyWalletDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('get_credits');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call onChoice with use_fallback when selected', async () => {
|
||||
// With fallback: items are [get_credits, use_fallback, stop]
|
||||
// use_fallback is the second item: Down + Enter
|
||||
const { unmount, stdin, waitUntilReady } = renderWithProviders(
|
||||
<EmptyWalletDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-3-flash-preview"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('use_fallback');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call onChoice with stop when selected', async () => {
|
||||
// Without fallback: items are [get_credits, stop]
|
||||
// stop is the second item: Down + Enter
|
||||
const { unmount, stdin, waitUntilReady } = renderWithProviders(
|
||||
<EmptyWalletDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('stop');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
/** Available choices in the empty wallet dialog */
|
||||
export type EmptyWalletChoice = 'get_credits' | 'use_fallback' | 'stop';
|
||||
|
||||
interface EmptyWalletDialogProps {
|
||||
/** The model that hit the quota limit */
|
||||
failedModel: string;
|
||||
/** The fallback model to offer (omit if none available) */
|
||||
fallbackModel?: string;
|
||||
/** Time when access resets (human-readable) */
|
||||
resetTime?: string;
|
||||
/** Callback to log click and open the browser for purchasing credits */
|
||||
onGetCredits?: () => void;
|
||||
/** Callback when user makes a selection */
|
||||
onChoice: (choice: EmptyWalletChoice) => void;
|
||||
}
|
||||
|
||||
export function EmptyWalletDialog({
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
resetTime,
|
||||
onGetCredits,
|
||||
onChoice,
|
||||
}: EmptyWalletDialogProps): React.JSX.Element {
|
||||
const items: Array<{
|
||||
label: string;
|
||||
value: EmptyWalletChoice;
|
||||
key: string;
|
||||
}> = [
|
||||
{
|
||||
label: 'Get AI Credits - Open browser to purchase credits',
|
||||
value: 'get_credits',
|
||||
key: 'get_credits',
|
||||
},
|
||||
];
|
||||
|
||||
if (fallbackModel) {
|
||||
items.push({
|
||||
label: `Switch to ${fallbackModel}`,
|
||||
value: 'use_fallback',
|
||||
key: 'use_fallback',
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: 'Stop - Abort request',
|
||||
value: 'stop',
|
||||
key: 'stop',
|
||||
});
|
||||
|
||||
const handleSelect = (choice: EmptyWalletChoice) => {
|
||||
if (choice === 'get_credits') {
|
||||
onGetCredits?.();
|
||||
}
|
||||
onChoice(choice);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color={theme.status.warning}>
|
||||
Usage limit reached for {failedModel}.
|
||||
</Text>
|
||||
{resetTime && <Text>Access resets at {resetTime}.</Text>}
|
||||
<Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
/stats
|
||||
</Text>{' '}
|
||||
model for usage details
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
/model
|
||||
</Text>{' '}
|
||||
to switch models.
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
/auth
|
||||
</Text>{' '}
|
||||
to switch to API key.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text>To continue using this model now, purchase more AI Credits.</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Newly purchased AI credits may take a few minutes to update.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text>How would you like to proceed?</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<RadioButtonSelect items={items} onSelect={handleSelect} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { Footer } from './Footer.js';
|
||||
import {
|
||||
makeFakeConfig,
|
||||
tildeifyPath,
|
||||
ToolCallDecision,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { tildeifyPath, ToolCallDecision } from '@google/gemini-cli-core';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
@@ -181,8 +177,6 @@ describe('<Footer />', () => {
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -209,8 +203,6 @@ describe('<Footer />', () => {
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -237,8 +229,6 @@ describe('<Footer />', () => {
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -507,75 +497,6 @@ describe('<Footer />', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error summary visibility', () => {
|
||||
it('hides error summary in low verbosity mode', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
errorCount: 2,
|
||||
showErrorDetails: false,
|
||||
},
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'low' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('F12 for details');
|
||||
expect(lastFrame()).not.toContain('2 errors');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows error summary in full verbosity mode', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
errorCount: 2,
|
||||
showErrorDetails: false,
|
||||
},
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'full' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('F12 for details');
|
||||
expect(lastFrame()).toContain('2 errors');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows error summary in debug mode even when verbosity is low', async () => {
|
||||
const debugConfig = makeFakeConfig();
|
||||
vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true);
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
config: debugConfig,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
errorCount: 1,
|
||||
showErrorDetails: false,
|
||||
},
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'low' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('F12 for details');
|
||||
expect(lastFrame()).toContain('1 error');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback mode display', () => {
|
||||
|
||||
@@ -60,9 +60,6 @@ export const Footer: React.FC = () => {
|
||||
|
||||
const showMemoryUsage =
|
||||
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
|
||||
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
|
||||
const showErrorSummary =
|
||||
!showErrorDetails && errorCount > 0 && (isFullErrorVerbosity || debugMode);
|
||||
const hideCWD = settings.merged.ui.footer.hideCWD;
|
||||
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
|
||||
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
|
||||
@@ -183,7 +180,7 @@ export const Footer: React.FC = () => {
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{showErrorSummary && (
|
||||
{!showErrorDetails && errorCount > 0 && (
|
||||
<Box paddingLeft={1} flexDirection="row">
|
||||
<Text color={theme.ui.comment}>| </Text>
|
||||
<ConsoleSummaryDisplay errorCount={errorCount} />
|
||||
|
||||
@@ -146,7 +146,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
creditBalance={itemForDisplay.creditBalance}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'model_stats' && (
|
||||
|
||||
@@ -895,9 +895,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
completion.isPerfectMatch &&
|
||||
keyMatchers[Command.SUBMIT](key) &&
|
||||
recentUnsafePasteTime === null &&
|
||||
(!completion.showSuggestions ||
|
||||
(completion.activeSuggestionIndex <= 0 &&
|
||||
!hasUserNavigatedSuggestions.current))
|
||||
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
|
||||
) {
|
||||
handleSubmit(buffer.text);
|
||||
return true;
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { act } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { OverageMenuDialog } from './OverageMenuDialog.js';
|
||||
|
||||
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
act(() => {
|
||||
stdin.write(key);
|
||||
});
|
||||
};
|
||||
|
||||
describe('OverageMenuDialog', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should match snapshot with fallback available', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<OverageMenuDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-3-flash-preview"
|
||||
resetTime="2:00 PM"
|
||||
creditBalance={500}
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should match snapshot without fallback', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<OverageMenuDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
creditBalance={500}
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the credit balance', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<OverageMenuDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
creditBalance={200}
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('200');
|
||||
expect(output).toContain('AI Credits available');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the model name', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<OverageMenuDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
creditBalance={100}
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('gemini-2.5-pro');
|
||||
expect(output).toContain('Usage limit reached');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display reset time when provided', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<OverageMenuDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
resetTime="3:45 PM"
|
||||
creditBalance={100}
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('3:45 PM');
|
||||
expect(output).toContain('Access resets at');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not display reset time when not provided', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<OverageMenuDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
creditBalance={100}
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).not.toContain('Access resets at');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display slash command hints', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<OverageMenuDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
creditBalance={100}
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('/stats');
|
||||
expect(output).toContain('/model');
|
||||
expect(output).toContain('/auth');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChoice handling', () => {
|
||||
it('should call onChoice with use_credits when selected', async () => {
|
||||
// use_credits is the first item, so just press Enter
|
||||
const { unmount, stdin, waitUntilReady } = renderWithProviders(
|
||||
<OverageMenuDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
creditBalance={100}
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('use_credits');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call onChoice with manage when selected', async () => {
|
||||
// manage is the second item: Down + Enter
|
||||
const { unmount, stdin, waitUntilReady } = renderWithProviders(
|
||||
<OverageMenuDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
creditBalance={100}
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('manage');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call onChoice with use_fallback when selected', async () => {
|
||||
// With fallback: items are [use_credits, manage, use_fallback, stop]
|
||||
// use_fallback is the third item: Down x2 + Enter
|
||||
const { unmount, stdin, waitUntilReady } = renderWithProviders(
|
||||
<OverageMenuDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-3-flash-preview"
|
||||
creditBalance={100}
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('use_fallback');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call onChoice with stop when selected', async () => {
|
||||
// Without fallback: items are [use_credits, manage, stop]
|
||||
// stop is the third item: Down x2 + Enter
|
||||
const { unmount, stdin, waitUntilReady } = renderWithProviders(
|
||||
<OverageMenuDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
creditBalance={100}
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('stop');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
/** Available choices in the overage menu dialog */
|
||||
export type OverageMenuChoice =
|
||||
| 'use_credits'
|
||||
| 'use_fallback'
|
||||
| 'manage'
|
||||
| 'stop';
|
||||
|
||||
interface OverageMenuDialogProps {
|
||||
/** The model that hit the quota limit */
|
||||
failedModel: string;
|
||||
/** The fallback model to offer (omit if none available) */
|
||||
fallbackModel?: string;
|
||||
/** Time when access resets (human-readable) */
|
||||
resetTime?: string;
|
||||
/** Available G1 AI credit balance */
|
||||
creditBalance: number;
|
||||
/** Callback when user makes a selection */
|
||||
onChoice: (choice: OverageMenuChoice) => void;
|
||||
}
|
||||
|
||||
export function OverageMenuDialog({
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
resetTime,
|
||||
creditBalance,
|
||||
onChoice,
|
||||
}: OverageMenuDialogProps): React.JSX.Element {
|
||||
const items: Array<{
|
||||
label: string;
|
||||
value: OverageMenuChoice;
|
||||
key: string;
|
||||
}> = [
|
||||
{
|
||||
label: 'Use AI Credits - Continue this request (Overage)',
|
||||
value: 'use_credits',
|
||||
key: 'use_credits',
|
||||
},
|
||||
{
|
||||
label: 'Manage - View balance and purchase more credits',
|
||||
value: 'manage',
|
||||
key: 'manage',
|
||||
},
|
||||
];
|
||||
|
||||
if (fallbackModel) {
|
||||
items.push({
|
||||
label: `Switch to ${fallbackModel}`,
|
||||
value: 'use_fallback',
|
||||
key: 'use_fallback',
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: 'Stop - Abort request',
|
||||
value: 'stop',
|
||||
key: 'stop',
|
||||
});
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color={theme.status.warning}>
|
||||
Usage limit reached for {failedModel}.
|
||||
</Text>
|
||||
{resetTime && <Text>Access resets at {resetTime}.</Text>}
|
||||
<Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
/stats
|
||||
</Text>{' '}
|
||||
model for usage details
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
/model
|
||||
</Text>{' '}
|
||||
to switch models.
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
/auth
|
||||
</Text>{' '}
|
||||
to switch to API key.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text>
|
||||
You have{' '}
|
||||
<Text bold color={theme.status.success}>
|
||||
{creditBalance}
|
||||
</Text>{' '}
|
||||
AI Credits available.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text>How would you like to proceed?</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<RadioButtonSelect items={items} onSelect={onChoice} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -395,7 +395,6 @@ interface StatsDisplayProps {
|
||||
tier?: string;
|
||||
currentModel?: string;
|
||||
quotaStats?: QuotaStats;
|
||||
creditBalance?: number;
|
||||
}
|
||||
|
||||
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
@@ -408,7 +407,6 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
tier,
|
||||
currentModel,
|
||||
quotaStats,
|
||||
creditBalance,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const { metrics } = stats;
|
||||
@@ -490,17 +488,6 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
<Text color={theme.text.primary}>{tier}</Text>
|
||||
</StatRow>
|
||||
)}
|
||||
{showUserIdentity && creditBalance != null && creditBalance >= 0 && (
|
||||
<StatRow title="Google AI Credits:">
|
||||
<Text
|
||||
color={
|
||||
creditBalance > 0 ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
>
|
||||
{creditBalance.toLocaleString()}
|
||||
</Text>
|
||||
</StatRow>
|
||||
)}
|
||||
<StatRow title="Tool Calls:">
|
||||
<Text color={theme.text.primary}>
|
||||
{tools.totalCalls} ({' '}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`EmptyWalletDialog > rendering > should match snapshot with fallback available 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Usage limit reached for gemini-2.5-pro. │
|
||||
│ Access resets at 2:00 PM. │
|
||||
│ /stats model for usage details │
|
||||
│ /model to switch models. │
|
||||
│ /auth to switch to API key. │
|
||||
│ │
|
||||
│ To continue using this model now, purchase more AI Credits. │
|
||||
│ │
|
||||
│ Newly purchased AI credits may take a few minutes to update. │
|
||||
│ │
|
||||
│ How would you like to proceed? │
|
||||
│ │
|
||||
│ │
|
||||
│ ● 1. Get AI Credits - Open browser to purchase credits │
|
||||
│ 2. Switch to gemini-3-flash-preview │
|
||||
│ 3. Stop - Abort request │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`EmptyWalletDialog > rendering > should match snapshot without fallback 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Usage limit reached for gemini-2.5-pro. │
|
||||
│ /stats model for usage details │
|
||||
│ /model to switch models. │
|
||||
│ /auth to switch to API key. │
|
||||
│ │
|
||||
│ To continue using this model now, purchase more AI Credits. │
|
||||
│ │
|
||||
│ Newly purchased AI credits may take a few minutes to update. │
|
||||
│ │
|
||||
│ How would you like to proceed? │
|
||||
│ │
|
||||
│ │
|
||||
│ ● 1. Get AI Credits - Open browser to purchase credits │
|
||||
│ 2. Stop - Abort request │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -1,47 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`OverageMenuDialog > rendering > should match snapshot with fallback available 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Usage limit reached for gemini-2.5-pro. │
|
||||
│ Access resets at 2:00 PM. │
|
||||
│ /stats model for usage details │
|
||||
│ /model to switch models. │
|
||||
│ /auth to switch to API key. │
|
||||
│ │
|
||||
│ You have 500 AI Credits available. │
|
||||
│ │
|
||||
│ How would you like to proceed? │
|
||||
│ │
|
||||
│ │
|
||||
│ ● 1. Use AI Credits - Continue this request (Overage) │
|
||||
│ 2. Manage - View balance and purchase more credits │
|
||||
│ 3. Switch to gemini-3-flash-preview │
|
||||
│ 4. Stop - Abort request │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`OverageMenuDialog > rendering > should match snapshot without fallback 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Usage limit reached for gemini-2.5-pro. │
|
||||
│ /stats model for usage details │
|
||||
│ /model to switch models. │
|
||||
│ /auth to switch to API key. │
|
||||
│ │
|
||||
│ You have 500 AI Credits available. │
|
||||
│ │
|
||||
│ How would you like to proceed? │
|
||||
│ │
|
||||
│ │
|
||||
│ ● 1. Use AI Credits - Continue this request (Overage) │
|
||||
│ 2. Manage - View balance and purchase more credits │
|
||||
│ 3. Stop - Abort request │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
GLOB_DISPLAY_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import os from 'node:os';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
|
||||
describe('<ToolGroupMessage />', () => {
|
||||
afterEach(() => {
|
||||
@@ -65,11 +64,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
ideMode: false,
|
||||
enableInteractiveShell: true,
|
||||
});
|
||||
const fullVerbositySettings = createMockSettings({
|
||||
merged: {
|
||||
ui: { errorVerbosity: 'full' },
|
||||
},
|
||||
});
|
||||
|
||||
describe('Golden Snapshots', () => {
|
||||
it('renders single successful tool call', async () => {
|
||||
@@ -79,7 +73,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
@@ -111,7 +104,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{ config: baseMockConfig, settings: fullVerbositySettings },
|
||||
{ config: baseMockConfig },
|
||||
);
|
||||
|
||||
// Should render nothing because all tools in the group are confirming
|
||||
@@ -147,7 +140,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
@@ -168,76 +160,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides errored tool calls in low error verbosity mode', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
callId: 'tool-1',
|
||||
name: 'successful-tool',
|
||||
status: CoreToolCallStatus.Success,
|
||||
}),
|
||||
createToolCall({
|
||||
callId: 'tool-2',
|
||||
name: 'error-tool',
|
||||
status: CoreToolCallStatus.Error,
|
||||
resultDisplay: 'Tool failed',
|
||||
}),
|
||||
];
|
||||
const item = createItem(toolCalls);
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
type: 'tool_group',
|
||||
tools: toolCalls,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('successful-tool');
|
||||
expect(output).not.toContain('error-tool');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps client-initiated errored tool calls visible in low error verbosity mode', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
callId: 'tool-1',
|
||||
name: 'client-error-tool',
|
||||
status: CoreToolCallStatus.Error,
|
||||
isClientInitiated: true,
|
||||
resultDisplay: 'Client tool failed',
|
||||
}),
|
||||
];
|
||||
const item = createItem(toolCalls);
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
type: 'tool_group',
|
||||
tools: toolCalls,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('client-error-tool');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders mixed tool calls including shell command', async () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
@@ -265,7 +187,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
@@ -312,7 +233,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
@@ -346,7 +266,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
@@ -369,7 +288,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
@@ -408,7 +326,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
</Scrollable>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
@@ -439,7 +356,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
@@ -490,7 +406,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
</Scrollable>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
@@ -524,7 +439,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
@@ -554,7 +468,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
@@ -597,7 +510,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
uiState: {
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
@@ -657,7 +569,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{ config: baseMockConfig, settings: fullVerbositySettings },
|
||||
{ config: baseMockConfig },
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -687,7 +599,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{ config: baseMockConfig, settings: fullVerbositySettings },
|
||||
{ config: baseMockConfig },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
@@ -715,7 +627,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
toolCalls={toolCalls}
|
||||
borderBottom={false}
|
||||
/>,
|
||||
{ config: baseMockConfig, settings: fullVerbositySettings },
|
||||
{ config: baseMockConfig },
|
||||
);
|
||||
// AskUser tools in progress are rendered by AskUserDialog, so we expect nothing.
|
||||
await waitUntilReady();
|
||||
@@ -753,7 +665,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
|
||||
{ config: baseMockConfig, settings: fullVerbositySettings },
|
||||
{ config: baseMockConfig },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
@@ -785,7 +697,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
@@ -817,7 +728,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
@@ -850,7 +760,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
@@ -882,7 +791,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
@@ -910,7 +818,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: false,
|
||||
@@ -943,7 +850,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
/>,
|
||||
{
|
||||
config: baseMockConfig,
|
||||
settings: fullVerbositySettings,
|
||||
useAlternateBuffer: true,
|
||||
uiState: {
|
||||
constrainHeight: true,
|
||||
|
||||
@@ -18,10 +18,7 @@ import { ShellToolMessage } from './ShellToolMessage.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool, isThisShellFocused } from './ToolShared.js';
|
||||
import {
|
||||
shouldHideToolCall,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { shouldHideToolCall } from '@google/gemini-cli-core';
|
||||
import { ShowMoreLines } from '../ShowMoreLines.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
@@ -30,7 +27,6 @@ import {
|
||||
calculateToolContentMaxLines,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
item: HistoryItem | HistoryItemWithoutId;
|
||||
@@ -55,29 +51,19 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
borderBottom: borderBottomOverride,
|
||||
isExpandable,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
|
||||
|
||||
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
|
||||
const toolCalls = useMemo(
|
||||
() =>
|
||||
allToolCalls.filter((t) => {
|
||||
if (
|
||||
isLowErrorVerbosity &&
|
||||
t.status === CoreToolCallStatus.Error &&
|
||||
!t.isClientInitiated
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shouldHideToolCall({
|
||||
displayName: t.name,
|
||||
status: t.status,
|
||||
approvalMode: t.approvalMode,
|
||||
hasResultDisplay: !!t.resultDisplay,
|
||||
});
|
||||
}),
|
||||
[allToolCalls, isLowErrorVerbosity],
|
||||
allToolCalls.filter(
|
||||
(t) =>
|
||||
!shouldHideToolCall({
|
||||
displayName: t.name,
|
||||
status: t.status,
|
||||
approvalMode: t.approvalMode,
|
||||
hasResultDisplay: !!t.resultDisplay,
|
||||
}),
|
||||
),
|
||||
[allToolCalls],
|
||||
);
|
||||
|
||||
const config = useConfig();
|
||||
|
||||
@@ -50,14 +50,10 @@ describe('ToolResultDisplay Overflow', () => {
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// In ASB mode the overflow hint can render before the scroll position
|
||||
// settles. Wait for both the hint and the tail of the content so this
|
||||
// snapshot is deterministic across slower CI runners.
|
||||
// ResizeObserver might take a tick, though ToolGroupMessage calculates overflow synchronously
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toBeDefined();
|
||||
expect(frame?.toLowerCase()).toContain('press ctrl+o to show more lines');
|
||||
expect(frame).toContain('line 50');
|
||||
expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines');
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
|
||||
@@ -47,7 +47,6 @@ describe('McpStatus', () => {
|
||||
isPersistentDisabled: false,
|
||||
},
|
||||
},
|
||||
errors: {},
|
||||
discoveryInProgress: false,
|
||||
connectingServers: [],
|
||||
showDescriptions: true,
|
||||
@@ -209,18 +208,6 @@ describe('McpStatus', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders correctly with a server error', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = render(
|
||||
<McpStatus
|
||||
{...baseProps}
|
||||
errors={{ 'server-1': 'Failed to connect to server' }}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('truncates resources when exceeding limit', async () => {
|
||||
const manyResources = Array.from({ length: 25 }, (_, i) => ({
|
||||
serverName: 'server-1',
|
||||
|
||||
@@ -26,7 +26,6 @@ interface McpStatusProps {
|
||||
serverStatus: (serverName: string) => MCPServerStatus;
|
||||
authStatus: HistoryItemMcpStatus['authStatus'];
|
||||
enablementState: HistoryItemMcpStatus['enablementState'];
|
||||
errors: Record<string, string>;
|
||||
discoveryInProgress: boolean;
|
||||
connectingServers: string[];
|
||||
showDescriptions: boolean;
|
||||
@@ -42,7 +41,6 @@ export const McpStatus: React.FC<McpStatusProps> = ({
|
||||
serverStatus,
|
||||
authStatus,
|
||||
enablementState,
|
||||
errors,
|
||||
discoveryInProgress,
|
||||
connectingServers,
|
||||
showDescriptions,
|
||||
@@ -197,14 +195,6 @@ export const McpStatus: React.FC<McpStatusProps> = ({
|
||||
<Text> ({toolCount} tools cached)</Text>
|
||||
)}
|
||||
|
||||
{errors[serverName] && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.status.error}>
|
||||
Error: {errors[serverName]}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showDescriptions && server?.description && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{server.description.trim()}
|
||||
|
||||
@@ -60,18 +60,6 @@ A test server
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`McpStatus > renders correctly with a server error 1`] = `
|
||||
"Configured MCP servers:
|
||||
|
||||
🟢 server-1 - Ready (1 tool)
|
||||
Error: Failed to connect to server
|
||||
A test server
|
||||
Tools:
|
||||
- tool-1
|
||||
A test tool
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`McpStatus > renders correctly with authenticated OAuth status 1`] = `
|
||||
"Configured MCP servers:
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import type { AuthState } from '../types.js';
|
||||
import { type PermissionsDialogProps } from '../components/PermissionsModifyTrustDialog.js';
|
||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import { type NewAgentsChoice } from '../components/NewAgentsNotification.js';
|
||||
import type { OverageMenuIntent, EmptyWalletIntent } from './UIStateContext.js';
|
||||
|
||||
export interface UIActions {
|
||||
handleThemeSelect: (
|
||||
@@ -63,8 +62,6 @@ export interface UIActions {
|
||||
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
|
||||
) => void;
|
||||
handleValidationChoice: (choice: 'verify' | 'change_auth' | 'cancel') => void;
|
||||
handleOverageMenuChoice: (choice: OverageMenuIntent) => void;
|
||||
handleEmptyWalletChoice: (choice: EmptyWalletIntent) => void;
|
||||
openSessionBrowser: () => void;
|
||||
closeSessionBrowser: () => void;
|
||||
handleResumeSession: (session: SessionInfo) => Promise<void>;
|
||||
@@ -91,7 +88,6 @@ export interface UIActions {
|
||||
handleRestart: () => void;
|
||||
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
|
||||
getPreferredEditor: () => EditorType | undefined;
|
||||
clearAccountSuspension: () => void;
|
||||
}
|
||||
|
||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||
|
||||
@@ -54,34 +54,6 @@ export interface ValidationDialogRequest {
|
||||
resolve: (intent: ValidationIntent) => void;
|
||||
}
|
||||
|
||||
/** Intent for overage menu dialog */
|
||||
export type OverageMenuIntent =
|
||||
| 'use_credits'
|
||||
| 'use_fallback'
|
||||
| 'manage'
|
||||
| 'stop';
|
||||
|
||||
export interface OverageMenuDialogRequest {
|
||||
failedModel: string;
|
||||
fallbackModel?: string;
|
||||
resetTime?: string;
|
||||
creditBalance: number;
|
||||
userEmail?: string;
|
||||
resolve: (intent: OverageMenuIntent) => void;
|
||||
}
|
||||
|
||||
/** Intent for empty wallet dialog */
|
||||
export type EmptyWalletIntent = 'get_credits' | 'use_fallback' | 'stop';
|
||||
|
||||
export interface EmptyWalletDialogRequest {
|
||||
failedModel: string;
|
||||
fallbackModel?: string;
|
||||
resetTime?: string;
|
||||
userEmail?: string;
|
||||
onGetCredits: () => void;
|
||||
resolve: (intent: EmptyWalletIntent) => void;
|
||||
}
|
||||
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
|
||||
@@ -92,15 +64,6 @@ export interface QuotaState {
|
||||
stats: QuotaStats | undefined;
|
||||
proQuotaRequest: ProQuotaDialogRequest | null;
|
||||
validationRequest: ValidationDialogRequest | null;
|
||||
// G1 AI Credits overage flow
|
||||
overageMenuRequest: OverageMenuDialogRequest | null;
|
||||
emptyWalletRequest: EmptyWalletDialogRequest | null;
|
||||
}
|
||||
|
||||
export interface AccountSuspensionInfo {
|
||||
message: string;
|
||||
appealUrl?: string;
|
||||
appealLinkText?: string;
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
@@ -113,7 +76,6 @@ export interface UIState {
|
||||
isAuthenticating: boolean;
|
||||
isConfigInitialized: boolean;
|
||||
authError: string | null;
|
||||
accountSuspensionInfo: AccountSuspensionInfo | null;
|
||||
isAuthDialogOpen: boolean;
|
||||
isAwaitingApiKeyInput: boolean;
|
||||
apiKeyDefaultValue?: string;
|
||||
|
||||
@@ -413,7 +413,6 @@ async function readMcpResources(
|
||||
name: `resources/read (${resource.serverName})`,
|
||||
description: resource.uri,
|
||||
status: CoreToolCallStatus.Success,
|
||||
isClientInitiated: true,
|
||||
resultDisplay: `Successfully read resource ${resource.uri}`,
|
||||
confirmationDetails: undefined,
|
||||
} as IndividualToolCallDisplay,
|
||||
@@ -428,7 +427,6 @@ async function readMcpResources(
|
||||
name: `resources/read (${resource.serverName})`,
|
||||
description: resource.uri,
|
||||
status: CoreToolCallStatus.Error,
|
||||
isClientInitiated: true,
|
||||
resultDisplay: `Error reading resource ${resource.uri}: ${getErrorMessage(error)}`,
|
||||
confirmationDetails: undefined,
|
||||
} as IndividualToolCallDisplay,
|
||||
@@ -508,7 +506,6 @@ async function readLocalFiles(
|
||||
name: readManyFilesTool.displayName,
|
||||
description: invocation.getDescription(),
|
||||
status: CoreToolCallStatus.Success,
|
||||
isClientInitiated: true,
|
||||
resultDisplay:
|
||||
result.returnDisplay ||
|
||||
`Successfully read: ${fileLabelsForDisplay.join(', ')}`,
|
||||
@@ -568,7 +565,6 @@ async function readLocalFiles(
|
||||
invocation?.getDescription() ??
|
||||
'Error attempting to execute tool to read files',
|
||||
status: CoreToolCallStatus.Error,
|
||||
isClientInitiated: true,
|
||||
resultDisplay: `Error reading files (${fileLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
||||
confirmationDetails: undefined,
|
||||
};
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { handleCreditsFlow } from './creditsFlowHandler.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import {
|
||||
type Config,
|
||||
type GeminiUserTier,
|
||||
makeFakeConfig,
|
||||
getG1CreditBalance,
|
||||
shouldAutoUseCredits,
|
||||
shouldShowOverageMenu,
|
||||
shouldShowEmptyWalletMenu,
|
||||
logBillingEvent,
|
||||
G1_CREDIT_TYPE,
|
||||
UserTierId,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getG1CreditBalance: vi.fn(),
|
||||
shouldAutoUseCredits: vi.fn(),
|
||||
shouldShowOverageMenu: vi.fn(),
|
||||
shouldShowEmptyWalletMenu: vi.fn(),
|
||||
logBillingEvent: vi.fn(),
|
||||
openBrowserSecurely: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('handleCreditsFlow', () => {
|
||||
let mockConfig: Config;
|
||||
let mockHistoryManager: UseHistoryManagerReturn;
|
||||
let isDialogPending: React.MutableRefObject<boolean>;
|
||||
let mockSetOverageMenuRequest: ReturnType<typeof vi.fn>;
|
||||
let mockSetEmptyWalletRequest: ReturnType<typeof vi.fn>;
|
||||
let mockSetModelSwitchedFromQuotaError: ReturnType<typeof vi.fn>;
|
||||
const mockPaidTier: GeminiUserTier = {
|
||||
id: UserTierId.STANDARD,
|
||||
availableCredits: [{ creditType: G1_CREDIT_TYPE, creditAmount: '100' }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = makeFakeConfig();
|
||||
mockHistoryManager = {
|
||||
addItem: vi.fn(),
|
||||
history: [],
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
};
|
||||
isDialogPending = { current: false };
|
||||
mockSetOverageMenuRequest = vi.fn();
|
||||
mockSetEmptyWalletRequest = vi.fn();
|
||||
mockSetModelSwitchedFromQuotaError = vi.fn();
|
||||
|
||||
vi.spyOn(mockConfig, 'setQuotaErrorOccurred');
|
||||
vi.spyOn(mockConfig, 'setOverageStrategy');
|
||||
|
||||
vi.mocked(getG1CreditBalance).mockReturnValue(100);
|
||||
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
|
||||
vi.mocked(shouldShowOverageMenu).mockReturnValue(false);
|
||||
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeArgs(
|
||||
overrides?: Partial<Parameters<typeof handleCreditsFlow>[0]>,
|
||||
) {
|
||||
return {
|
||||
config: mockConfig,
|
||||
paidTier: mockPaidTier,
|
||||
overageStrategy: 'ask' as const,
|
||||
failedModel: 'gemini-3-pro-preview',
|
||||
fallbackModel: 'gemini-3-flash-preview',
|
||||
usageLimitReachedModel: 'all Pro models',
|
||||
resetTime: '3:45 PM',
|
||||
historyManager: mockHistoryManager,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
isDialogPending,
|
||||
setOverageMenuRequest: mockSetOverageMenuRequest,
|
||||
setEmptyWalletRequest: mockSetEmptyWalletRequest,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('should return null if credit balance is null (non-G1 user)', async () => {
|
||||
vi.mocked(getG1CreditBalance).mockReturnValue(null);
|
||||
const result = await handleCreditsFlow(makeArgs());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if credits are already auto-used (strategy=always)', async () => {
|
||||
vi.mocked(shouldAutoUseCredits).mockReturnValue(true);
|
||||
const result = await handleCreditsFlow(makeArgs());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should show overage menu and return retry_with_credits when use_credits selected', async () => {
|
||||
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
|
||||
|
||||
const flowPromise = handleCreditsFlow(makeArgs());
|
||||
|
||||
// Extract the resolve callback from the setOverageMenuRequest call
|
||||
expect(mockSetOverageMenuRequest).toHaveBeenCalledOnce();
|
||||
const request = mockSetOverageMenuRequest.mock.calls[0][0];
|
||||
expect(request.failedModel).toBe('all Pro models');
|
||||
expect(request.creditBalance).toBe(100);
|
||||
|
||||
// Simulate user choosing 'use_credits'
|
||||
request.resolve('use_credits');
|
||||
const result = await flowPromise;
|
||||
|
||||
expect(result).toBe('retry_with_credits');
|
||||
expect(mockConfig.setOverageStrategy).toHaveBeenCalledWith('always');
|
||||
expect(logBillingEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show overage menu and return retry_always when use_fallback selected', async () => {
|
||||
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
|
||||
|
||||
const flowPromise = handleCreditsFlow(makeArgs());
|
||||
const request = mockSetOverageMenuRequest.mock.calls[0][0];
|
||||
request.resolve('use_fallback');
|
||||
const result = await flowPromise;
|
||||
|
||||
expect(result).toBe('retry_always');
|
||||
});
|
||||
|
||||
it('should show overage menu and return stop when stop selected', async () => {
|
||||
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
|
||||
|
||||
const flowPromise = handleCreditsFlow(makeArgs());
|
||||
const request = mockSetOverageMenuRequest.mock.calls[0][0];
|
||||
request.resolve('stop');
|
||||
const result = await flowPromise;
|
||||
|
||||
expect(result).toBe('stop');
|
||||
});
|
||||
|
||||
it('should return stop immediately if dialog is already pending (overage)', async () => {
|
||||
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
|
||||
isDialogPending.current = true;
|
||||
|
||||
const result = await handleCreditsFlow(makeArgs());
|
||||
expect(result).toBe('stop');
|
||||
expect(mockSetOverageMenuRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show empty wallet menu and return stop when get_credits selected', async () => {
|
||||
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
|
||||
|
||||
const flowPromise = handleCreditsFlow(makeArgs());
|
||||
|
||||
expect(mockSetEmptyWalletRequest).toHaveBeenCalledOnce();
|
||||
const request = mockSetEmptyWalletRequest.mock.calls[0][0];
|
||||
expect(request.failedModel).toBe('all Pro models');
|
||||
|
||||
request.resolve('get_credits');
|
||||
const result = await flowPromise;
|
||||
|
||||
expect(result).toBe('stop');
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('few minutes'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show empty wallet menu and return retry_always when use_fallback selected', async () => {
|
||||
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
|
||||
|
||||
const flowPromise = handleCreditsFlow(makeArgs());
|
||||
const request = mockSetEmptyWalletRequest.mock.calls[0][0];
|
||||
request.resolve('use_fallback');
|
||||
const result = await flowPromise;
|
||||
|
||||
expect(result).toBe('retry_always');
|
||||
});
|
||||
|
||||
it('should return stop immediately if dialog is already pending (empty wallet)', async () => {
|
||||
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
|
||||
isDialogPending.current = true;
|
||||
|
||||
const result = await handleCreditsFlow(makeArgs());
|
||||
expect(result).toBe('stop');
|
||||
expect(mockSetEmptyWalletRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null if no flow conditions are met', async () => {
|
||||
vi.mocked(getG1CreditBalance).mockReturnValue(100);
|
||||
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
|
||||
vi.mocked(shouldShowOverageMenu).mockReturnValue(false);
|
||||
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(false);
|
||||
|
||||
const result = await handleCreditsFlow(makeArgs());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear dialog state after overage menu resolves', async () => {
|
||||
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
|
||||
|
||||
const flowPromise = handleCreditsFlow(makeArgs());
|
||||
expect(isDialogPending.current).toBe(true);
|
||||
|
||||
const request = mockSetOverageMenuRequest.mock.calls[0][0];
|
||||
request.resolve('stop');
|
||||
await flowPromise;
|
||||
|
||||
expect(isDialogPending.current).toBe(false);
|
||||
// Verify null was set to clear the request
|
||||
expect(mockSetOverageMenuRequest).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('should clear dialog state after empty wallet menu resolves', async () => {
|
||||
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
|
||||
|
||||
const flowPromise = handleCreditsFlow(makeArgs());
|
||||
expect(isDialogPending.current).toBe(true);
|
||||
|
||||
const request = mockSetEmptyWalletRequest.mock.calls[0][0];
|
||||
request.resolve('stop');
|
||||
await flowPromise;
|
||||
|
||||
expect(isDialogPending.current).toBe(false);
|
||||
expect(mockSetEmptyWalletRequest).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -1,290 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type Config,
|
||||
type FallbackIntent,
|
||||
type GeminiUserTier,
|
||||
type OverageOption,
|
||||
getG1CreditBalance,
|
||||
shouldAutoUseCredits,
|
||||
shouldShowOverageMenu,
|
||||
shouldShowEmptyWalletMenu,
|
||||
openBrowserSecurely,
|
||||
logBillingEvent,
|
||||
OverageMenuShownEvent,
|
||||
OverageOptionSelectedEvent,
|
||||
EmptyWalletMenuShownEvent,
|
||||
CreditPurchaseClickEvent,
|
||||
buildG1Url,
|
||||
G1_UTM_CAMPAIGNS,
|
||||
UserAccountManager,
|
||||
recordOverageOptionSelected,
|
||||
recordCreditPurchaseClick,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import type {
|
||||
OverageMenuIntent,
|
||||
EmptyWalletIntent,
|
||||
EmptyWalletDialogRequest,
|
||||
} from '../contexts/UIStateContext.js';
|
||||
|
||||
interface CreditsFlowArgs {
|
||||
config: Config;
|
||||
paidTier: GeminiUserTier;
|
||||
overageStrategy: 'ask' | 'always' | 'never';
|
||||
failedModel: string;
|
||||
fallbackModel: string;
|
||||
usageLimitReachedModel: string;
|
||||
resetTime: string | undefined;
|
||||
historyManager: UseHistoryManagerReturn;
|
||||
setModelSwitchedFromQuotaError: (value: boolean) => void;
|
||||
isDialogPending: React.MutableRefObject<boolean>;
|
||||
setOverageMenuRequest: (
|
||||
req: {
|
||||
failedModel: string;
|
||||
fallbackModel: string;
|
||||
resetTime: string | undefined;
|
||||
creditBalance: number;
|
||||
resolve: (intent: OverageMenuIntent) => void;
|
||||
} | null,
|
||||
) => void;
|
||||
setEmptyWalletRequest: (req: EmptyWalletDialogRequest | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the G1 AI Credits flow when a quota error occurs.
|
||||
* Returns a FallbackIntent if the credits flow handled the error,
|
||||
* or null to fall through to the default ProQuotaDialog.
|
||||
*/
|
||||
export async function handleCreditsFlow(
|
||||
args: CreditsFlowArgs,
|
||||
): Promise<FallbackIntent | null> {
|
||||
const creditBalance = getG1CreditBalance(args.paidTier);
|
||||
|
||||
// creditBalance is null when user is not eligible for G1 credits.
|
||||
if (creditBalance == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { overageStrategy } = args;
|
||||
|
||||
// If credits are already auto-enabled (strategy='always'), the request
|
||||
// that just failed already included enabledCreditTypes — credits didn't
|
||||
// help. Fall through to ProQuotaDialog which offers the Flash downgrade.
|
||||
if (shouldAutoUseCredits(overageStrategy, creditBalance)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show overage menu when strategy is 'ask' and credits > 0
|
||||
if (shouldShowOverageMenu(overageStrategy, creditBalance)) {
|
||||
return handleOverageMenu(args, creditBalance);
|
||||
}
|
||||
|
||||
// Show empty wallet when credits === 0 and strategy isn't 'never'
|
||||
if (shouldShowEmptyWalletMenu(overageStrategy, creditBalance)) {
|
||||
return handleEmptyWalletMenu(args);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overage menu flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleOverageMenu(
|
||||
args: CreditsFlowArgs,
|
||||
creditBalance: number,
|
||||
): Promise<FallbackIntent> {
|
||||
const {
|
||||
config,
|
||||
fallbackModel,
|
||||
usageLimitReachedModel,
|
||||
overageStrategy,
|
||||
resetTime,
|
||||
isDialogPending,
|
||||
setOverageMenuRequest,
|
||||
setModelSwitchedFromQuotaError,
|
||||
historyManager,
|
||||
} = args;
|
||||
|
||||
logBillingEvent(
|
||||
config,
|
||||
new OverageMenuShownEvent(
|
||||
usageLimitReachedModel,
|
||||
creditBalance,
|
||||
overageStrategy,
|
||||
),
|
||||
);
|
||||
|
||||
if (isDialogPending.current) {
|
||||
return 'stop';
|
||||
}
|
||||
isDialogPending.current = true;
|
||||
|
||||
setModelSwitchedFromQuotaError(true);
|
||||
config.setQuotaErrorOccurred(true);
|
||||
|
||||
const overageIntent = await new Promise<OverageMenuIntent>((resolve) => {
|
||||
setOverageMenuRequest({
|
||||
failedModel: usageLimitReachedModel,
|
||||
fallbackModel,
|
||||
resetTime,
|
||||
creditBalance,
|
||||
resolve,
|
||||
});
|
||||
});
|
||||
|
||||
setOverageMenuRequest(null);
|
||||
isDialogPending.current = false;
|
||||
|
||||
logOverageOptionSelected(
|
||||
config,
|
||||
usageLimitReachedModel,
|
||||
overageIntent,
|
||||
creditBalance,
|
||||
);
|
||||
|
||||
switch (overageIntent) {
|
||||
case 'use_credits':
|
||||
setModelSwitchedFromQuotaError(false);
|
||||
config.setQuotaErrorOccurred(false);
|
||||
config.setOverageStrategy('always');
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Using AI Credits for this request.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return 'retry_with_credits';
|
||||
|
||||
case 'use_fallback':
|
||||
return 'retry_always';
|
||||
|
||||
case 'manage':
|
||||
logCreditPurchaseClick(config, 'manage', usageLimitReachedModel);
|
||||
await openG1Url('activity', G1_UTM_CAMPAIGNS.MANAGE_ACTIVITY);
|
||||
return 'stop';
|
||||
|
||||
case 'stop':
|
||||
default:
|
||||
return 'stop';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty wallet flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleEmptyWalletMenu(
|
||||
args: CreditsFlowArgs,
|
||||
): Promise<FallbackIntent> {
|
||||
const {
|
||||
config,
|
||||
fallbackModel,
|
||||
usageLimitReachedModel,
|
||||
resetTime,
|
||||
isDialogPending,
|
||||
setEmptyWalletRequest,
|
||||
setModelSwitchedFromQuotaError,
|
||||
} = args;
|
||||
|
||||
logBillingEvent(
|
||||
config,
|
||||
new EmptyWalletMenuShownEvent(usageLimitReachedModel),
|
||||
);
|
||||
|
||||
if (isDialogPending.current) {
|
||||
return 'stop';
|
||||
}
|
||||
isDialogPending.current = true;
|
||||
|
||||
setModelSwitchedFromQuotaError(true);
|
||||
config.setQuotaErrorOccurred(true);
|
||||
|
||||
const emptyWalletIntent = await new Promise<EmptyWalletIntent>((resolve) => {
|
||||
setEmptyWalletRequest({
|
||||
failedModel: usageLimitReachedModel,
|
||||
fallbackModel,
|
||||
resetTime,
|
||||
onGetCredits: () => {
|
||||
logCreditPurchaseClick(
|
||||
config,
|
||||
'empty_wallet_menu',
|
||||
usageLimitReachedModel,
|
||||
);
|
||||
void openG1Url('credits', G1_UTM_CAMPAIGNS.EMPTY_WALLET_ADD_CREDITS);
|
||||
},
|
||||
resolve,
|
||||
});
|
||||
});
|
||||
|
||||
setEmptyWalletRequest(null);
|
||||
isDialogPending.current = false;
|
||||
|
||||
switch (emptyWalletIntent) {
|
||||
case 'get_credits':
|
||||
args.historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Newly purchased AI credits may take a few minutes to update. Run /stats to check your balance.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return 'stop';
|
||||
|
||||
case 'use_fallback':
|
||||
return 'retry_always';
|
||||
|
||||
case 'stop':
|
||||
default:
|
||||
return 'stop';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telemetry helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function logOverageOptionSelected(
|
||||
config: Config,
|
||||
model: string,
|
||||
option: OverageOption,
|
||||
creditBalance: number,
|
||||
): void {
|
||||
logBillingEvent(
|
||||
config,
|
||||
new OverageOptionSelectedEvent(model, option, creditBalance),
|
||||
);
|
||||
recordOverageOptionSelected(config, {
|
||||
selected_option: option,
|
||||
model,
|
||||
});
|
||||
}
|
||||
|
||||
function logCreditPurchaseClick(
|
||||
config: Config,
|
||||
source: 'overage_menu' | 'empty_wallet_menu' | 'manage',
|
||||
model: string,
|
||||
): void {
|
||||
logBillingEvent(config, new CreditPurchaseClickEvent(source, model));
|
||||
recordCreditPurchaseClick(config, { source, model });
|
||||
}
|
||||
|
||||
async function openG1Url(
|
||||
path: 'activity' | 'credits',
|
||||
campaign: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userEmail = new UserAccountManager().getCachedGoogleAccount() ?? '';
|
||||
await openBrowserSecurely(buildG1Url(path, userEmail, campaign));
|
||||
} catch {
|
||||
// Ignore browser open errors
|
||||
}
|
||||
}
|
||||
@@ -305,7 +305,6 @@ export const useShellCommandProcessor = (
|
||||
name: SHELL_COMMAND_NAME,
|
||||
description: rawQuery,
|
||||
status: CoreToolCallStatus.Executing,
|
||||
isClientInitiated: true,
|
||||
resultDisplay: '',
|
||||
confirmationDetails: undefined,
|
||||
};
|
||||
|
||||
@@ -581,7 +581,6 @@ export const useSlashCommandProcessor = (
|
||||
name: 'Expansion',
|
||||
description: 'Command expansion needs shell access',
|
||||
status: CoreToolCallStatus.AwaitingApproval,
|
||||
isClientInitiated: true,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails,
|
||||
};
|
||||
|
||||
@@ -325,33 +325,5 @@ describe('toolMapping', () => {
|
||||
const result = mapToDisplay(toolCall);
|
||||
expect(result.tools[0].originalRequestName).toBe('original_tool');
|
||||
});
|
||||
|
||||
it('propagates isClientInitiated from tool request', () => {
|
||||
const clientInitiatedTool: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
...mockRequest,
|
||||
callId: 'call-client',
|
||||
isClientInitiated: true,
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation,
|
||||
};
|
||||
|
||||
const modelInitiatedTool: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
...mockRequest,
|
||||
callId: 'call-model',
|
||||
isClientInitiated: false,
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation,
|
||||
};
|
||||
|
||||
const result = mapToDisplay([clientInitiatedTool, modelInitiatedTool]);
|
||||
expect(result.tools[0].isClientInitiated).toBe(true);
|
||||
expect(result.tools[1].isClientInitiated).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +101,6 @@ export function mapToDisplay(
|
||||
return {
|
||||
...baseDisplayProperties,
|
||||
status: call.status,
|
||||
isClientInitiated: !!call.request.isClientInitiated,
|
||||
resultDisplay,
|
||||
confirmationDetails,
|
||||
outputFile,
|
||||
|
||||
@@ -28,7 +28,6 @@ import type { UseAtCompletionProps } from './useAtCompletion.js';
|
||||
import { useAtCompletion } from './useAtCompletion.js';
|
||||
import type { UseSlashCompletionProps } from './useSlashCompletion.js';
|
||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||
import { useShellCompletion } from './useShellCompletion.js';
|
||||
|
||||
vi.mock('./useAtCompletion', () => ({
|
||||
useAtCompletion: vi.fn(),
|
||||
@@ -41,35 +40,29 @@ vi.mock('./useSlashCompletion', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./useShellCompletion', () => ({
|
||||
useShellCompletion: vi.fn(() => ({
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
query: '',
|
||||
})),
|
||||
}));
|
||||
vi.mock('./useShellCompletion', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('./useShellCompletion.js')
|
||||
>('./useShellCompletion');
|
||||
return {
|
||||
...actual,
|
||||
useShellCompletion: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Helper to set up mocks in a consistent way for both child hooks
|
||||
const setupMocks = ({
|
||||
atSuggestions = [],
|
||||
slashSuggestions = [],
|
||||
shellSuggestions = [],
|
||||
isLoading = false,
|
||||
isPerfectMatch = false,
|
||||
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
|
||||
shellCompletionRange = { completionStart: 0, completionEnd: 0, query: '' },
|
||||
}: {
|
||||
atSuggestions?: Suggestion[];
|
||||
slashSuggestions?: Suggestion[];
|
||||
shellSuggestions?: Suggestion[];
|
||||
isLoading?: boolean;
|
||||
isPerfectMatch?: boolean;
|
||||
slashCompletionRange?: { completionStart: number; completionEnd: number };
|
||||
shellCompletionRange?: {
|
||||
completionStart: number;
|
||||
completionEnd: number;
|
||||
query: string;
|
||||
};
|
||||
}) => {
|
||||
// Mock for @-completions
|
||||
(useAtCompletion as Mock).mockImplementation(
|
||||
@@ -106,19 +99,6 @@ const setupMocks = ({
|
||||
return slashCompletionRange;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock for shell completions
|
||||
(useShellCompletion as Mock).mockImplementation(
|
||||
({ enabled, setSuggestions, setIsLoadingSuggestions }) => {
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
setIsLoadingSuggestions(isLoading);
|
||||
setSuggestions(shellSuggestions);
|
||||
}
|
||||
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
|
||||
return shellCompletionRange;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe('useCommandCompletion', () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { toCodePoints } from '../utils/textUtils.js';
|
||||
import { useAtCompletion } from './useAtCompletion.js';
|
||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||
import { useShellCompletion } from './useShellCompletion.js';
|
||||
import { useShellCompletion, getTokenAtCursor } from './useShellCompletion.js';
|
||||
import type { PromptCompletion } from './usePromptCompletion.js';
|
||||
import {
|
||||
usePromptCompletion,
|
||||
@@ -103,22 +103,40 @@ export function useCommandCompletion({
|
||||
|
||||
const {
|
||||
completionMode,
|
||||
query: memoQuery,
|
||||
query,
|
||||
completionStart,
|
||||
completionEnd,
|
||||
shellTokenIsCommand,
|
||||
shellTokens,
|
||||
shellCursorIndex,
|
||||
shellCommandToken,
|
||||
} = useMemo(() => {
|
||||
const currentLine = buffer.lines[cursorRow] || '';
|
||||
const codePoints = toCodePoints(currentLine);
|
||||
|
||||
if (shellModeActive) {
|
||||
const tokenInfo = getTokenAtCursor(currentLine, cursorCol);
|
||||
if (tokenInfo) {
|
||||
return {
|
||||
completionMode: CompletionMode.SHELL,
|
||||
query: tokenInfo.token,
|
||||
completionStart: tokenInfo.start,
|
||||
completionEnd: tokenInfo.end,
|
||||
shellTokenIsCommand: tokenInfo.isFirstToken,
|
||||
shellTokens: tokenInfo.tokens,
|
||||
shellCursorIndex: tokenInfo.cursorIndex,
|
||||
shellCommandToken: tokenInfo.commandToken,
|
||||
};
|
||||
}
|
||||
return {
|
||||
completionMode:
|
||||
currentLine.trim().length === 0
|
||||
? CompletionMode.IDLE
|
||||
: CompletionMode.SHELL,
|
||||
completionMode: CompletionMode.SHELL,
|
||||
query: '',
|
||||
completionStart: -1,
|
||||
completionEnd: -1,
|
||||
completionStart: cursorCol,
|
||||
completionEnd: cursorCol,
|
||||
shellTokenIsCommand: currentLine.trim().length === 0,
|
||||
shellTokens: [''],
|
||||
shellCursorIndex: 0,
|
||||
shellCommandToken: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +176,10 @@ export function useCommandCompletion({
|
||||
query: partialPath,
|
||||
completionStart: pathStart,
|
||||
completionEnd: end,
|
||||
shellTokenIsCommand: false,
|
||||
shellTokens: [],
|
||||
shellCursorIndex: -1,
|
||||
shellCommandToken: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -169,6 +191,10 @@ export function useCommandCompletion({
|
||||
query: currentLine,
|
||||
completionStart: 0,
|
||||
completionEnd: currentLine.length,
|
||||
shellTokenIsCommand: false,
|
||||
shellTokens: [],
|
||||
shellCursorIndex: -1,
|
||||
shellCommandToken: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,6 +212,10 @@ export function useCommandCompletion({
|
||||
query: trimmedText,
|
||||
completionStart: 0,
|
||||
completionEnd: trimmedText.length,
|
||||
shellTokenIsCommand: false,
|
||||
shellTokens: [],
|
||||
shellCursorIndex: -1,
|
||||
shellCommandToken: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -194,12 +224,16 @@ export function useCommandCompletion({
|
||||
query: null,
|
||||
completionStart: -1,
|
||||
completionEnd: -1,
|
||||
shellTokenIsCommand: false,
|
||||
shellTokens: [],
|
||||
shellCursorIndex: -1,
|
||||
shellCommandToken: '',
|
||||
};
|
||||
}, [cursorRow, cursorCol, buffer.lines, buffer.text, shellModeActive]);
|
||||
|
||||
useAtCompletion({
|
||||
enabled: active && completionMode === CompletionMode.AT,
|
||||
pattern: memoQuery || '',
|
||||
pattern: query || '',
|
||||
config,
|
||||
cwd,
|
||||
setSuggestions,
|
||||
@@ -209,7 +243,7 @@ export function useCommandCompletion({
|
||||
const slashCompletionRange = useSlashCompletion({
|
||||
enabled:
|
||||
active && completionMode === CompletionMode.SLASH && !shellModeActive,
|
||||
query: memoQuery,
|
||||
query,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
setSuggestions,
|
||||
@@ -217,20 +251,18 @@ export function useCommandCompletion({
|
||||
setIsPerfectMatch,
|
||||
});
|
||||
|
||||
const shellCompletionRange = useShellCompletion({
|
||||
useShellCompletion({
|
||||
enabled: active && completionMode === CompletionMode.SHELL,
|
||||
line: buffer.lines[cursorRow] || '',
|
||||
cursorCol,
|
||||
query: query || '',
|
||||
isCommandPosition: shellTokenIsCommand,
|
||||
tokens: shellTokens,
|
||||
cursorIndex: shellCursorIndex,
|
||||
commandToken: shellCommandToken,
|
||||
cwd,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
});
|
||||
|
||||
const query =
|
||||
completionMode === CompletionMode.SHELL
|
||||
? shellCompletionRange.query
|
||||
: memoQuery;
|
||||
|
||||
const promptCompletion = usePromptCompletion({
|
||||
buffer,
|
||||
});
|
||||
@@ -289,9 +321,6 @@ export function useCommandCompletion({
|
||||
if (completionMode === CompletionMode.SLASH) {
|
||||
start = slashCompletionRange.completionStart;
|
||||
end = slashCompletionRange.completionEnd;
|
||||
} else if (completionMode === CompletionMode.SHELL) {
|
||||
start = shellCompletionRange.completionStart;
|
||||
end = shellCompletionRange.completionEnd;
|
||||
}
|
||||
|
||||
if (start === -1 || end === -1) {
|
||||
@@ -321,7 +350,6 @@ export function useCommandCompletion({
|
||||
completionStart,
|
||||
completionEnd,
|
||||
slashCompletionRange,
|
||||
shellCompletionRange,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -342,9 +370,6 @@ export function useCommandCompletion({
|
||||
if (completionMode === CompletionMode.SLASH) {
|
||||
start = slashCompletionRange.completionStart;
|
||||
end = slashCompletionRange.completionEnd;
|
||||
} else if (completionMode === CompletionMode.SHELL) {
|
||||
start = shellCompletionRange.completionStart;
|
||||
end = shellCompletionRange.completionEnd;
|
||||
}
|
||||
|
||||
// Add space padding for Tab completion (auto-execute gets padding from getCompletedText)
|
||||
@@ -383,7 +408,6 @@ export function useCommandCompletion({
|
||||
completionStart,
|
||||
completionEnd,
|
||||
slashCompletionRange,
|
||||
shellCompletionRange,
|
||||
getCompletedText,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -335,10 +335,7 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
|
||||
const mockLoadedSettings: LoadedSettings = {
|
||||
merged: {
|
||||
preferredEditor: 'vscode',
|
||||
ui: { errorVerbosity: 'full' },
|
||||
},
|
||||
merged: { preferredEditor: 'vscode' },
|
||||
user: { path: '/user/settings.json', settings: {} },
|
||||
workspace: { path: '/workspace/.gemini/settings.json', settings: {} },
|
||||
errors: [],
|
||||
@@ -349,7 +346,6 @@ describe('useGeminiStream', () => {
|
||||
const renderTestHook = (
|
||||
initialToolCalls: TrackedToolCall[] = [],
|
||||
geminiClient?: any,
|
||||
loadedSettings: LoadedSettings = mockLoadedSettings,
|
||||
) => {
|
||||
const client = geminiClient || mockConfig.getGeminiClient();
|
||||
let lastToolCalls = initialToolCalls;
|
||||
@@ -364,7 +360,7 @@ describe('useGeminiStream', () => {
|
||||
cmd: PartListUnion,
|
||||
) => Promise<SlashCommandProcessorResult | false>,
|
||||
shellModeActive: false,
|
||||
loadedSettings,
|
||||
loadedSettings: mockLoadedSettings,
|
||||
toolCalls: initialToolCalls,
|
||||
};
|
||||
|
||||
@@ -973,93 +969,6 @@ describe('useGeminiStream', () => {
|
||||
// Streaming state should be Idle
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
});
|
||||
|
||||
const infoTexts = mockAddItem.mock.calls.map(
|
||||
([item]) => (item as { text?: string }).text ?? '',
|
||||
);
|
||||
expect(
|
||||
infoTexts.some((text) =>
|
||||
text.includes(
|
||||
'Some internal tool attempts failed before this final error',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
infoTexts.some((text) =>
|
||||
text.includes('This request failed. Press F12 for diagnostics'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should add a compact suppressed-error note before STOP_EXECUTION terminal info in low verbosity mode', async () => {
|
||||
const stopExecutionToolCalls: TrackedToolCall[] = [
|
||||
{
|
||||
request: {
|
||||
callId: 'stop-call',
|
||||
name: 'stopTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-stop',
|
||||
},
|
||||
status: CoreToolCallStatus.Error,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason from hook'),
|
||||
resultDisplay: undefined,
|
||||
},
|
||||
responseSubmittedToGemini: false,
|
||||
tool: {
|
||||
displayName: 'stop tool',
|
||||
},
|
||||
invocation: {
|
||||
getDescription: () => `Mock description`,
|
||||
} as unknown as AnyToolInvocation,
|
||||
} as unknown as TrackedCompletedToolCall,
|
||||
];
|
||||
const lowVerbositySettings = {
|
||||
...mockLoadedSettings,
|
||||
merged: {
|
||||
...mockLoadedSettings.merged,
|
||||
ui: { errorVerbosity: 'low' },
|
||||
},
|
||||
} as LoadedSettings;
|
||||
const client = new MockedGeminiClientClass(mockConfig);
|
||||
|
||||
const { result } = renderTestHook([], client, lowVerbositySettings);
|
||||
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await capturedOnComplete(stopExecutionToolCalls);
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['stop-call']);
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
});
|
||||
|
||||
const infoTexts = mockAddItem.mock.calls.map(
|
||||
([item]) => (item as { text?: string }).text ?? '',
|
||||
);
|
||||
const noteIndex = infoTexts.findIndex((text) =>
|
||||
text.includes(
|
||||
'Some internal tool attempts failed before this final error',
|
||||
),
|
||||
);
|
||||
const stopIndex = infoTexts.findIndex((text) =>
|
||||
text.includes('Agent execution stopped: Stop reason from hook'),
|
||||
);
|
||||
const failureHintIndex = infoTexts.findIndex((text) =>
|
||||
text.includes('This request failed. Press F12 for diagnostics'),
|
||||
);
|
||||
expect(noteIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(stopIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(failureHintIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(noteIndex).toBeLessThan(stopIndex);
|
||||
expect(stopIndex).toBeLessThan(failureHintIndex);
|
||||
});
|
||||
|
||||
it('should group multiple cancelled tool call responses into a single history entry', async () => {
|
||||
|
||||
@@ -107,11 +107,6 @@ enum StreamProcessingStatus {
|
||||
Error,
|
||||
}
|
||||
|
||||
const SUPPRESSED_TOOL_ERRORS_NOTE =
|
||||
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
|
||||
const LOW_VERBOSITY_FAILURE_NOTE =
|
||||
'This request failed. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
|
||||
|
||||
function isShellToolData(data: unknown): data is ShellToolData {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return false;
|
||||
@@ -207,10 +202,6 @@ export const useGeminiStream = (
|
||||
const [retryStatus, setRetryStatus] = useState<RetryAttemptPayload | null>(
|
||||
null,
|
||||
);
|
||||
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
|
||||
const suppressedToolErrorCountRef = useRef(0);
|
||||
const suppressedToolErrorNoteShownRef = useRef(false);
|
||||
const lowVerbosityFailureNoteShownRef = useRef(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const turnCancelledRef = useRef(false);
|
||||
const activeQueryIdRef = useRef<string | null>(null);
|
||||
@@ -568,51 +559,6 @@ export const useGeminiStream = (
|
||||
}
|
||||
}, [isResponding]);
|
||||
|
||||
const maybeAddSuppressedToolErrorNote = useCallback(
|
||||
(userMessageTimestamp?: number) => {
|
||||
if (!isLowErrorVerbosity) {
|
||||
return;
|
||||
}
|
||||
if (suppressedToolErrorCountRef.current === 0) {
|
||||
return;
|
||||
}
|
||||
if (suppressedToolErrorNoteShownRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: SUPPRESSED_TOOL_ERRORS_NOTE,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
suppressedToolErrorNoteShownRef.current = true;
|
||||
},
|
||||
[addItem, isLowErrorVerbosity],
|
||||
);
|
||||
|
||||
const maybeAddLowVerbosityFailureNote = useCallback(
|
||||
(userMessageTimestamp?: number) => {
|
||||
if (!isLowErrorVerbosity || config.getDebugMode()) {
|
||||
return;
|
||||
}
|
||||
if (lowVerbosityFailureNoteShownRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: LOW_VERBOSITY_FAILURE_NOTE,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
lowVerbosityFailureNoteShownRef.current = true;
|
||||
},
|
||||
[addItem, config, isLowErrorVerbosity],
|
||||
);
|
||||
|
||||
const cancelOngoingRequest = useCallback(() => {
|
||||
if (
|
||||
streamingState !== StreamingState.Responding &&
|
||||
@@ -962,7 +908,6 @@ export const useGeminiStream = (
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
@@ -976,18 +921,9 @@ export const useGeminiStream = (
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
|
||||
setThought(null); // Reset thought when there's an error
|
||||
},
|
||||
[
|
||||
addItem,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
config,
|
||||
setThought,
|
||||
maybeAddSuppressedToolErrorNote,
|
||||
maybeAddLowVerbosityFailureNote,
|
||||
],
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config, setThought],
|
||||
);
|
||||
|
||||
const handleCitationEvent = useCallback(
|
||||
@@ -1150,7 +1086,6 @@ export const useGeminiStream = (
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
|
||||
if (contextCleared) {
|
||||
addItem(
|
||||
{
|
||||
@@ -1162,13 +1097,7 @@ export const useGeminiStream = (
|
||||
}
|
||||
setIsResponding(false);
|
||||
},
|
||||
[
|
||||
addItem,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
setIsResponding,
|
||||
maybeAddLowVerbosityFailureNote,
|
||||
],
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, setIsResponding],
|
||||
);
|
||||
|
||||
const handleAgentExecutionBlockedEvent = useCallback(
|
||||
@@ -1189,7 +1118,6 @@ export const useGeminiStream = (
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
|
||||
if (contextCleared) {
|
||||
addItem(
|
||||
{
|
||||
@@ -1200,12 +1128,7 @@ export const useGeminiStream = (
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
addItem,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
maybeAddLowVerbosityFailureNote,
|
||||
],
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
||||
);
|
||||
|
||||
const processGeminiStreamEvents = useCallback(
|
||||
@@ -1363,9 +1286,6 @@ export const useGeminiStream = (
|
||||
if (!options?.isContinuation) {
|
||||
setModelSwitchedFromQuotaError(false);
|
||||
config.setQuotaErrorOccurred(false);
|
||||
suppressedToolErrorCountRef.current = 0;
|
||||
suppressedToolErrorNoteShownRef.current = false;
|
||||
lowVerbosityFailureNoteShownRef.current = false;
|
||||
}
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
@@ -1482,7 +1402,6 @@ export const useGeminiStream = (
|
||||
) {
|
||||
// Error was handled by validation dialog, don't display again
|
||||
} else if (!isNodeError(error) || error.name !== 'AbortError') {
|
||||
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
@@ -1496,7 +1415,6 @@ export const useGeminiStream = (
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
|
||||
}
|
||||
} finally {
|
||||
if (activeQueryIdRef.current === queryId) {
|
||||
@@ -1521,8 +1439,6 @@ export const useGeminiStream = (
|
||||
startNewPrompt,
|
||||
getPromptCount,
|
||||
setThought,
|
||||
maybeAddSuppressedToolErrorNote,
|
||||
maybeAddLowVerbosityFailureNote,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1671,13 +1587,6 @@ export const useGeminiStream = (
|
||||
(t) => !t.request.isClientInitiated,
|
||||
);
|
||||
|
||||
if (isLowErrorVerbosity) {
|
||||
// Low-mode suppression applies only to model-initiated tool failures.
|
||||
suppressedToolErrorCountRef.current += geminiTools.filter(
|
||||
(tc) => tc.status === CoreToolCallStatus.Error,
|
||||
).length;
|
||||
}
|
||||
|
||||
if (geminiTools.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1688,12 +1597,10 @@ export const useGeminiStream = (
|
||||
);
|
||||
|
||||
if (stopExecutionTool && stopExecutionTool.response.error) {
|
||||
maybeAddSuppressedToolErrorNote();
|
||||
addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Agent execution stopped: ${stopExecutionTool.response.error.message}`,
|
||||
});
|
||||
maybeAddLowVerbosityFailureNote();
|
||||
setIsResponding(false);
|
||||
|
||||
const callIdsToMarkAsSubmitted = geminiTools.map(
|
||||
@@ -1799,9 +1706,6 @@ export const useGeminiStream = (
|
||||
registerBackgroundShell,
|
||||
consumeUserHint,
|
||||
config,
|
||||
isLowErrorVerbosity,
|
||||
maybeAddSuppressedToolErrorNote,
|
||||
maybeAddLowVerbosityFailureNote,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ describe('useLoadingIndicator', () => {
|
||||
initialShouldShowFocusHint: boolean = false,
|
||||
initialRetryStatus: RetryAttemptPayload | null = null,
|
||||
loadingPhrasesMode: LoadingPhrasesMode = 'all',
|
||||
initialErrorVerbosity: 'low' | 'full' = 'full',
|
||||
) => {
|
||||
let hookResult: ReturnType<typeof useLoadingIndicator>;
|
||||
function TestComponent({
|
||||
@@ -43,20 +42,17 @@ describe('useLoadingIndicator', () => {
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
mode,
|
||||
errorVerbosity,
|
||||
}: {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint?: boolean;
|
||||
retryStatus?: RetryAttemptPayload | null;
|
||||
mode?: LoadingPhrasesMode;
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
}) {
|
||||
hookResult = useLoadingIndicator({
|
||||
streamingState,
|
||||
shouldShowFocusHint: !!shouldShowFocusHint,
|
||||
retryStatus: retryStatus || null,
|
||||
loadingPhrasesMode: mode,
|
||||
errorVerbosity,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -66,7 +62,6 @@ describe('useLoadingIndicator', () => {
|
||||
shouldShowFocusHint={initialShouldShowFocusHint}
|
||||
retryStatus={initialRetryStatus}
|
||||
mode={loadingPhrasesMode}
|
||||
errorVerbosity={initialErrorVerbosity}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
@@ -80,15 +75,7 @@ describe('useLoadingIndicator', () => {
|
||||
shouldShowFocusHint?: boolean;
|
||||
retryStatus?: RetryAttemptPayload | null;
|
||||
mode?: LoadingPhrasesMode;
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
}) =>
|
||||
rerender(
|
||||
<TestComponent
|
||||
mode={loadingPhrasesMode}
|
||||
errorVerbosity={initialErrorVerbosity}
|
||||
{...newProps}
|
||||
/>,
|
||||
),
|
||||
}) => rerender(<TestComponent mode={loadingPhrasesMode} {...newProps} />),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -242,46 +229,6 @@ describe('useLoadingIndicator', () => {
|
||||
expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3');
|
||||
});
|
||||
|
||||
it('should hide low-verbosity retry status for early retry attempts', () => {
|
||||
const retryStatus = {
|
||||
model: 'gemini-pro',
|
||||
attempt: 1,
|
||||
maxAttempts: 5,
|
||||
delayMs: 1000,
|
||||
};
|
||||
const { result } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
retryStatus,
|
||||
'all',
|
||||
'low',
|
||||
);
|
||||
|
||||
expect(result.current.currentLoadingPhrase).not.toBe(
|
||||
"This is taking a bit longer, we're still on it.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should show a generic retry phrase in low error verbosity mode for later retries', () => {
|
||||
const retryStatus = {
|
||||
model: 'gemini-pro',
|
||||
attempt: 2,
|
||||
maxAttempts: 5,
|
||||
delayMs: 1000,
|
||||
};
|
||||
const { result } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
retryStatus,
|
||||
'all',
|
||||
'low',
|
||||
);
|
||||
|
||||
expect(result.current.currentLoadingPhrase).toBe(
|
||||
"This is taking a bit longer, we're still on it.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should show no phrases when loadingPhrasesMode is "off"', () => {
|
||||
const { result } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
|
||||
@@ -14,15 +14,12 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadingPhrasesMode } from '../../config/settings.js';
|
||||
|
||||
const LOW_VERBOSITY_RETRY_HINT_ATTEMPT_THRESHOLD = 2;
|
||||
|
||||
export interface UseLoadingIndicatorProps {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint: boolean;
|
||||
retryStatus: RetryAttemptPayload | null;
|
||||
loadingPhrasesMode?: LoadingPhrasesMode;
|
||||
customWittyPhrases?: string[];
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
}
|
||||
|
||||
export const useLoadingIndicator = ({
|
||||
@@ -31,7 +28,6 @@ export const useLoadingIndicator = ({
|
||||
retryStatus,
|
||||
loadingPhrasesMode,
|
||||
customWittyPhrases,
|
||||
errorVerbosity = 'full',
|
||||
}: UseLoadingIndicatorProps) => {
|
||||
const [timerResetKey, setTimerResetKey] = useState(0);
|
||||
const isTimerActive = streamingState === StreamingState.Responding;
|
||||
@@ -74,11 +70,7 @@ export const useLoadingIndicator = ({
|
||||
}, [streamingState, elapsedTimeFromTimer]);
|
||||
|
||||
const retryPhrase = retryStatus
|
||||
? errorVerbosity === 'low'
|
||||
? retryStatus.attempt >= LOW_VERBOSITY_RETRY_HINT_ATTEMPT_THRESHOLD
|
||||
? "This is taking a bit longer, we're still on it."
|
||||
: null
|
||||
: `Trying to reach ${getDisplayString(retryStatus.model)} (Attempt ${retryStatus.attempt + 1}/${retryStatus.maxAttempts})`
|
||||
? `Trying to reach ${getDisplayString(retryStatus.model)} (Attempt ${retryStatus.attempt + 1}/${retryStatus.maxAttempts})`
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,8 +14,7 @@ import {
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderHook, mockSettings } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import {
|
||||
type Config,
|
||||
type FallbackModelHandler,
|
||||
@@ -30,12 +29,6 @@ import {
|
||||
ModelNotFoundError,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
getG1CreditBalance,
|
||||
shouldAutoUseCredits,
|
||||
shouldShowOverageMenu,
|
||||
shouldShowEmptyWalletMenu,
|
||||
logBillingEvent,
|
||||
G1_CREDIT_TYPE,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useQuotaAndFallback } from './useQuotaAndFallback.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -44,19 +37,6 @@ import { MessageType } from '../types.js';
|
||||
// Use a type alias for SpyInstance as it's not directly exported
|
||||
type SpyInstance = ReturnType<typeof vi.spyOn>;
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getG1CreditBalance: vi.fn(),
|
||||
shouldAutoUseCredits: vi.fn(),
|
||||
shouldShowOverageMenu: vi.fn(),
|
||||
shouldShowEmptyWalletMenu: vi.fn(),
|
||||
logBillingEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('useQuotaAndFallback', () => {
|
||||
let mockConfig: Config;
|
||||
let mockHistoryManager: UseHistoryManagerReturn;
|
||||
@@ -94,16 +74,10 @@ describe('useQuotaAndFallback', () => {
|
||||
vi.spyOn(mockConfig, 'setModel');
|
||||
vi.spyOn(mockConfig, 'setActiveModel');
|
||||
vi.spyOn(mockConfig, 'activateFallbackMode');
|
||||
|
||||
// Mock billing utility functions
|
||||
vi.mocked(getG1CreditBalance).mockReturnValue(0);
|
||||
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
|
||||
vi.mocked(shouldShowOverageMenu).mockReturnValue(false);
|
||||
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should register a fallback handler on initialization', () => {
|
||||
@@ -114,8 +88,6 @@ describe('useQuotaAndFallback', () => {
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -137,8 +109,6 @@ describe('useQuotaAndFallback', () => {
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -161,67 +131,6 @@ describe('useQuotaAndFallback', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should auto-retry transient capacity failures in low verbosity mode', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
errorVerbosity: 'low',
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
const intent = await handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new RetryableQuotaError('retryable quota', mockGoogleApiError, 5),
|
||||
);
|
||||
|
||||
expect(intent).toBe('retry_once');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
expect(mockSetModelSwitchedFromQuotaError).not.toHaveBeenCalledWith(true);
|
||||
expect(mockConfig.setQuotaErrorOccurred).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should still prompt for terminal quota in low verbosity mode', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
errorVerbosity: 'low',
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
act(() => {
|
||||
promise = handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new TerminalQuotaError('pro quota', mockGoogleApiError),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.proQuotaRequest).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('retry_later');
|
||||
});
|
||||
await promise!;
|
||||
});
|
||||
|
||||
describe('Interactive Fallback', () => {
|
||||
it('should set an interactive request for a terminal quota error', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
@@ -231,8 +140,6 @@ describe('useQuotaAndFallback', () => {
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -286,8 +193,6 @@ describe('useQuotaAndFallback', () => {
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -327,8 +232,6 @@ describe('useQuotaAndFallback', () => {
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -361,8 +264,6 @@ describe('useQuotaAndFallback', () => {
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -429,8 +330,6 @@ describe('useQuotaAndFallback', () => {
|
||||
setModelSwitchedFromQuotaError:
|
||||
mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -486,8 +385,6 @@ describe('useQuotaAndFallback', () => {
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -533,8 +430,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -569,243 +464,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
});
|
||||
});
|
||||
|
||||
describe('G1 AI Credits Flow', () => {
|
||||
const mockPaidTier = {
|
||||
id: UserTierId.STANDARD,
|
||||
userTier: UserTierId.STANDARD,
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: G1_CREDIT_TYPE,
|
||||
creditAmount: '100',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Default to having credits
|
||||
vi.mocked(getG1CreditBalance).mockReturnValue(100);
|
||||
});
|
||||
|
||||
it('should fall through to ProQuotaDialog if credits are already active (strategy=always)', async () => {
|
||||
// If shouldAutoUseCredits is true, credits were already active on the
|
||||
// failed request — they didn't help. Fall through to ProQuotaDialog
|
||||
// so the user can downgrade to Flash instead of retrying infinitely.
|
||||
vi.mocked(shouldAutoUseCredits).mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.STANDARD,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: mockPaidTier,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
const error = new TerminalQuotaError(
|
||||
'pro quota',
|
||||
mockGoogleApiError,
|
||||
1000 * 60 * 5,
|
||||
);
|
||||
|
||||
const intentPromise = handler(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
'gemini-flash',
|
||||
error,
|
||||
);
|
||||
|
||||
// Since credits didn't help, the ProQuotaDialog should be shown
|
||||
await waitFor(() => {
|
||||
expect(result.current.proQuotaRequest).not.toBeNull();
|
||||
});
|
||||
|
||||
// Resolve it to verify the flow completes
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('stop');
|
||||
});
|
||||
|
||||
const intent = await intentPromise;
|
||||
expect(intent).toBe('stop');
|
||||
});
|
||||
|
||||
it('should show overage menu if balance > 0 and not auto-using', async () => {
|
||||
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
|
||||
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.STANDARD,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: mockPaidTier,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
act(() => {
|
||||
promise = handler(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
'gemini-flash',
|
||||
new TerminalQuotaError('pro quota', mockGoogleApiError),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.overageMenuRequest).not.toBeNull();
|
||||
expect(result.current.overageMenuRequest?.creditBalance).toBe(100);
|
||||
expect(logBillingEvent).toHaveBeenCalled();
|
||||
|
||||
// Simulate choosing "Use Credits"
|
||||
await act(async () => {
|
||||
result.current.handleOverageMenuChoice('use_credits');
|
||||
await promise!;
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry_with_credits');
|
||||
});
|
||||
|
||||
it('should handle use_fallback from overage menu', async () => {
|
||||
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
|
||||
vi.mocked(shouldShowOverageMenu).mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.STANDARD,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: mockPaidTier,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
act(() => {
|
||||
promise = handler(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
'gemini-flash',
|
||||
new TerminalQuotaError('pro quota', mockGoogleApiError),
|
||||
);
|
||||
});
|
||||
|
||||
// Simulate choosing "Switch to fallback"
|
||||
await act(async () => {
|
||||
result.current.handleOverageMenuChoice('use_fallback');
|
||||
await promise!;
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry_always');
|
||||
});
|
||||
|
||||
it('should show empty wallet menu if balance is 0', async () => {
|
||||
vi.mocked(getG1CreditBalance).mockReturnValue(0);
|
||||
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
|
||||
vi.mocked(shouldShowOverageMenu).mockReturnValue(false);
|
||||
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.STANDARD,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: { ...mockPaidTier, availableCredits: [] },
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
act(() => {
|
||||
promise = handler(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
'gemini-flash',
|
||||
new TerminalQuotaError('pro quota', mockGoogleApiError),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.emptyWalletRequest).not.toBeNull();
|
||||
expect(logBillingEvent).toHaveBeenCalled();
|
||||
|
||||
// Simulate choosing "Stop"
|
||||
await act(async () => {
|
||||
result.current.handleEmptyWalletChoice('stop');
|
||||
await promise!;
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('stop');
|
||||
});
|
||||
|
||||
it('should add info message to history when get_credits is selected', async () => {
|
||||
vi.mocked(getG1CreditBalance).mockReturnValue(0);
|
||||
vi.mocked(shouldAutoUseCredits).mockReturnValue(false);
|
||||
vi.mocked(shouldShowOverageMenu).mockReturnValue(false);
|
||||
vi.mocked(shouldShowEmptyWalletMenu).mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.STANDARD,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: { ...mockPaidTier, availableCredits: [] },
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
act(() => {
|
||||
promise = handler(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
'gemini-flash',
|
||||
new TerminalQuotaError('pro quota', mockGoogleApiError),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.emptyWalletRequest).not.toBeNull();
|
||||
|
||||
// Simulate choosing "Get AI Credits"
|
||||
await act(async () => {
|
||||
result.current.handleEmptyWalletChoice('get_credits');
|
||||
await promise!;
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('stop');
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('few minutes'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleProQuotaChoice', () => {
|
||||
it('should do nothing if there is no pending pro quota request', () => {
|
||||
const { result } = renderHook(() =>
|
||||
@@ -815,8 +473,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -835,8 +491,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -868,8 +522,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -914,8 +566,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -952,8 +602,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -998,8 +646,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1015,8 +661,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1059,8 +703,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1103,8 +745,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1135,8 +775,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1167,8 +805,6 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -16,9 +16,7 @@ import {
|
||||
type UserTierId,
|
||||
VALID_GEMINI_MODELS,
|
||||
isProModel,
|
||||
isOverageEligibleModel,
|
||||
getDisplayString,
|
||||
type GeminiUserTier,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -26,55 +24,30 @@ import { MessageType } from '../types.js';
|
||||
import {
|
||||
type ProQuotaDialogRequest,
|
||||
type ValidationDialogRequest,
|
||||
type OverageMenuDialogRequest,
|
||||
type OverageMenuIntent,
|
||||
type EmptyWalletDialogRequest,
|
||||
type EmptyWalletIntent,
|
||||
} from '../contexts/UIStateContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { handleCreditsFlow } from './creditsFlowHandler.js';
|
||||
|
||||
interface UseQuotaAndFallbackArgs {
|
||||
config: Config;
|
||||
historyManager: UseHistoryManagerReturn;
|
||||
userTier: UserTierId | undefined;
|
||||
paidTier: GeminiUserTier | null | undefined;
|
||||
settings: LoadedSettings;
|
||||
setModelSwitchedFromQuotaError: (value: boolean) => void;
|
||||
onShowAuthSelection: () => void;
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
}
|
||||
|
||||
export function useQuotaAndFallback({
|
||||
config,
|
||||
historyManager,
|
||||
userTier,
|
||||
paidTier,
|
||||
settings,
|
||||
setModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection,
|
||||
errorVerbosity = 'full',
|
||||
}: UseQuotaAndFallbackArgs) {
|
||||
const [proQuotaRequest, setProQuotaRequest] =
|
||||
useState<ProQuotaDialogRequest | null>(null);
|
||||
const [validationRequest, setValidationRequest] =
|
||||
useState<ValidationDialogRequest | null>(null);
|
||||
// G1 AI Credits dialog states
|
||||
const [overageMenuRequest, setOverageMenuRequest] =
|
||||
useState<OverageMenuDialogRequest | null>(null);
|
||||
const [emptyWalletRequest, setEmptyWalletRequest] =
|
||||
useState<EmptyWalletDialogRequest | null>(null);
|
||||
const isDialogPending = useRef(false);
|
||||
const isValidationPending = useRef(false);
|
||||
|
||||
// Initial overage strategy from settings; runtime value read from config at call time.
|
||||
const initialOverageStrategy =
|
||||
(settings.merged.billing?.overageStrategy as
|
||||
| 'ask'
|
||||
| 'always'
|
||||
| 'never'
|
||||
| undefined) ?? 'ask';
|
||||
|
||||
// Set up Flash fallback handler
|
||||
useEffect(() => {
|
||||
const fallbackHandler: FallbackModelHandler = async (
|
||||
@@ -90,52 +63,12 @@ export function useQuotaAndFallback({
|
||||
const usageLimitReachedModel = isProModel(failedModel)
|
||||
? 'all Pro models'
|
||||
: failedModel;
|
||||
|
||||
if (error instanceof TerminalQuotaError) {
|
||||
isTerminalQuotaError = true;
|
||||
|
||||
const isInsufficientCredits = error.isInsufficientCredits;
|
||||
|
||||
// G1 Credits Flow: Only apply if user has a tier that supports credits
|
||||
// (paidTier?.availableCredits indicates the user is a G1 subscriber)
|
||||
// Skip if the error explicitly says they have insufficient credits (e.g. they
|
||||
// just exhausted them or zero balance cache is delayed).
|
||||
if (
|
||||
!isInsufficientCredits &&
|
||||
paidTier?.availableCredits &&
|
||||
isOverageEligibleModel(failedModel)
|
||||
) {
|
||||
const resetTime = error.retryDelayMs
|
||||
? getResetTimeMessage(error.retryDelayMs)
|
||||
: undefined;
|
||||
|
||||
const overageStrategy =
|
||||
config.getBillingSettings().overageStrategy ??
|
||||
initialOverageStrategy;
|
||||
|
||||
const creditsResult = await handleCreditsFlow({
|
||||
config,
|
||||
paidTier,
|
||||
overageStrategy,
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
usageLimitReachedModel,
|
||||
resetTime,
|
||||
historyManager,
|
||||
setModelSwitchedFromQuotaError,
|
||||
isDialogPending,
|
||||
setOverageMenuRequest,
|
||||
setEmptyWalletRequest,
|
||||
});
|
||||
if (creditsResult) return creditsResult;
|
||||
}
|
||||
|
||||
// Default: Show existing ProQuotaDialog (for overageStrategy: 'never' or non-G1 users)
|
||||
// Common part of the message for both tiers
|
||||
const messageLines = [
|
||||
`Usage limit reached for ${usageLimitReachedModel}.`,
|
||||
error.retryDelayMs
|
||||
? `Access resets at ${getResetTimeMessage(error.retryDelayMs)}.`
|
||||
: null,
|
||||
error.retryDelayMs ? getResetTimeMessage(error.retryDelayMs) : null,
|
||||
`/stats model for usage details`,
|
||||
`/model to switch models.`,
|
||||
contentGeneratorConfig?.authType === AuthType.LOGIN_WITH_GOOGLE
|
||||
@@ -167,16 +100,6 @@ export function useQuotaAndFallback({
|
||||
message = messageLines.join('\n');
|
||||
}
|
||||
|
||||
// In low verbosity mode, auto-retry transient capacity failures
|
||||
// without interrupting with a dialog.
|
||||
if (
|
||||
errorVerbosity === 'low' &&
|
||||
!isTerminalQuotaError &&
|
||||
!isModelNotFoundError
|
||||
) {
|
||||
return 'retry_once';
|
||||
}
|
||||
|
||||
setModelSwitchedFromQuotaError(true);
|
||||
config.setQuotaErrorOccurred(true);
|
||||
|
||||
@@ -203,17 +126,7 @@ export function useQuotaAndFallback({
|
||||
};
|
||||
|
||||
config.setFallbackModelHandler(fallbackHandler);
|
||||
}, [
|
||||
config,
|
||||
historyManager,
|
||||
userTier,
|
||||
paidTier,
|
||||
settings,
|
||||
initialOverageStrategy,
|
||||
setModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection,
|
||||
errorVerbosity,
|
||||
]);
|
||||
}, [config, historyManager, userTier, setModelSwitchedFromQuotaError]);
|
||||
|
||||
// Set up validation handler for 403 VALIDATION_REQUIRED errors
|
||||
useEffect(() => {
|
||||
@@ -291,38 +204,11 @@ export function useQuotaAndFallback({
|
||||
[validationRequest, onShowAuthSelection],
|
||||
);
|
||||
|
||||
// Handler for overage menu dialog (G1 AI Credits flow)
|
||||
const handleOverageMenuChoice = useCallback(
|
||||
(choice: OverageMenuIntent) => {
|
||||
if (!overageMenuRequest) return;
|
||||
|
||||
overageMenuRequest.resolve(choice);
|
||||
// State will be cleared by the effect callback after the promise resolves
|
||||
},
|
||||
[overageMenuRequest],
|
||||
);
|
||||
|
||||
// Handler for empty wallet dialog (G1 AI Credits flow)
|
||||
const handleEmptyWalletChoice = useCallback(
|
||||
(choice: EmptyWalletIntent) => {
|
||||
if (!emptyWalletRequest) return;
|
||||
|
||||
emptyWalletRequest.resolve(choice);
|
||||
// State will be cleared by the effect callback after the promise resolves
|
||||
},
|
||||
[emptyWalletRequest],
|
||||
);
|
||||
|
||||
return {
|
||||
proQuotaRequest,
|
||||
handleProQuotaChoice,
|
||||
validationRequest,
|
||||
handleValidationChoice,
|
||||
// G1 AI Credits
|
||||
overageMenuRequest,
|
||||
handleOverageMenuChoice,
|
||||
emptyWalletRequest,
|
||||
handleEmptyWalletChoice,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -335,5 +221,5 @@ function getResetTimeMessage(delayMs: number): string {
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
return timeFormatter.format(resetDate);
|
||||
return `Access resets at ${timeFormatter.format(resetDate)}.`;
|
||||
}
|
||||
|
||||
@@ -384,10 +384,6 @@ describe('useShellCompletion utilities', () => {
|
||||
// Very basic sanity check: common commands should be found
|
||||
if (process.platform !== 'win32') {
|
||||
expect(results).toContain('ls');
|
||||
} else {
|
||||
expect(results).toContain('dir');
|
||||
expect(results).toContain('cls');
|
||||
expect(results).toContain('copy');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -402,12 +398,7 @@ describe('useShellCompletion utilities', () => {
|
||||
it('should handle empty PATH', async () => {
|
||||
vi.stubEnv('PATH', '');
|
||||
const results = await scanPathExecutables();
|
||||
if (process.platform === 'win32') {
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results).toContain('dir');
|
||||
} else {
|
||||
expect(results).toEqual([]);
|
||||
}
|
||||
expect(results).toEqual([]);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -196,60 +196,6 @@ export async function scanPathExecutables(
|
||||
const seen = new Set<string>();
|
||||
const executables: string[] = [];
|
||||
|
||||
// Add Windows shell built-ins
|
||||
if (isWindows) {
|
||||
const builtins = [
|
||||
'assoc',
|
||||
'break',
|
||||
'call',
|
||||
'cd',
|
||||
'chcp',
|
||||
'chdir',
|
||||
'cls',
|
||||
'color',
|
||||
'copy',
|
||||
'date',
|
||||
'del',
|
||||
'dir',
|
||||
'echo',
|
||||
'endlocal',
|
||||
'erase',
|
||||
'exit',
|
||||
'for',
|
||||
'ftype',
|
||||
'goto',
|
||||
'if',
|
||||
'md',
|
||||
'mkdir',
|
||||
'mklink',
|
||||
'move',
|
||||
'path',
|
||||
'pause',
|
||||
'popd',
|
||||
'prompt',
|
||||
'pushd',
|
||||
'rd',
|
||||
'rem',
|
||||
'ren',
|
||||
'rename',
|
||||
'rmdir',
|
||||
'set',
|
||||
'setlocal',
|
||||
'shift',
|
||||
'start',
|
||||
'time',
|
||||
'title',
|
||||
'type',
|
||||
'ver',
|
||||
'verify',
|
||||
'vol',
|
||||
];
|
||||
for (const builtin of builtins) {
|
||||
seen.add(builtin);
|
||||
executables.push(builtin);
|
||||
}
|
||||
}
|
||||
|
||||
const dirResults = await Promise.all(
|
||||
dirs.map(async (dir) => {
|
||||
if (signal?.aborted) return [];
|
||||
@@ -419,10 +365,16 @@ export async function resolvePathCompletions(
|
||||
export interface UseShellCompletionProps {
|
||||
/** Whether shell completion is active. */
|
||||
enabled: boolean;
|
||||
/** The current line text. */
|
||||
line: string;
|
||||
/** The current cursor column. */
|
||||
cursorCol: number;
|
||||
/** The partial query string (the token under the cursor). */
|
||||
query: string;
|
||||
/** Whether the token is in command position (first word). */
|
||||
isCommandPosition: boolean;
|
||||
/** The full list of parsed tokens */
|
||||
tokens: string[];
|
||||
/** The cursor index in the full list of parsed tokens */
|
||||
cursorIndex: number;
|
||||
/** The root command token */
|
||||
commandToken: string;
|
||||
/** The current working directory for path resolution. */
|
||||
cwd: string;
|
||||
/** Callback to set suggestions on the parent state. */
|
||||
@@ -431,53 +383,33 @@ export interface UseShellCompletionProps {
|
||||
setIsLoadingSuggestions: (isLoading: boolean) => void;
|
||||
}
|
||||
|
||||
export interface UseShellCompletionReturn {
|
||||
completionStart: number;
|
||||
completionEnd: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
const EMPTY_TOKENS: string[] = [];
|
||||
|
||||
export function useShellCompletion({
|
||||
enabled,
|
||||
line,
|
||||
cursorCol,
|
||||
query,
|
||||
isCommandPosition,
|
||||
tokens,
|
||||
cursorIndex,
|
||||
commandToken,
|
||||
cwd,
|
||||
setSuggestions,
|
||||
setIsLoadingSuggestions,
|
||||
}: UseShellCompletionProps): UseShellCompletionReturn {
|
||||
const pathCachePromiseRef = useRef<Promise<string[]> | null>(null);
|
||||
}: UseShellCompletionProps): void {
|
||||
const pathCacheRef = useRef<string[] | null>(null);
|
||||
const pathEnvRef = useRef<string>(process.env['PATH'] ?? '');
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const tokenInfo = useMemo(
|
||||
() => (enabled ? getTokenAtCursor(line, cursorCol) : null),
|
||||
[enabled, line, cursorCol],
|
||||
);
|
||||
|
||||
const {
|
||||
token: query = '',
|
||||
start: completionStart = -1,
|
||||
end: completionEnd = -1,
|
||||
isFirstToken: isCommandPosition = false,
|
||||
tokens = EMPTY_TOKENS,
|
||||
cursorIndex = -1,
|
||||
commandToken = '',
|
||||
} = tokenInfo || {};
|
||||
|
||||
// Invalidate PATH cache when $PATH changes
|
||||
useEffect(() => {
|
||||
const currentPath = process.env['PATH'] ?? '';
|
||||
if (currentPath !== pathEnvRef.current) {
|
||||
pathCachePromiseRef.current = null;
|
||||
pathCacheRef.current = null;
|
||||
pathEnvRef.current = currentPath;
|
||||
}
|
||||
});
|
||||
|
||||
const performCompletion = useCallback(async () => {
|
||||
if (!enabled || !tokenInfo) {
|
||||
if (!enabled) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
@@ -502,25 +434,15 @@ export function useShellCompletion({
|
||||
if (isCommandPosition) {
|
||||
setIsLoadingSuggestions(true);
|
||||
|
||||
if (!pathCachePromiseRef.current) {
|
||||
// We don't pass the signal here because we want the cache to finish
|
||||
// even if this specific completion request is aborted.
|
||||
pathCachePromiseRef.current = scanPathExecutables();
|
||||
if (!pathCacheRef.current) {
|
||||
pathCacheRef.current = await scanPathExecutables(signal);
|
||||
}
|
||||
|
||||
const executables = await pathCachePromiseRef.current;
|
||||
if (signal.aborted) return;
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
results = executables
|
||||
results = pathCacheRef.current
|
||||
.filter((cmd) => cmd.toLowerCase().startsWith(queryLower))
|
||||
.sort((a, b) => {
|
||||
// Prioritize shorter commands as they are likely common built-ins
|
||||
if (a.length !== b.length) {
|
||||
return a.length - b.length;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.slice(0, MAX_SHELL_SUGGESTIONS)
|
||||
.map((cmd) => ({
|
||||
label: cmd,
|
||||
@@ -579,7 +501,6 @@ export function useShellCompletion({
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
tokenInfo,
|
||||
query,
|
||||
isCommandPosition,
|
||||
tokens,
|
||||
@@ -590,17 +511,13 @@ export function useShellCompletion({
|
||||
setIsLoadingSuggestions,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
abortRef.current?.abort();
|
||||
setSuggestions([]);
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
|
||||
|
||||
// Debounced effect to trigger completion
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!enabled) {
|
||||
setSuggestions([]);
|
||||
setIsLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
@@ -616,7 +533,7 @@ export function useShellCompletion({
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [enabled, performCompletion]);
|
||||
}, [enabled, performCompletion, setSuggestions, setIsLoadingSuggestions]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(
|
||||
@@ -628,10 +545,4 @@ export function useShellCompletion({
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
completionStart,
|
||||
completionEnd,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -359,10 +359,14 @@ describe('useToolScheduler', () => {
|
||||
} as ToolCallsUpdateMessage);
|
||||
});
|
||||
|
||||
const [toolCalls] = result.current;
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect(toolCalls[0].request.callId).toBe('call-root');
|
||||
expect(toolCalls[0].schedulerId).toBe(ROOT_SCHEDULER_ID);
|
||||
let [toolCalls] = result.current;
|
||||
expect(toolCalls).toHaveLength(2);
|
||||
expect(
|
||||
toolCalls.find((t) => t.request.callId === 'call-root')?.schedulerId,
|
||||
).toBe(ROOT_SCHEDULER_ID);
|
||||
expect(
|
||||
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
|
||||
).toBe('subagent-1');
|
||||
|
||||
// 2. Call setToolCallsForDisplay (e.g., simulate a manual update or clear)
|
||||
act(() => {
|
||||
@@ -373,45 +377,34 @@ describe('useToolScheduler', () => {
|
||||
});
|
||||
|
||||
// 3. Verify that tools are still present and maintain their scheduler IDs
|
||||
const [toolCalls2] = result.current;
|
||||
expect(toolCalls2).toHaveLength(1);
|
||||
expect(toolCalls2[0].responseSubmittedToGemini).toBe(true);
|
||||
expect(toolCalls2[0].schedulerId).toBe(ROOT_SCHEDULER_ID);
|
||||
});
|
||||
// The internal map should have been re-grouped.
|
||||
[toolCalls] = result.current;
|
||||
expect(toolCalls).toHaveLength(2);
|
||||
expect(toolCalls.every((t) => t.responseSubmittedToGemini)).toBe(true);
|
||||
|
||||
it('ignores TOOL_CALLS_UPDATE from non-root schedulers', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useToolScheduler(
|
||||
vi.fn().mockResolvedValue(undefined),
|
||||
mockConfig,
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
const updatedRoot = toolCalls.find((t) => t.request.callId === 'call-root');
|
||||
const updatedSub = toolCalls.find((t) => t.request.callId === 'call-sub');
|
||||
|
||||
const subagentCall = {
|
||||
status: CoreToolCallStatus.Executing as const,
|
||||
request: {
|
||||
callId: 'call-sub',
|
||||
name: 'test',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'p1',
|
||||
},
|
||||
tool: createMockTool(),
|
||||
invocation: createMockInvocation(),
|
||||
schedulerId: 'subagent-1',
|
||||
};
|
||||
expect(updatedRoot?.schedulerId).toBe(ROOT_SCHEDULER_ID);
|
||||
expect(updatedSub?.schedulerId).toBe('subagent-1');
|
||||
|
||||
// 4. Verify that a subsequent update to ONE scheduler doesn't wipe the other
|
||||
act(() => {
|
||||
void mockMessageBus.publish({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [subagentCall],
|
||||
schedulerId: 'subagent-1',
|
||||
toolCalls: [{ ...callRoot, status: CoreToolCallStatus.Executing }],
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
} as ToolCallsUpdateMessage);
|
||||
});
|
||||
|
||||
const [toolCalls] = result.current;
|
||||
expect(toolCalls).toHaveLength(0);
|
||||
[toolCalls] = result.current;
|
||||
expect(toolCalls).toHaveLength(2);
|
||||
expect(
|
||||
toolCalls.find((t) => t.request.callId === 'call-root')?.status,
|
||||
).toBe(CoreToolCallStatus.Executing);
|
||||
expect(
|
||||
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
|
||||
).toBe('subagent-1');
|
||||
});
|
||||
|
||||
it('adapts success/error status to executing when a tail call is present', () => {
|
||||
|
||||
@@ -115,12 +115,6 @@ export function useToolScheduler(
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: ToolCallsUpdateMessage) => {
|
||||
// Only process updates for the root scheduler.
|
||||
// Subagent internal tools should not be displayed in the main tool list.
|
||||
if (event.schedulerId !== ROOT_SCHEDULER_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update output timer for UI spinners (Side Effect)
|
||||
const hasExecuting = event.toolCalls.some(
|
||||
(tc) =>
|
||||
|
||||
@@ -102,8 +102,6 @@ export interface IndividualToolCallDisplay {
|
||||
description: string;
|
||||
resultDisplay: ToolResultDisplay | undefined;
|
||||
status: CoreToolCallStatus;
|
||||
// True when the tool was initiated directly by the user (slash/@/shell flows).
|
||||
isClientInitiated?: boolean;
|
||||
confirmationDetails: SerializableConfirmationDetails | undefined;
|
||||
renderOutputAsMarkdown?: boolean;
|
||||
ptyId?: number;
|
||||
@@ -203,7 +201,6 @@ export type HistoryItemStats = HistoryItemQuotaBase & {
|
||||
type: 'stats';
|
||||
duration: string;
|
||||
quotas?: RetrieveUserQuotaResponse;
|
||||
creditBalance?: number;
|
||||
};
|
||||
|
||||
export type HistoryItemModelStats = HistoryItemQuotaBase & {
|
||||
@@ -340,7 +337,6 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
|
||||
isPersistentDisabled: boolean;
|
||||
}
|
||||
>;
|
||||
errors: Record<string, string>;
|
||||
blockedServers: Array<{ name: string; extensionName: string }>;
|
||||
discoveryInProgress: boolean;
|
||||
connectingServers: string[];
|
||||
|
||||
@@ -5,36 +5,17 @@
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
||||
import { spawn, exec, execSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import { start_sandbox } from './sandbox.js';
|
||||
import {
|
||||
FatalSandboxError,
|
||||
type SandboxConfig,
|
||||
SandboxOrchestrator,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
const { mockedHomedir, mockedGetContainerPath, mockSpawnAsync } = vi.hoisted(
|
||||
() => ({
|
||||
mockedHomedir: vi.fn().mockReturnValue('/home/user'),
|
||||
mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p),
|
||||
mockSpawnAsync: vi.fn().mockImplementation(async (cmd, args) => {
|
||||
if (cmd === 'id' && args?.[0] === '-u')
|
||||
return { stdout: '1000', stderr: '' };
|
||||
if (cmd === 'id' && args?.[0] === '-g')
|
||||
return { stdout: '1000', stderr: '' };
|
||||
if (cmd === 'getconf') return { stdout: '/tmp/cache', stderr: '' };
|
||||
if (cmd === 'docker' && args?.[0] === 'ps')
|
||||
return { stdout: 'existing-container', stderr: '' };
|
||||
if (cmd === 'docker' && args?.[0] === 'network')
|
||||
return { stdout: '', stderr: '' };
|
||||
if (cmd === 'curl') return { stdout: 'ok', stderr: '' };
|
||||
return { stdout: '', stderr: '' };
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const { mockedHomedir, mockedGetContainerPath } = vi.hoisted(() => ({
|
||||
mockedHomedir: vi.fn().mockReturnValue('/home/user'),
|
||||
mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p),
|
||||
}));
|
||||
|
||||
vi.mock('./sandboxUtils.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./sandboxUtils.js')>();
|
||||
@@ -47,6 +28,32 @@ vi.mock('./sandboxUtils.js', async (importOriginal) => {
|
||||
vi.mock('node:child_process');
|
||||
vi.mock('node:os');
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:util')>();
|
||||
return {
|
||||
...actual,
|
||||
promisify: (fn: (...args: unknown[]) => unknown) => {
|
||||
if (fn === exec) {
|
||||
return async (cmd: string) => {
|
||||
if (cmd === 'id -u' || cmd === 'id -g') {
|
||||
return { stdout: '1000', stderr: '' };
|
||||
}
|
||||
if (cmd.includes('curl')) {
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
if (cmd.includes('getconf DARWIN_USER_CACHE_DIR')) {
|
||||
return { stdout: '/tmp/cache', stderr: '' };
|
||||
}
|
||||
if (cmd.includes('ps -a --format')) {
|
||||
return { stdout: 'existing-container', stderr: '' };
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
};
|
||||
}
|
||||
return actual.promisify(fn);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -61,17 +68,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
SandboxOrchestrator: {
|
||||
ensureSandboxImageIsPresent: vi.fn().mockResolvedValue(true),
|
||||
getContainerRunArgs: vi
|
||||
.fn()
|
||||
.mockResolvedValue(['run', '-i', '--rm', '--init']),
|
||||
getSeatbeltArgs: vi.fn().mockReturnValue(['-D', 'TARGET_DIR=/tmp']),
|
||||
FatalSandboxError: class extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'FatalSandboxError';
|
||||
}
|
||||
},
|
||||
spawnAsync: mockSpawnAsync,
|
||||
LOCAL_DEV_SANDBOX_IMAGE_NAME: 'gemini-cli-sandbox',
|
||||
SANDBOX_NETWORK_NAME: 'gemini-cli-sandbox',
|
||||
SANDBOX_PROXY_NAME: 'gemini-cli-sandbox-proxy',
|
||||
GEMINI_DIR: '.gemini',
|
||||
homedir: mockedHomedir,
|
||||
};
|
||||
});
|
||||
@@ -104,27 +107,9 @@ describe('sandbox', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
|
||||
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
|
||||
|
||||
// Default mockSpawnAsync implementation
|
||||
mockSpawnAsync.mockImplementation(async (cmd, args) => {
|
||||
if (cmd === 'id' && args?.[0] === '-u')
|
||||
return { stdout: '1000', stderr: '' };
|
||||
if (cmd === 'id' && args?.[0] === '-g')
|
||||
return { stdout: '1000', stderr: '' };
|
||||
if (cmd === 'getconf') return { stdout: '/tmp/cache', stderr: '' };
|
||||
if (cmd === 'docker' && args?.[0] === 'ps')
|
||||
return { stdout: 'existing-container', stderr: '' };
|
||||
if (cmd === 'docker' && args?.[0] === 'network')
|
||||
return { stdout: '', stderr: '' };
|
||||
if (cmd === 'curl') return { stdout: 'ok', stderr: '' };
|
||||
return { stdout: '', stderr: '' };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Note: We intentionally avoid vi.restoreAllMocks() here because it clears the top-level
|
||||
// vi.mock('@google/gemini-cli-core') entirely, making its un-exported constants (like GEMINI_DIR)
|
||||
// undefined in subsequent tests. Call counts are still reset by clearAllMocks() in beforeEach.
|
||||
process.env = originalEnv;
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
@@ -137,27 +122,30 @@ describe('sandbox', () => {
|
||||
image: 'some-image',
|
||||
};
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||
// @ts-expect-error - mocking readonly property
|
||||
interface MockProcess extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
const mockSpawnProcess = new EventEmitter() as MockProcess;
|
||||
mockSpawnProcess.stdout = new EventEmitter();
|
||||
// @ts-expect-error - mocking readonly property
|
||||
mockSpawnProcess.stderr = new EventEmitter();
|
||||
// @ts-expect-error - mocking readonly property
|
||||
mockSpawnProcess.pid = 123;
|
||||
vi.mocked(spawn).mockReturnValue(
|
||||
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
|
||||
);
|
||||
|
||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
// Use setImmediate to ensure the promise has had a chance to register handlers
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
mockSpawnProcess.emit('close', 0);
|
||||
setTimeout(() => {
|
||||
mockSpawnProcess.emit('close', 0);
|
||||
}, 10);
|
||||
|
||||
await expect(promise).resolves.toBe(0);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'sandbox-exec',
|
||||
expect.arrayContaining(['-D', expect.stringContaining('TARGET_DIR=')]),
|
||||
expect.arrayContaining([
|
||||
'-f',
|
||||
expect.stringContaining('sandbox-macos-permissive-open.sb'),
|
||||
]),
|
||||
expect.objectContaining({ stdio: 'inherit' }),
|
||||
);
|
||||
});
|
||||
@@ -179,155 +167,152 @@ describe('sandbox', () => {
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||
// @ts-expect-error - mocking readonly property
|
||||
mockSpawnProcess.stdout = new EventEmitter();
|
||||
// @ts-expect-error - mocking readonly property
|
||||
mockSpawnProcess.stderr = new EventEmitter();
|
||||
// @ts-expect-error - mocking readonly property
|
||||
mockSpawnProcess.pid = 123;
|
||||
// Mock image check to return true (image exists)
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce((_cmd, args) => {
|
||||
if (args && args[0] === 'images') {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
|
||||
}
|
||||
return new EventEmitter() as unknown as ReturnType<typeof spawn>; // fallback
|
||||
});
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
>;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setImmediate(() => cb(0));
|
||||
setTimeout(() => cb(0), 10);
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||
vi.mocked(spawn).mockImplementationOnce((cmd, args) => {
|
||||
if (cmd === 'docker' && args && args[0] === 'run') {
|
||||
return mockSpawnProcess;
|
||||
}
|
||||
return new EventEmitter() as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
await expect(promise).resolves.toBe(0);
|
||||
expect(
|
||||
SandboxOrchestrator.ensureSandboxImageIsPresent,
|
||||
).toHaveBeenCalled();
|
||||
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.any(Array),
|
||||
expect.arrayContaining(['run', '-i', '--rm', '--init']),
|
||||
expect.objectContaining({ stdio: 'inherit' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should inject custom flags from SANDBOX_FLAGS env var', async () => {
|
||||
process.env['SANDBOX_FLAGS'] =
|
||||
'--security-opt label=disable --env FOO=bar';
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setImmediate(() => cb(0));
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||
|
||||
await start_sandbox(config);
|
||||
|
||||
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
'--security-opt label=disable --env FOO=bar',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should inject custom flags from config (settings)', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
flags: '--privileged',
|
||||
};
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setImmediate(() => cb(0));
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||
|
||||
await start_sandbox(config);
|
||||
|
||||
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should expand multiple environment variables in sandbox flags', async () => {
|
||||
process.env['VAR1'] = 'val1';
|
||||
process.env['VAR2'] = 'val2';
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
flags: '--env V1=$VAR1 --env V2=${VAR2}',
|
||||
};
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setImmediate(() => cb(0));
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||
|
||||
await start_sandbox(config);
|
||||
|
||||
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle quoted strings in sandbox flags', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
flags: '--label "description=multi word label" --env \'FOO=bar baz\'',
|
||||
};
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setImmediate(() => cb(0));
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||
|
||||
await start_sandbox(config);
|
||||
|
||||
expect(SandboxOrchestrator.getContainerRunArgs).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if image is missing', async () => {
|
||||
it('should pull image if missing', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'missing-image',
|
||||
};
|
||||
|
||||
vi.mocked(
|
||||
SandboxOrchestrator.ensureSandboxImageIsPresent,
|
||||
).mockResolvedValueOnce(false);
|
||||
// 1. Image check fails
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess1 =
|
||||
new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess1.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess1.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess1 as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
// 2. Pull image succeeds
|
||||
interface MockProcessWithStdoutStderr extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr;
|
||||
mockPullProcess.stdout = new EventEmitter();
|
||||
mockPullProcess.stderr = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockPullProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockPullProcess as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
// 3. Image check succeeds
|
||||
const mockImageCheckProcess2 =
|
||||
new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess2.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess2.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess2.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess2 as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
// 4. Docker run
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
>;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setTimeout(() => cb(0), 10);
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
||||
|
||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
await expect(promise).resolves.toBe(0);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining(['pull', 'missing-image']),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if image pull fails', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'missing-image',
|
||||
};
|
||||
|
||||
// 1. Image check fails
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess1 =
|
||||
new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess1.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess1.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess1 as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
// 2. Pull image fails
|
||||
interface MockProcessWithStdoutStderr extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
const mockPullProcess = new EventEmitter() as MockProcessWithStdoutStderr;
|
||||
mockPullProcess.stdout = new EventEmitter();
|
||||
mockPullProcess.stderr = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockPullProcess.emit('close', 1);
|
||||
}, 1);
|
||||
return mockPullProcess as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
|
||||
});
|
||||
@@ -340,20 +325,51 @@ describe('sandbox', () => {
|
||||
process.env['SANDBOX_MOUNTS'] = '/host/path:/container/path:ro';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true); // For mount path check
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||
// Mock image check to return true
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
>;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setImmediate(() => cb(0));
|
||||
setTimeout(() => cb(0), 10);
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
||||
|
||||
await start_sandbox(config);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
// The first call is 'docker images -q ...'
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'docker',
|
||||
expect.arrayContaining(['--volume', '/host/path:/container/path:ro']),
|
||||
expect.arrayContaining(['images', '-q']),
|
||||
);
|
||||
|
||||
// The second call is 'docker run ...'
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'docker',
|
||||
expect.arrayContaining([
|
||||
'run',
|
||||
'--volume',
|
||||
'/host/path:/container/path:ro',
|
||||
'--volume',
|
||||
expect.stringMatching(/[\\/]home[\\/]user[\\/]\.gemini/),
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -366,14 +382,30 @@ describe('sandbox', () => {
|
||||
process.env['GOOGLE_GEMINI_BASE_URL'] = 'http://gemini.proxy';
|
||||
process.env['GOOGLE_VERTEX_BASE_URL'] = 'http://vertex.proxy';
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||
// Mock image check to return true
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
>;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setImmediate(() => cb(0));
|
||||
setTimeout(() => cb(0), 10);
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
||||
|
||||
await start_sandbox(config);
|
||||
|
||||
@@ -402,14 +434,30 @@ describe('sandbox', () => {
|
||||
return Buffer.from('');
|
||||
});
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ChildProcess;
|
||||
// Mock image check to return true
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
>;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setImmediate(() => cb(0));
|
||||
setTimeout(() => cb(0), 10);
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockReturnValue(mockSpawnProcess);
|
||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
||||
|
||||
await start_sandbox(config);
|
||||
|
||||
@@ -419,63 +467,11 @@ describe('sandbox', () => {
|
||||
expect.any(Object),
|
||||
);
|
||||
// Check that the entrypoint command includes useradd/groupadd
|
||||
const args = vi.mocked(spawn).mock.calls[0][1] as string[];
|
||||
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
|
||||
const entrypointCmd = args[args.length - 1];
|
||||
expect(entrypointCmd).toContain('groupadd');
|
||||
expect(entrypointCmd).toContain('useradd');
|
||||
expect(entrypointCmd).toContain('su -p gemini');
|
||||
});
|
||||
|
||||
describe('waitForProxy timeout', () => {
|
||||
it('should time out waiting for proxy', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
process.env['GEMINI_SANDBOX_PROXY_COMMAND'] = 'my-proxy';
|
||||
|
||||
// Mock spawn to return processes that stay open
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
const p = new EventEmitter() as unknown as ChildProcess;
|
||||
// @ts-expect-error - mocking readonly property
|
||||
p.pid = 123;
|
||||
p.kill = vi.fn();
|
||||
// @ts-expect-error - mocking readonly property
|
||||
p.stderr = new EventEmitter();
|
||||
// @ts-expect-error - mocking readonly property
|
||||
p.stdout = new EventEmitter();
|
||||
return p;
|
||||
});
|
||||
|
||||
// Mock spawnAsync to fail for curl (simulating proxy not started)
|
||||
mockSpawnAsync.mockImplementation(async (cmd) => {
|
||||
if (cmd === 'curl') {
|
||||
throw new Error('Connection refused');
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
});
|
||||
|
||||
// Mock Date.now to control time
|
||||
let currentTime = 1000000;
|
||||
const dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
|
||||
currentTime += 10000; // Increment time by 10s on each call to hit timeout fast
|
||||
return currentTime;
|
||||
});
|
||||
|
||||
// We also need to mock setTimeout to resolve immediately,
|
||||
// otherwise the loop will still take real time.
|
||||
const originalSetTimeout = global.setTimeout;
|
||||
// @ts-expect-error - mocking global setTimeout
|
||||
global.setTimeout = vi.fn().mockImplementation((cb) => cb());
|
||||
|
||||
try {
|
||||
const promise = start_sandbox(config);
|
||||
await expect(promise).rejects.toThrow(/Timed out waiting for proxy/);
|
||||
} finally {
|
||||
dateSpy.mockRestore();
|
||||
global.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+220
-117
@@ -1,20 +1,16 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
execSync,
|
||||
spawn,
|
||||
spawnSync,
|
||||
type ChildProcess,
|
||||
} from 'node:child_process';
|
||||
import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { quote } from 'shell-quote';
|
||||
import { quote, parse } from 'shell-quote';
|
||||
import { promisify } from 'node:util';
|
||||
import type { Config, SandboxConfig } from '@google/gemini-cli-core';
|
||||
import {
|
||||
coreEvents,
|
||||
@@ -22,11 +18,6 @@ import {
|
||||
FatalSandboxError,
|
||||
GEMINI_DIR,
|
||||
homedir,
|
||||
SandboxOrchestrator,
|
||||
LOCAL_DEV_SANDBOX_IMAGE_NAME,
|
||||
SANDBOX_NETWORK_NAME,
|
||||
SANDBOX_PROXY_NAME,
|
||||
spawnAsync,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
@@ -36,30 +27,13 @@ import {
|
||||
parseImageName,
|
||||
ports,
|
||||
entrypoint,
|
||||
LOCAL_DEV_SANDBOX_IMAGE_NAME,
|
||||
SANDBOX_NETWORK_NAME,
|
||||
SANDBOX_PROXY_NAME,
|
||||
BUILTIN_SEATBELT_PROFILES,
|
||||
} from './sandboxUtils.js';
|
||||
|
||||
async function waitForProxy(
|
||||
proxyUrl: string,
|
||||
timeoutMs: number = 30000,
|
||||
retryDelayMs: number = 500,
|
||||
now: () => number = Date.now,
|
||||
): Promise<void> {
|
||||
const start = now();
|
||||
while (now() - start < timeoutMs) {
|
||||
try {
|
||||
await spawnAsync('curl', ['-s', proxyUrl], {
|
||||
timeout: 500,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, retryDelayMs));
|
||||
}
|
||||
}
|
||||
throw new FatalSandboxError(
|
||||
`Timed out waiting for proxy at ${proxyUrl} to start after ${timeoutMs / 1000} seconds`,
|
||||
);
|
||||
}
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export async function start_sandbox(
|
||||
config: SandboxConfig,
|
||||
@@ -96,16 +70,26 @@ export async function start_sandbox(
|
||||
);
|
||||
}
|
||||
debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`);
|
||||
const cacheDir = (
|
||||
await spawnAsync('getconf', ['DARWIN_USER_CACHE_DIR'])
|
||||
).stdout.trim();
|
||||
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
|
||||
const nodeOptions = [
|
||||
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
|
||||
...nodeArgs,
|
||||
].join(' ');
|
||||
|
||||
const targetDirReal = fs.realpathSync(process.cwd());
|
||||
const tmpDirReal = fs.realpathSync(os.tmpdir());
|
||||
const homeDirReal = fs.realpathSync(homedir());
|
||||
const cacheDirReal = fs.realpathSync(cacheDir);
|
||||
const args = [
|
||||
'-D',
|
||||
`TARGET_DIR=${fs.realpathSync(process.cwd())}`,
|
||||
'-D',
|
||||
`TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
|
||||
'-D',
|
||||
`HOME_DIR=${fs.realpathSync(homedir())}`,
|
||||
'-D',
|
||||
`CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
|
||||
];
|
||||
|
||||
// Add included directories from the workspace context
|
||||
// Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them
|
||||
const MAX_INCLUDE_DIRS = 5;
|
||||
const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
|
||||
const includedDirs: string[] = [];
|
||||
|
||||
@@ -122,24 +106,21 @@ export async function start_sandbox(
|
||||
}
|
||||
}
|
||||
|
||||
const args = SandboxOrchestrator.getSeatbeltArgs(
|
||||
targetDirReal,
|
||||
tmpDirReal,
|
||||
homeDirReal,
|
||||
cacheDirReal,
|
||||
profileFile,
|
||||
includedDirs,
|
||||
);
|
||||
for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
|
||||
let dirPath = '/dev/null'; // Default to a safe path that won't cause issues
|
||||
|
||||
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
|
||||
const nodeOptions = [
|
||||
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
|
||||
...nodeArgs,
|
||||
].join(' ');
|
||||
if (i < includedDirs.length) {
|
||||
dirPath = includedDirs[i];
|
||||
}
|
||||
|
||||
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
|
||||
}
|
||||
|
||||
const finalArgv = cliArgs;
|
||||
|
||||
args.push(
|
||||
'-f',
|
||||
profileFile,
|
||||
'sh',
|
||||
'-c',
|
||||
[
|
||||
@@ -180,7 +161,6 @@ export async function start_sandbox(
|
||||
if (proxyProcess?.pid) {
|
||||
process.kill(-proxyProcess.pid, 'SIGTERM');
|
||||
}
|
||||
return;
|
||||
};
|
||||
process.off('exit', stopProxy);
|
||||
process.on('exit', stopProxy);
|
||||
@@ -205,7 +185,9 @@ export async function start_sandbox(
|
||||
);
|
||||
});
|
||||
debugLogger.log('waiting for proxy to start ...');
|
||||
await waitForProxy('http://localhost:8877', 30000, 500);
|
||||
await execAsync(
|
||||
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
|
||||
);
|
||||
}
|
||||
// spawn child and let it inherit stdio
|
||||
process.stdin.pause();
|
||||
@@ -273,11 +255,7 @@ export async function start_sandbox(
|
||||
|
||||
// stop if image is missing
|
||||
if (
|
||||
!(await SandboxOrchestrator.ensureSandboxImageIsPresent(
|
||||
config.command,
|
||||
image,
|
||||
cliConfig,
|
||||
))
|
||||
!(await ensureSandboxImageIsPresent(config.command, image, cliConfig))
|
||||
) {
|
||||
const remedy =
|
||||
image === LOCAL_DEV_SANDBOX_IMAGE_NAME
|
||||
@@ -290,13 +268,26 @@ export async function start_sandbox(
|
||||
|
||||
// use interactive mode and auto-remove container on exit
|
||||
// run init binary inside container to forward signals & reap zombies
|
||||
const args = await SandboxOrchestrator.getContainerRunArgs(
|
||||
config,
|
||||
workdir,
|
||||
containerWorkdir,
|
||||
process.env['SANDBOX_FLAGS'],
|
||||
!process.stdin.isTTY,
|
||||
);
|
||||
const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];
|
||||
|
||||
// add custom flags from SANDBOX_FLAGS
|
||||
if (process.env['SANDBOX_FLAGS']) {
|
||||
const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter(
|
||||
(f): f is string => typeof f === 'string',
|
||||
);
|
||||
args.push(...flags);
|
||||
}
|
||||
|
||||
// add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container
|
||||
if (process.stdin.isTTY) {
|
||||
args.push('-t');
|
||||
}
|
||||
|
||||
// allow access to host.docker.internal
|
||||
args.push('--add-host', 'host.docker.internal:host-gateway');
|
||||
|
||||
// mount current directory as working directory in sandbox (set via --workdir)
|
||||
args.push('--volume', `${workdir}:${containerWorkdir}`);
|
||||
|
||||
// mount user settings directory inside container, after creating if missing
|
||||
// note user/home changes inside sandbox and we mount at BOTH paths for consistency
|
||||
@@ -418,38 +409,17 @@ export async function start_sandbox(
|
||||
|
||||
// if using proxy, switch to internal networking through proxy
|
||||
if (proxy) {
|
||||
try {
|
||||
await spawnAsync(config.command, [
|
||||
'network',
|
||||
'inspect',
|
||||
SANDBOX_NETWORK_NAME,
|
||||
]);
|
||||
} catch {
|
||||
await spawnAsync(config.command, [
|
||||
'network',
|
||||
'create',
|
||||
'--internal',
|
||||
SANDBOX_NETWORK_NAME,
|
||||
]);
|
||||
}
|
||||
execSync(
|
||||
`${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`,
|
||||
);
|
||||
args.push('--network', SANDBOX_NETWORK_NAME);
|
||||
// if proxy command is set, create a separate network w/ host access (i.e. non-internal)
|
||||
// we will run proxy in its own container connected to both host network and internal network
|
||||
// this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation
|
||||
if (proxyCommand) {
|
||||
try {
|
||||
await spawnAsync(config.command, [
|
||||
'network',
|
||||
'inspect',
|
||||
SANDBOX_PROXY_NAME,
|
||||
]);
|
||||
} catch {
|
||||
await spawnAsync(config.command, [
|
||||
'network',
|
||||
'create',
|
||||
SANDBOX_PROXY_NAME,
|
||||
]);
|
||||
}
|
||||
execSync(
|
||||
`${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,12 +436,9 @@ export async function start_sandbox(
|
||||
debugLogger.log(`ContainerName: ${containerName}`);
|
||||
} else {
|
||||
let index = 0;
|
||||
const { stdout: containerNameCheck } = await spawnAsync(config.command, [
|
||||
'ps',
|
||||
'-a',
|
||||
'--format',
|
||||
'{{.Names}}',
|
||||
]);
|
||||
const containerNameCheck = (
|
||||
await execAsync(`${config.command} ps -a --format "{{.Names}}"`)
|
||||
).stdout.trim();
|
||||
while (containerNameCheck.includes(`${imageName}-${index}`)) {
|
||||
index++;
|
||||
}
|
||||
@@ -639,8 +606,8 @@ export async function start_sandbox(
|
||||
// The entrypoint script then handles dropping privileges to the correct user.
|
||||
args.push('--user', 'root');
|
||||
|
||||
const uid = (await spawnAsync('id', ['-u'])).stdout.trim();
|
||||
const gid = (await spawnAsync('id', ['-g'])).stdout.trim();
|
||||
const uid = (await execAsync('id -u')).stdout.trim();
|
||||
const gid = (await execAsync('id -g')).stdout.trim();
|
||||
|
||||
// Instead of passing --user to the main sandbox container, we let it
|
||||
// start as root, then create a user with the host's UID/GID, and
|
||||
@@ -693,13 +660,8 @@ export async function start_sandbox(
|
||||
// install handlers to stop proxy on exit/signal
|
||||
const stopProxy = () => {
|
||||
debugLogger.log('stopping proxy container ...');
|
||||
try {
|
||||
spawnSync(config.command, ['rm', '-f', SANDBOX_PROXY_NAME]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`);
|
||||
};
|
||||
|
||||
process.off('exit', stopProxy);
|
||||
process.on('exit', stopProxy);
|
||||
process.off('SIGINT', stopProxy);
|
||||
@@ -719,19 +681,18 @@ export async function start_sandbox(
|
||||
process.kill(-sandboxProcess.pid, 'SIGTERM');
|
||||
}
|
||||
throw new FatalSandboxError(
|
||||
`Proxy container command exited with code ${code}, signal ${signal}`,
|
||||
`Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`,
|
||||
);
|
||||
});
|
||||
debugLogger.log('waiting for proxy to start ...');
|
||||
await waitForProxy('http://localhost:8877', 30000, 500);
|
||||
await execAsync(
|
||||
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
|
||||
);
|
||||
// connect proxy container to sandbox network
|
||||
// (workaround for older versions of docker that don't support multiple --network args)
|
||||
await spawnAsync(config.command, [
|
||||
'network',
|
||||
'connect',
|
||||
SANDBOX_NETWORK_NAME,
|
||||
SANDBOX_PROXY_NAME,
|
||||
]);
|
||||
await execAsync(
|
||||
`${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,
|
||||
);
|
||||
}
|
||||
|
||||
// spawn child and let it inherit stdio
|
||||
@@ -760,3 +721,145 @@ export async function start_sandbox(
|
||||
patcher.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions to ensure sandbox image is present
|
||||
async function imageExists(sandbox: string, image: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const args = ['images', '-q', image];
|
||||
const checkProcess = spawn(sandbox, args);
|
||||
|
||||
let stdoutData = '';
|
||||
if (checkProcess.stdout) {
|
||||
checkProcess.stdout.on('data', (data) => {
|
||||
stdoutData += data.toString();
|
||||
});
|
||||
}
|
||||
|
||||
checkProcess.on('error', (err) => {
|
||||
debugLogger.warn(
|
||||
`Failed to start '${sandbox}' command for image check: ${err.message}`,
|
||||
);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
checkProcess.on('close', (code) => {
|
||||
// Non-zero code might indicate docker daemon not running, etc.
|
||||
// The primary success indicator is non-empty stdoutData.
|
||||
if (code !== 0) {
|
||||
// console.warn(`'${sandbox} images -q ${image}' exited with code ${code}.`);
|
||||
}
|
||||
resolve(stdoutData.trim() !== '');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function pullImage(
|
||||
sandbox: string,
|
||||
image: string,
|
||||
cliConfig?: Config,
|
||||
): Promise<boolean> {
|
||||
debugLogger.debug(`Attempting to pull image ${image} using ${sandbox}...`);
|
||||
return new Promise((resolve) => {
|
||||
const args = ['pull', image];
|
||||
const pullProcess = spawn(sandbox, args, { stdio: 'pipe' });
|
||||
|
||||
let stderrData = '';
|
||||
|
||||
const onStdoutData = (data: Buffer) => {
|
||||
if (cliConfig?.getDebugMode() || process.env['DEBUG']) {
|
||||
debugLogger.log(data.toString().trim()); // Show pull progress
|
||||
}
|
||||
};
|
||||
|
||||
const onStderrData = (data: Buffer) => {
|
||||
stderrData += data.toString();
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(data.toString().trim()); // Show pull errors/info from the command itself
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
debugLogger.warn(
|
||||
`Failed to start '${sandbox} pull ${image}' command: ${err.message}`,
|
||||
);
|
||||
cleanup();
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
const onClose = (code: number | null) => {
|
||||
if (code === 0) {
|
||||
debugLogger.log(`Successfully pulled image ${image}.`);
|
||||
cleanup();
|
||||
resolve(true);
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`Failed to pull image ${image}. '${sandbox} pull ${image}' exited with code ${code}.`,
|
||||
);
|
||||
if (stderrData.trim()) {
|
||||
// Details already printed by the stderr listener above
|
||||
}
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (pullProcess.stdout) {
|
||||
pullProcess.stdout.removeListener('data', onStdoutData);
|
||||
}
|
||||
if (pullProcess.stderr) {
|
||||
pullProcess.stderr.removeListener('data', onStderrData);
|
||||
}
|
||||
pullProcess.removeListener('error', onError);
|
||||
pullProcess.removeListener('close', onClose);
|
||||
if (pullProcess.connected) {
|
||||
pullProcess.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
if (pullProcess.stdout) {
|
||||
pullProcess.stdout.on('data', onStdoutData);
|
||||
}
|
||||
if (pullProcess.stderr) {
|
||||
pullProcess.stderr.on('data', onStderrData);
|
||||
}
|
||||
pullProcess.on('error', onError);
|
||||
pullProcess.on('close', onClose);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureSandboxImageIsPresent(
|
||||
sandbox: string,
|
||||
image: string,
|
||||
cliConfig?: Config,
|
||||
): Promise<boolean> {
|
||||
debugLogger.log(`Checking for sandbox image: ${image}`);
|
||||
if (await imageExists(sandbox, image)) {
|
||||
debugLogger.log(`Sandbox image ${image} found locally.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
debugLogger.log(`Sandbox image ${image} not found locally.`);
|
||||
if (image === LOCAL_DEV_SANDBOX_IMAGE_NAME) {
|
||||
// user needs to build the image themselves
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await pullImage(sandbox, image, cliConfig)) {
|
||||
// After attempting to pull, check again to be certain
|
||||
if (await imageExists(sandbox, image)) {
|
||||
debugLogger.log(`Sandbox image ${image} is now available after pulling.`);
|
||||
return true;
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`Sandbox image ${image} still not found after a pull attempt. This might indicate an issue with the image name or registry, or the pull command reported success but failed to make the image available.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to obtain sandbox image ${image} after check and pull attempt.`,
|
||||
);
|
||||
return false; // Pull command failed or image still not present
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import { readFile } from 'node:fs/promises';
|
||||
import { quote } from 'shell-quote';
|
||||
import { debugLogger, GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
|
||||
export const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'gemini-cli-sandbox';
|
||||
export const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox';
|
||||
export const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy';
|
||||
export const BUILTIN_SEATBELT_PROFILES = [
|
||||
'permissive-open',
|
||||
'permissive-proxied',
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { GeminiUserTier } from '../code_assist/types.js';
|
||||
import {
|
||||
buildG1Url,
|
||||
getG1CreditBalance,
|
||||
G1_CREDIT_TYPE,
|
||||
G1_UTM_CAMPAIGNS,
|
||||
isOverageEligibleModel,
|
||||
shouldAutoUseCredits,
|
||||
shouldShowEmptyWalletMenu,
|
||||
shouldShowOverageMenu,
|
||||
wrapInAccountChooser,
|
||||
} from './billing.js';
|
||||
|
||||
describe('billing', () => {
|
||||
describe('wrapInAccountChooser', () => {
|
||||
it('should wrap URL with AccountChooser redirect', () => {
|
||||
const result = wrapInAccountChooser(
|
||||
'user@gmail.com',
|
||||
'https://one.google.com/ai/activity',
|
||||
);
|
||||
expect(result).toBe(
|
||||
'https://accounts.google.com/AccountChooser?Email=user%40gmail.com&continue=https%3A%2F%2Fone.google.com%2Fai%2Factivity',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle special characters in email', () => {
|
||||
const result = wrapInAccountChooser(
|
||||
'user+test@example.com',
|
||||
'https://example.com',
|
||||
);
|
||||
expect(result).toContain('Email=user%2Btest%40example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildG1Url', () => {
|
||||
it('should build activity URL with UTM params wrapped in AccountChooser', () => {
|
||||
const result = buildG1Url(
|
||||
'activity',
|
||||
'user@gmail.com',
|
||||
G1_UTM_CAMPAIGNS.MANAGE_ACTIVITY,
|
||||
);
|
||||
|
||||
// Should contain AccountChooser prefix
|
||||
expect(result).toContain('https://accounts.google.com/AccountChooser');
|
||||
expect(result).toContain('Email=user%40gmail.com');
|
||||
|
||||
// The continue URL should contain the G1 activity path and UTM params
|
||||
expect(result).toContain('one.google.com%2Fai%2Factivity');
|
||||
expect(result).toContain('utm_source%3Dgemini_cli');
|
||||
expect(result).toContain(
|
||||
'utm_campaign%3Dhydrogen_cli_settings_ai_credits_activity_page',
|
||||
);
|
||||
});
|
||||
|
||||
it('should build credits URL with UTM params wrapped in AccountChooser', () => {
|
||||
const result = buildG1Url(
|
||||
'credits',
|
||||
'test@example.com',
|
||||
G1_UTM_CAMPAIGNS.EMPTY_WALLET_ADD_CREDITS,
|
||||
);
|
||||
|
||||
expect(result).toContain('https://accounts.google.com/AccountChooser');
|
||||
expect(result).toContain('one.google.com%2Fai%2Fcredits');
|
||||
expect(result).toContain(
|
||||
'utm_campaign%3Dhydrogen_cli_insufficient_credits_add_credits',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getG1CreditBalance', () => {
|
||||
it('should return null for null tier', () => {
|
||||
expect(getG1CreditBalance(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for undefined tier', () => {
|
||||
expect(getG1CreditBalance(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for tier without availableCredits', () => {
|
||||
const tier: GeminiUserTier = { id: 'PERSONAL' };
|
||||
expect(getG1CreditBalance(tier)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty availableCredits array', () => {
|
||||
const tier: GeminiUserTier = { id: 'PERSONAL', availableCredits: [] };
|
||||
expect(getG1CreditBalance(tier)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no G1 credit type found', () => {
|
||||
const tier: GeminiUserTier = {
|
||||
id: 'PERSONAL',
|
||||
availableCredits: [
|
||||
{ creditType: 'CREDIT_TYPE_UNSPECIFIED', creditAmount: '100' },
|
||||
],
|
||||
};
|
||||
expect(getG1CreditBalance(tier)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return G1 credit balance when present', () => {
|
||||
const tier: GeminiUserTier = {
|
||||
id: 'PERSONAL',
|
||||
availableCredits: [{ creditType: G1_CREDIT_TYPE, creditAmount: '500' }],
|
||||
};
|
||||
expect(getG1CreditBalance(tier)).toBe(500);
|
||||
});
|
||||
|
||||
it('should return G1 credit balance when multiple credit types present', () => {
|
||||
const tier: GeminiUserTier = {
|
||||
id: 'PERSONAL',
|
||||
availableCredits: [
|
||||
{ creditType: 'CREDIT_TYPE_UNSPECIFIED', creditAmount: '100' },
|
||||
{ creditType: G1_CREDIT_TYPE, creditAmount: '750' },
|
||||
],
|
||||
};
|
||||
expect(getG1CreditBalance(tier)).toBe(750);
|
||||
});
|
||||
|
||||
it('should return 0 for invalid credit amount', () => {
|
||||
const tier: GeminiUserTier = {
|
||||
id: 'PERSONAL',
|
||||
availableCredits: [
|
||||
{ creditType: G1_CREDIT_TYPE, creditAmount: 'invalid' },
|
||||
],
|
||||
};
|
||||
expect(getG1CreditBalance(tier)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle large credit amounts (int64 as string)', () => {
|
||||
const tier: GeminiUserTier = {
|
||||
id: 'PERSONAL',
|
||||
availableCredits: [
|
||||
{ creditType: G1_CREDIT_TYPE, creditAmount: '9999999999' },
|
||||
],
|
||||
};
|
||||
expect(getG1CreditBalance(tier)).toBe(9999999999);
|
||||
});
|
||||
|
||||
it('should sum multiple credits of the same G1 type', () => {
|
||||
const tier: GeminiUserTier = {
|
||||
id: 'PERSONAL',
|
||||
availableCredits: [
|
||||
{ creditType: G1_CREDIT_TYPE, creditAmount: '1000' },
|
||||
{ creditType: G1_CREDIT_TYPE, creditAmount: '8' },
|
||||
],
|
||||
};
|
||||
expect(getG1CreditBalance(tier)).toBe(1008);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldAutoUseCredits', () => {
|
||||
it('should return true when strategy is always and balance > 0', () => {
|
||||
expect(shouldAutoUseCredits('always', 100)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when strategy is always but balance is 0', () => {
|
||||
expect(shouldAutoUseCredits('always', 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when strategy is ask', () => {
|
||||
expect(shouldAutoUseCredits('ask', 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when strategy is never', () => {
|
||||
expect(shouldAutoUseCredits('never', 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when creditBalance is null (ineligible)', () => {
|
||||
expect(shouldAutoUseCredits('always', null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowOverageMenu', () => {
|
||||
it('should return true when strategy is ask and balance > 0', () => {
|
||||
expect(shouldShowOverageMenu('ask', 100)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when strategy is ask but balance is 0', () => {
|
||||
expect(shouldShowOverageMenu('ask', 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when strategy is always', () => {
|
||||
expect(shouldShowOverageMenu('always', 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when strategy is never', () => {
|
||||
expect(shouldShowOverageMenu('never', 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when creditBalance is null (ineligible)', () => {
|
||||
expect(shouldShowOverageMenu('ask', null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowEmptyWalletMenu', () => {
|
||||
it('should return true when strategy is ask and balance is 0', () => {
|
||||
expect(shouldShowEmptyWalletMenu('ask', 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when strategy is always and balance is 0', () => {
|
||||
expect(shouldShowEmptyWalletMenu('always', 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when strategy is never', () => {
|
||||
expect(shouldShowEmptyWalletMenu('never', 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when balance > 0', () => {
|
||||
expect(shouldShowEmptyWalletMenu('ask', 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when creditBalance is null (ineligible)', () => {
|
||||
expect(shouldShowEmptyWalletMenu('ask', null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOverageEligibleModel', () => {
|
||||
it('should return true for gemini-3-pro-preview', () => {
|
||||
expect(isOverageEligibleModel('gemini-3-pro-preview')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for gemini-3.1-pro-preview', () => {
|
||||
expect(isOverageEligibleModel('gemini-3.1-pro-preview')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for gemini-3.1-pro-preview-customtools', () => {
|
||||
expect(isOverageEligibleModel('gemini-3.1-pro-preview-customtools')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for gemini-3-flash-preview', () => {
|
||||
expect(isOverageEligibleModel('gemini-3-flash-preview')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for gemini-2.5-pro', () => {
|
||||
expect(isOverageEligibleModel('gemini-2.5-pro')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for gemini-2.5-flash', () => {
|
||||
expect(isOverageEligibleModel('gemini-2.5-flash')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for custom model names', () => {
|
||||
expect(isOverageEligibleModel('my-custom-model')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user