mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 16:50:59 -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -125,7 +118,6 @@ they appear in the UI.
|
||||
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
|
||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
||||
| Auto-add to Policy | `security.autoAddPolicy` | Automatically add "Proceed always" approvals to your persistent policy. | `true` |
|
||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `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):
|
||||
@@ -866,11 +851,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
confirmation dialogs.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`security.autoAddPolicy`** (boolean):
|
||||
- **Description:** Automatically add "Proceed always" approvals to your
|
||||
persistent policy.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`security.blockGitExtensions`** (boolean):
|
||||
- **Description:** Blocks installing and loading extensions from Git.
|
||||
- **Default:** `false`
|
||||
|
||||
+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
|
||||
|
||||
+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
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -818,10 +818,9 @@ export async function loadCliConfig(
|
||||
model: resolvedModel,
|
||||
maxSessionTurns: settings.model?.maxSessionTurns,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
listSessions: argv.listSessions || false,
|
||||
deleteSession: argv.deleteSession,
|
||||
autoAddPolicy:
|
||||
settings.security?.autoAddPolicy && !settings.admin?.secureModeEnabled,
|
||||
enabledExtensions: argv.extensions,
|
||||
extensionLoader: extensionManager,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
@@ -844,6 +843,7 @@ export async function loadCliConfig(
|
||||
interactive,
|
||||
trustedFolder,
|
||||
useBackgroundColor: settings.ui?.useBackgroundColor,
|
||||
useAlternateBuffer: settings.ui?.useAlternateBuffer,
|
||||
useRipgrep: settings.tools?.useRipgrep,
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
|
||||
@@ -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',
|
||||
@@ -1480,16 +1436,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable the "Allow for all future sessions" option in tool confirmation dialogs.',
|
||||
showInDialog: true,
|
||||
},
|
||||
autoAddPolicy: {
|
||||
type: 'boolean',
|
||||
label: 'Auto-add to Policy',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Automatically add "Proceed always" approvals to your persistent policy.',
|
||||
showInDialog: true,
|
||||
},
|
||||
blockGitExtensions: {
|
||||
type: 'boolean',
|
||||
label: 'Blocks extensions from Git',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -594,13 +594,6 @@ export async function main() {
|
||||
const messageBus = config.getMessageBus();
|
||||
createPolicyUpdater(policyEngine, messageBus, config.storage);
|
||||
|
||||
// Listen for settings changes to update reactive config properties
|
||||
coreEvents.on(CoreEvent.SettingsChanged, () => {
|
||||
if (settings.merged.security.autoAddPolicy !== undefined) {
|
||||
config.setAutoAddPolicy(settings.merged.security.autoAddPolicy);
|
||||
}
|
||||
});
|
||||
|
||||
// Register SessionEnd hook to fire on graceful exit
|
||||
// This runs before telemetry shutdown in runExitCleanup()
|
||||
registerCleanup(async () => {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
AvailableCredits,
|
||||
CreditType,
|
||||
GeminiUserTier,
|
||||
} from '../code_assist/types.js';
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
} from '../config/models.js';
|
||||
|
||||
/**
|
||||
* Strategy for handling quota exhaustion when AI credits are available.
|
||||
* - 'ask': Prompt the user each time
|
||||
* - 'always': Automatically use credits
|
||||
* - 'never': Never use credits, show standard fallback
|
||||
*/
|
||||
export type OverageStrategy = 'ask' | 'always' | 'never';
|
||||
|
||||
/** Credit type for Google One AI credits */
|
||||
export const G1_CREDIT_TYPE: CreditType = 'GOOGLE_ONE_AI';
|
||||
|
||||
/**
|
||||
* The set of models that support AI credits overage billing.
|
||||
* Only these models are eligible for the credits-based retry flow.
|
||||
*/
|
||||
export const OVERAGE_ELIGIBLE_MODELS = new Set([
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Checks if a model is eligible for AI credits overage billing.
|
||||
* @param model The model name to check.
|
||||
* @returns true if the model supports credits overage, false otherwise.
|
||||
*/
|
||||
export function isOverageEligibleModel(model: string): boolean {
|
||||
return OVERAGE_ELIGIBLE_MODELS.has(model);
|
||||
}
|
||||
|
||||
/** Base URL for Google One AI page */
|
||||
const G1_AI_BASE_URL = 'https://one.google.com/ai';
|
||||
|
||||
/** AccountChooser URL for redirecting with email context */
|
||||
const ACCOUNT_CHOOSER_URL = 'https://accounts.google.com/AccountChooser';
|
||||
|
||||
/** UTM parameters for CLI tracking */
|
||||
const UTM_SOURCE = 'gemini_cli';
|
||||
// TODO: change to 'desktop' when G1 service fix is rolled out
|
||||
const UTM_MEDIUM = 'web';
|
||||
|
||||
/**
|
||||
* Wraps a URL in the AccountChooser redirect to maintain user context.
|
||||
* @param email User's email address for account selection
|
||||
* @param continueUrl The destination URL after account selection
|
||||
* @returns The full AccountChooser redirect URL
|
||||
*/
|
||||
export function wrapInAccountChooser(
|
||||
email: string,
|
||||
continueUrl: string,
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
Email: email,
|
||||
continue: continueUrl,
|
||||
});
|
||||
return `${ACCOUNT_CHOOSER_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTM campaign identifiers per the design doc.
|
||||
*/
|
||||
export const G1_UTM_CAMPAIGNS = {
|
||||
/** From Interception Flow "Manage" link (user has credits) */
|
||||
MANAGE_ACTIVITY: 'hydrogen_cli_settings_ai_credits_activity_page',
|
||||
/** From "Manage" to add more credits */
|
||||
MANAGE_ADD_CREDITS: 'hydrogen_cli_settings_add_credits',
|
||||
/** From Empty Wallet Flow "Get AI Credits" link */
|
||||
EMPTY_WALLET_ADD_CREDITS: 'hydrogen_cli_insufficient_credits_add_credits',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Builds a G1 AI URL with UTM tracking parameters.
|
||||
* @param path The path segment (e.g., 'activity' or 'credits')
|
||||
* @param email User's email for AccountChooser wrapper
|
||||
* @param campaign The UTM campaign identifier
|
||||
* @returns The complete URL wrapped in AccountChooser
|
||||
*/
|
||||
export function buildG1Url(
|
||||
path: 'activity' | 'credits',
|
||||
email: string,
|
||||
campaign: string,
|
||||
): string {
|
||||
const baseUrl = `${G1_AI_BASE_URL}/${path}`;
|
||||
const params = new URLSearchParams({
|
||||
utm_source: UTM_SOURCE,
|
||||
utm_medium: UTM_MEDIUM,
|
||||
utm_campaign: campaign,
|
||||
});
|
||||
const urlWithUtm = `${baseUrl}?${params.toString()}`;
|
||||
return wrapInAccountChooser(email, urlWithUtm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the G1 AI credit balance from a tier's available credits.
|
||||
* @param tier The user tier to check
|
||||
* @returns The credit amount as a number, 0 if eligible but empty, or null if not eligible
|
||||
*/
|
||||
export function getG1CreditBalance(
|
||||
tier: GeminiUserTier | null | undefined,
|
||||
): number | null {
|
||||
if (!tier?.availableCredits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const g1Credits = tier.availableCredits.filter(
|
||||
(credit: AvailableCredits) => credit.creditType === G1_CREDIT_TYPE,
|
||||
);
|
||||
|
||||
if (g1Credits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// creditAmount is an int64 represented as string; sum all matching entries
|
||||
return g1Credits.reduce((sum, credit) => {
|
||||
const amount = parseInt(credit.creditAmount ?? '0', 10);
|
||||
return sum + (isNaN(amount) ? 0 : amount);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export const MIN_CREDIT_BALANCE = 50;
|
||||
|
||||
/**
|
||||
* Determines if credits should be automatically used based on the overage strategy.
|
||||
* @param strategy The configured overage strategy
|
||||
* @param creditBalance The available credit balance
|
||||
* @returns true if credits should be auto-used, false otherwise
|
||||
*/
|
||||
export function shouldAutoUseCredits(
|
||||
strategy: OverageStrategy,
|
||||
creditBalance: number | null,
|
||||
): boolean {
|
||||
return (
|
||||
strategy === 'always' &&
|
||||
creditBalance != null &&
|
||||
creditBalance >= MIN_CREDIT_BALANCE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the overage menu should be shown based on the strategy.
|
||||
* @param strategy The configured overage strategy
|
||||
* @param creditBalance The available credit balance
|
||||
* @returns true if the menu should be shown
|
||||
*/
|
||||
export function shouldShowOverageMenu(
|
||||
strategy: OverageStrategy,
|
||||
creditBalance: number | null,
|
||||
): boolean {
|
||||
return (
|
||||
strategy === 'ask' &&
|
||||
creditBalance != null &&
|
||||
creditBalance >= MIN_CREDIT_BALANCE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the empty wallet menu should be shown.
|
||||
* @param strategy The configured overage strategy
|
||||
* @param creditBalance The available credit balance
|
||||
* @returns true if the empty wallet menu should be shown
|
||||
*/
|
||||
export function shouldShowEmptyWalletMenu(
|
||||
strategy: OverageStrategy,
|
||||
creditBalance: number | null,
|
||||
): boolean {
|
||||
return (
|
||||
strategy !== 'never' &&
|
||||
creditBalance != null &&
|
||||
creditBalance < MIN_CREDIT_BALANCE
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export * from './billing.js';
|
||||
@@ -73,8 +73,6 @@ describe('codeAssist', () => {
|
||||
'session-123',
|
||||
'free-tier',
|
||||
'free-tier-name',
|
||||
undefined,
|
||||
mockConfig,
|
||||
);
|
||||
expect(generator).toBeInstanceOf(MockedCodeAssistServer);
|
||||
});
|
||||
@@ -105,8 +103,6 @@ describe('codeAssist', () => {
|
||||
undefined, // No session ID
|
||||
'free-tier',
|
||||
'free-tier-name',
|
||||
undefined,
|
||||
mockConfig,
|
||||
);
|
||||
expect(generator).toBeInstanceOf(MockedCodeAssistServer);
|
||||
});
|
||||
|
||||
@@ -36,8 +36,6 @@ export async function createCodeAssistContentGenerator(
|
||||
sessionId,
|
||||
userData.userTier,
|
||||
userData.userTierName,
|
||||
userData.paidTier,
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,14 +28,12 @@ import type {
|
||||
} from '@google/genai';
|
||||
import { GenerateContentResponse } from '@google/genai';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { Credits } from './types.js';
|
||||
|
||||
export interface CAGenerateContentRequest {
|
||||
model: string;
|
||||
project?: string;
|
||||
user_prompt_id?: string;
|
||||
request: VertexGenerateContentRequest;
|
||||
enabled_credit_types?: string[];
|
||||
}
|
||||
|
||||
interface VertexGenerateContentRequest {
|
||||
@@ -77,8 +75,6 @@ interface VertexGenerationConfig {
|
||||
export interface CaGenerateContentResponse {
|
||||
response?: VertexGenerateContentResponse;
|
||||
traceId?: string;
|
||||
consumedCredits?: Credits[];
|
||||
remainingCredits?: Credits[];
|
||||
}
|
||||
|
||||
interface VertexGenerateContentResponse {
|
||||
@@ -131,14 +127,12 @@ export function toGenerateContentRequest(
|
||||
userPromptId: string,
|
||||
project?: string,
|
||||
sessionId?: string,
|
||||
enabledCreditTypes?: string[],
|
||||
): CAGenerateContentRequest {
|
||||
return {
|
||||
model: req.model,
|
||||
project,
|
||||
user_prompt_id: userPromptId,
|
||||
request: toVertexGenerateContentRequest(req, sessionId),
|
||||
enabled_credit_types: enabledCreditTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,16 +305,3 @@ function toVertexGenerationConfig(
|
||||
thinkingConfig: config.thinkingConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export function fromGenerateContentResponseUsage(
|
||||
metadata?: GenerateContentResponseUsageMetadata,
|
||||
): GenerateContentResponseUsageMetadata | undefined {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
promptTokenCount: metadata.promptTokenCount,
|
||||
candidatesTokenCount: metadata.candidatesTokenCount,
|
||||
totalTokenCount: metadata.totalTokenCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,7 +84,6 @@ describe('CodeAssistServer', () => {
|
||||
body: expect.any(String),
|
||||
signal: undefined,
|
||||
retryConfig: {
|
||||
retryDelay: 1000,
|
||||
retry: 3,
|
||||
noResponseRetries: 3,
|
||||
statusCodesToRetry: [
|
||||
@@ -411,7 +410,15 @@ describe('CodeAssistServer', () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: undefined,
|
||||
retry: false,
|
||||
retryConfig: {
|
||||
retry: 3,
|
||||
noResponseRetries: 3,
|
||||
statusCodesToRetry: [
|
||||
[429, 429],
|
||||
[499, 499],
|
||||
[500, 599],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
@@ -21,10 +21,7 @@ import type {
|
||||
ConversationInteraction,
|
||||
StreamingLatency,
|
||||
RecordCodeAssistMetricsRequest,
|
||||
GeminiUserTier,
|
||||
Credits,
|
||||
} from './types.js';
|
||||
import { UserTierId } from './types.js';
|
||||
import type {
|
||||
ListExperimentsRequest,
|
||||
ListExperimentsResponse,
|
||||
@@ -40,15 +37,7 @@ import type {
|
||||
import * as readline from 'node:readline';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ContentGenerator } from '../core/contentGenerator.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
G1_CREDIT_TYPE,
|
||||
getG1CreditBalance,
|
||||
isOverageEligibleModel,
|
||||
shouldAutoUseCredits,
|
||||
} from '../billing/billing.js';
|
||||
import { logBillingEvent } from '../telemetry/loggers.js';
|
||||
import { CreditsUsedEvent } from '../telemetry/billingEvents.js';
|
||||
import { UserTierId } from './types.js';
|
||||
import type {
|
||||
CaCountTokenResponse,
|
||||
CaGenerateContentResponse,
|
||||
@@ -73,7 +62,6 @@ export interface HttpOptions {
|
||||
|
||||
export const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
|
||||
export const CODE_ASSIST_API_VERSION = 'v1internal';
|
||||
const GENERATE_CONTENT_RETRY_DELAY_IN_MILLISECONDS = 1000;
|
||||
|
||||
export class CodeAssistServer implements ContentGenerator {
|
||||
constructor(
|
||||
@@ -83,8 +71,6 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
readonly sessionId?: string,
|
||||
readonly userTier?: UserTierId,
|
||||
readonly userTierName?: string,
|
||||
readonly paidTier?: GeminiUserTier,
|
||||
readonly config?: Config,
|
||||
) {}
|
||||
|
||||
async generateContentStream(
|
||||
@@ -93,19 +79,6 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
role: LlmRole,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
const autoUse = this.config
|
||||
? shouldAutoUseCredits(
|
||||
this.config.getBillingSettings().overageStrategy,
|
||||
getG1CreditBalance(this.paidTier),
|
||||
)
|
||||
: false;
|
||||
const modelIsEligible = isOverageEligibleModel(req.model);
|
||||
const shouldEnableCredits = modelIsEligible && autoUse;
|
||||
|
||||
const enabledCreditTypes = shouldEnableCredits
|
||||
? ([G1_CREDIT_TYPE] as string[])
|
||||
: undefined;
|
||||
|
||||
const responses =
|
||||
await this.requestStreamingPost<CaGenerateContentResponse>(
|
||||
'streamGenerateContent',
|
||||
@@ -114,7 +87,6 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
userPromptId,
|
||||
this.projectId,
|
||||
this.sessionId,
|
||||
enabledCreditTypes,
|
||||
),
|
||||
req.config?.abortSignal,
|
||||
);
|
||||
@@ -126,9 +98,6 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
return (async function* (
|
||||
server: CodeAssistServer,
|
||||
): AsyncGenerator<GenerateContentResponse> {
|
||||
let totalConsumed = 0;
|
||||
let lastRemaining = 0;
|
||||
|
||||
for await (const response of responses) {
|
||||
if (isFirst) {
|
||||
streamingLatency.firstMessageLatency = formatProtoJsonDuration(
|
||||
@@ -151,38 +120,8 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
req.config?.abortSignal,
|
||||
);
|
||||
|
||||
if (response.consumedCredits) {
|
||||
for (const credit of response.consumedCredits) {
|
||||
if (credit.creditType === G1_CREDIT_TYPE && credit.creditAmount) {
|
||||
totalConsumed += parseInt(credit.creditAmount, 10) || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.remainingCredits) {
|
||||
// Sum all G1 credit entries for consistency with getG1CreditBalance
|
||||
lastRemaining = response.remainingCredits.reduce((sum, credit) => {
|
||||
if (credit.creditType === G1_CREDIT_TYPE && credit.creditAmount) {
|
||||
return sum + (parseInt(credit.creditAmount, 10) || 0);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
server.updateCredits(response.remainingCredits);
|
||||
}
|
||||
|
||||
yield translatedResponse;
|
||||
}
|
||||
|
||||
// Emit credits used telemetry after the stream completes
|
||||
if (totalConsumed > 0 && server.config) {
|
||||
logBillingEvent(
|
||||
server.config,
|
||||
new CreditsUsedEvent(
|
||||
req.model ?? 'unknown',
|
||||
totalConsumed,
|
||||
lastRemaining,
|
||||
),
|
||||
);
|
||||
}
|
||||
})(this);
|
||||
}
|
||||
|
||||
@@ -200,10 +139,8 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
userPromptId,
|
||||
this.projectId,
|
||||
this.sessionId,
|
||||
undefined,
|
||||
),
|
||||
req.config?.abortSignal,
|
||||
GENERATE_CONTENT_RETRY_DELAY_IN_MILLISECONDS,
|
||||
);
|
||||
const duration = formatProtoJsonDuration(Date.now() - start);
|
||||
const streamingLatency: StreamingLatency = {
|
||||
@@ -221,29 +158,9 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
req.config?.abortSignal,
|
||||
);
|
||||
|
||||
if (response.remainingCredits) {
|
||||
this.updateCredits(response.remainingCredits);
|
||||
}
|
||||
|
||||
return translatedResponse;
|
||||
}
|
||||
|
||||
private updateCredits(remainingCredits: Credits[]): void {
|
||||
if (!this.paidTier) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace the G1 credits entries with the latest remaining amounts.
|
||||
// Non-G1 credits are preserved as-is.
|
||||
const nonG1Credits = (this.paidTier.availableCredits ?? []).filter(
|
||||
(c) => c.creditType !== G1_CREDIT_TYPE,
|
||||
);
|
||||
const updatedG1Credits = remainingCredits.filter(
|
||||
(c) => c.creditType === G1_CREDIT_TYPE,
|
||||
);
|
||||
this.paidTier.availableCredits = [...nonG1Credits, ...updatedG1Credits];
|
||||
}
|
||||
|
||||
async onboardUser(
|
||||
req: OnboardUserRequest,
|
||||
): Promise<LongRunningOperationResponse> {
|
||||
@@ -273,25 +190,6 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAvailableCredits(): Promise<void> {
|
||||
if (!this.paidTier) {
|
||||
return;
|
||||
}
|
||||
const res = await this.loadCodeAssist({
|
||||
cloudaicompanionProject: this.projectId,
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
duetProject: this.projectId,
|
||||
},
|
||||
mode: 'HEALTH_CHECK',
|
||||
});
|
||||
if (res.paidTier?.availableCredits) {
|
||||
this.paidTier.availableCredits = res.paidTier.availableCredits;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAdminControls(
|
||||
req: FetchAdminControlsRequest,
|
||||
): Promise<FetchAdminControlsResponse> {
|
||||
@@ -396,7 +294,6 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
method: string,
|
||||
req: object,
|
||||
signal?: AbortSignal,
|
||||
retryDelay: number = 100,
|
||||
): Promise<T> {
|
||||
const res = await this.client.request<T>({
|
||||
url: this.getMethodUrl(method),
|
||||
@@ -409,7 +306,6 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
body: JSON.stringify(req),
|
||||
signal,
|
||||
retryConfig: {
|
||||
retryDelay,
|
||||
retry: 3,
|
||||
noResponseRetries: 3,
|
||||
statusCodesToRetry: [
|
||||
@@ -465,7 +361,15 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
responseType: 'stream',
|
||||
body: JSON.stringify(req),
|
||||
signal,
|
||||
retry: false,
|
||||
retryConfig: {
|
||||
retry: 3,
|
||||
noResponseRetries: 3,
|
||||
statusCodesToRetry: [
|
||||
[429, 429],
|
||||
[499, 499],
|
||||
[500, 599],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return (async function* (): AsyncGenerator<T> {
|
||||
|
||||
@@ -52,7 +52,6 @@ export interface UserData {
|
||||
projectId: string;
|
||||
userTier: UserTierId;
|
||||
userTierName?: string;
|
||||
paidTier?: GeminiUserTier;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,7 +146,6 @@ export async function setupUser(
|
||||
loadRes.currentTier.id ??
|
||||
UserTierId.STANDARD,
|
||||
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
|
||||
paidTier: loadRes.paidTier ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,7 +157,6 @@ export async function setupUser(
|
||||
userTier:
|
||||
loadRes.paidTier?.id ?? loadRes.currentTier.id ?? UserTierId.STANDARD,
|
||||
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
|
||||
paidTier: loadRes.paidTier ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -39,41 +39,11 @@ export type ClientMetadataPluginType =
|
||||
| 'AIPLUGIN_INTELLIJ'
|
||||
| 'AIPLUGIN_STUDIO';
|
||||
|
||||
/**
|
||||
* Credit types that can be used for API consumption.
|
||||
*/
|
||||
export type CreditType = 'CREDIT_TYPE_UNSPECIFIED' | 'GOOGLE_ONE_AI';
|
||||
|
||||
/**
|
||||
* Represents a credit amount for a specific credit type.
|
||||
* Used in LoadCodeAssistResponse for available credits and
|
||||
* in GenerateContentResponse for consumed/remaining credits.
|
||||
*/
|
||||
export interface Credits {
|
||||
creditType: CreditType;
|
||||
creditAmount: string; // int64 represented as string in JSON
|
||||
}
|
||||
|
||||
/** Alias for Credits used in available_credits context */
|
||||
export type AvailableCredits = Credits;
|
||||
|
||||
/** Alias for Credits used in consumedCredits context */
|
||||
export type ConsumedCredits = Credits;
|
||||
|
||||
/** Alias for Credits used in remainingCredits context */
|
||||
export type RemainingCredits = Credits;
|
||||
|
||||
export interface LoadCodeAssistRequest {
|
||||
cloudaicompanionProject?: string;
|
||||
metadata: ClientMetadata;
|
||||
mode?: LoadCodeAssistMode;
|
||||
}
|
||||
|
||||
export type LoadCodeAssistMode =
|
||||
| 'MODE_UNSPECIFIED'
|
||||
| 'FULL_ELIGIBILITY_CHECK'
|
||||
| 'HEALTH_CHECK';
|
||||
|
||||
/**
|
||||
* Represents LoadCodeAssistResponse proto json field
|
||||
* http://google3/google/internal/cloud/code/v1internal/cloudcode.proto;l=224
|
||||
@@ -99,8 +69,6 @@ export interface GeminiUserTier {
|
||||
privacyNotice?: PrivacyNotice;
|
||||
hasAcceptedTos?: boolean;
|
||||
hasOnboardedPreviously?: boolean;
|
||||
/** Available AI credits for this tier (e.g., Google One AI credits) */
|
||||
availableCredits?: AvailableCredits[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
ContentGenerator,
|
||||
ContentGeneratorConfig,
|
||||
} from '../core/contentGenerator.js';
|
||||
import type { OverageStrategy } from '../billing/billing.js';
|
||||
import {
|
||||
AuthType,
|
||||
createContentGenerator,
|
||||
@@ -117,7 +116,6 @@ import {
|
||||
import { HookSystem } from '../hooks/index.js';
|
||||
import type {
|
||||
UserTierId,
|
||||
GeminiUserTier,
|
||||
RetrieveUserQuotaResponse,
|
||||
AdminControlsSettings,
|
||||
} from '../code_assist/types.js';
|
||||
@@ -367,7 +365,6 @@ import {
|
||||
SimpleExtensionLoader,
|
||||
} from '../utils/extensionLoader.js';
|
||||
import { McpClientManager } from '../tools/mcp-client-manager.js';
|
||||
import { type McpContext } from '../tools/mcp-client.js';
|
||||
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import {
|
||||
@@ -501,9 +498,7 @@ export interface ConfigParameters {
|
||||
experimentalZedIntegration?: boolean;
|
||||
listSessions?: boolean;
|
||||
deleteSession?: string;
|
||||
autoAddPolicy?: boolean;
|
||||
listExtensions?: boolean;
|
||||
|
||||
extensionLoader?: ExtensionLoader;
|
||||
enabledExtensions?: string[];
|
||||
enableExtensionReloading?: boolean;
|
||||
@@ -578,12 +573,9 @@ export interface ConfigParameters {
|
||||
agents?: AgentSettings;
|
||||
}>;
|
||||
enableConseca?: boolean;
|
||||
billing?: {
|
||||
overageStrategy?: OverageStrategy;
|
||||
};
|
||||
}
|
||||
|
||||
export class Config implements McpContext {
|
||||
export class Config {
|
||||
private toolRegistry!: ToolRegistry;
|
||||
private mcpClientManager?: McpClientManager;
|
||||
private allowedMcpServers: string[];
|
||||
@@ -659,7 +651,6 @@ export class Config implements McpContext {
|
||||
|
||||
private _activeModel: string;
|
||||
private readonly maxSessionTurns: number;
|
||||
private readonly autoAddPolicy: boolean;
|
||||
private readonly listSessions: boolean;
|
||||
private readonly deleteSession: string | undefined;
|
||||
private readonly listExtensions: boolean;
|
||||
@@ -763,10 +754,6 @@ export class Config implements McpContext {
|
||||
}>)
|
||||
| undefined;
|
||||
|
||||
private readonly billing: {
|
||||
overageStrategy: OverageStrategy;
|
||||
};
|
||||
|
||||
private readonly enableAgents: boolean;
|
||||
private agents: AgentSettings;
|
||||
private readonly enableEventDrivenScheduler: boolean;
|
||||
@@ -895,7 +882,6 @@ export class Config implements McpContext {
|
||||
params.experimentalZedIntegration ?? false;
|
||||
this.listSessions = params.listSessions ?? false;
|
||||
this.deleteSession = params.deleteSession;
|
||||
this.autoAddPolicy = params.autoAddPolicy ?? false;
|
||||
this.listExtensions = params.listExtensions ?? false;
|
||||
this._extensionLoader =
|
||||
params.extensionLoader ?? new SimpleExtensionLoader([]);
|
||||
@@ -1015,10 +1001,6 @@ export class Config implements McpContext {
|
||||
this.onModelChange = params.onModelChange;
|
||||
this.onReload = params.onReload;
|
||||
|
||||
this.billing = {
|
||||
overageStrategy: params.billing?.overageStrategy ?? 'ask',
|
||||
};
|
||||
|
||||
if (params.contextFileName) {
|
||||
setGeminiMdFilename(params.contextFileName);
|
||||
}
|
||||
@@ -1282,10 +1264,6 @@ export class Config implements McpContext {
|
||||
return this.contentGenerator?.userTierName;
|
||||
}
|
||||
|
||||
getUserPaidTier(): GeminiUserTier | undefined {
|
||||
return this.contentGenerator?.paidTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to the BaseLlmClient for stateless LLM operations.
|
||||
*/
|
||||
@@ -1600,19 +1578,6 @@ export class Config implements McpContext {
|
||||
this.hasAccessToPreviewModel = hasAccess;
|
||||
}
|
||||
|
||||
async refreshAvailableCredits(): Promise<void> {
|
||||
const codeAssistServer = getCodeAssistServer(this);
|
||||
if (!codeAssistServer) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await codeAssistServer.refreshAvailableCredits();
|
||||
} catch {
|
||||
// Non-fatal: proceed even if refresh fails.
|
||||
// The actual credit balance will be verified server-side.
|
||||
}
|
||||
}
|
||||
|
||||
async refreshUserQuota(): Promise<RetrieveUserQuotaResponse | undefined> {
|
||||
const codeAssistServer = getCodeAssistServer(this);
|
||||
if (!codeAssistServer || !codeAssistServer.projectId) {
|
||||
@@ -1774,33 +1739,6 @@ export class Config implements McpContext {
|
||||
return this.mcpClientManager;
|
||||
}
|
||||
|
||||
setUserInteractedWithMcp(): void {
|
||||
this.mcpClientManager?.setUserInteractedWithMcp();
|
||||
}
|
||||
|
||||
/** @deprecated Use getMcpClientManager().getLastError() directly */
|
||||
getLastMcpError(serverName: string): string | undefined {
|
||||
return this.mcpClientManager?.getLastError(serverName);
|
||||
}
|
||||
|
||||
emitMcpDiagnostic(
|
||||
severity: 'info' | 'warning' | 'error',
|
||||
message: string,
|
||||
error?: unknown,
|
||||
serverName?: string,
|
||||
): void {
|
||||
if (this.mcpClientManager) {
|
||||
this.mcpClientManager.emitDiagnostic(
|
||||
severity,
|
||||
message,
|
||||
error,
|
||||
serverName,
|
||||
);
|
||||
} else {
|
||||
coreEvents.emitFeedback(severity, message, error);
|
||||
}
|
||||
}
|
||||
|
||||
getAllowedMcpServers(): string[] | undefined {
|
||||
return this.allowedMcpServers;
|
||||
}
|
||||
@@ -2114,19 +2052,6 @@ export class Config implements McpContext {
|
||||
return this.telemetrySettings.outfile;
|
||||
}
|
||||
|
||||
getBillingSettings(): { overageStrategy: OverageStrategy } {
|
||||
return this.billing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the overage strategy at runtime.
|
||||
* Used to switch from 'ask' to 'always' after the user accepts credits
|
||||
* via the overage dialog, so subsequent API calls auto-include credits.
|
||||
*/
|
||||
setOverageStrategy(strategy: OverageStrategy): void {
|
||||
this.billing.overageStrategy = strategy;
|
||||
}
|
||||
|
||||
getTelemetryUseCollector(): boolean {
|
||||
return this.telemetrySettings.useCollector ?? false;
|
||||
}
|
||||
@@ -2219,18 +2144,6 @@ export class Config implements McpContext {
|
||||
return this.bugCommand;
|
||||
}
|
||||
|
||||
getAutoAddPolicy(): boolean {
|
||||
if (this.disableYoloMode) {
|
||||
return false;
|
||||
}
|
||||
return this.autoAddPolicy;
|
||||
}
|
||||
|
||||
setAutoAddPolicy(value: boolean): void {
|
||||
// @ts-expect-error - readonly property
|
||||
this.autoAddPolicy = value;
|
||||
}
|
||||
|
||||
getFileService(): FileDiscoveryService {
|
||||
if (!this.fileDiscoveryService) {
|
||||
this.fileDiscoveryService = new FileDiscoveryService(this.targetDir, {
|
||||
|
||||
@@ -121,7 +121,6 @@ export interface UpdatePolicy {
|
||||
argsPattern?: string;
|
||||
commandPrefix?: string | string[];
|
||||
mcpName?: string;
|
||||
isSensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolPolicyRejection {
|
||||
|
||||
@@ -152,7 +152,7 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -326,7 +326,7 @@ An approved plan is available for this task at \`/tmp/plans/feature-x.md\`.
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -607,7 +607,7 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -758,7 +758,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -897,7 +897,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -1019,7 +1019,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -1656,7 +1656,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -1807,7 +1807,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -1962,7 +1962,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -2117,7 +2117,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -2268,7 +2268,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -2411,7 +2411,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -2561,7 +2561,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -2712,7 +2712,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -3104,7 +3104,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -3255,7 +3255,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -3518,7 +3518,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
@@ -3669,7 +3669,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate.
|
||||
|
||||
## Security and Safety Rules
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command.
|
||||
- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
|
||||
@@ -17,7 +17,7 @@ import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { loadApiKey } from './apiKeyCredentialStorage.js';
|
||||
|
||||
import type { UserTierId, GeminiUserTier } from '../code_assist/types.js';
|
||||
import type { UserTierId } from '../code_assist/types.js';
|
||||
import { LoggingContentGenerator } from './loggingContentGenerator.js';
|
||||
import { InstallationManager } from '../utils/installationManager.js';
|
||||
import { FakeContentGenerator } from './fakeContentGenerator.js';
|
||||
@@ -49,8 +49,6 @@ export interface ContentGenerator {
|
||||
userTier?: UserTierId;
|
||||
|
||||
userTierName?: string;
|
||||
|
||||
paidTier?: GeminiUserTier;
|
||||
}
|
||||
|
||||
export enum AuthType {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '@google/genai';
|
||||
import { promises } from 'node:fs';
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import type { UserTierId, GeminiUserTier } from '../code_assist/types.js';
|
||||
import type { UserTierId } from '../code_assist/types.js';
|
||||
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
|
||||
import type { LlmRole } from '../telemetry/types.js';
|
||||
|
||||
@@ -44,7 +44,6 @@ export class FakeContentGenerator implements ContentGenerator {
|
||||
private callCounter = 0;
|
||||
userTier?: UserTierId;
|
||||
userTierName?: string;
|
||||
paidTier?: GeminiUserTier;
|
||||
|
||||
constructor(private readonly responses: FakeResponse[]) {}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '../telemetry/types.js';
|
||||
import type { LlmRole } from '../telemetry/llmRole.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { UserTierId, GeminiUserTier } from '../code_assist/types.js';
|
||||
import type { UserTierId } from '../code_assist/types.js';
|
||||
import {
|
||||
logApiError,
|
||||
logApiRequest,
|
||||
@@ -163,10 +163,6 @@ export class LoggingContentGenerator implements ContentGenerator {
|
||||
return this.wrapped.userTierName;
|
||||
}
|
||||
|
||||
get paidTier(): GeminiUserTier | undefined {
|
||||
return this.wrapped.paidTier;
|
||||
}
|
||||
|
||||
private logApiRequest(
|
||||
contents: Content[],
|
||||
model: string,
|
||||
|
||||
@@ -140,9 +140,6 @@ async function processIntent(
|
||||
// based on the availability service state (which is updated before this).
|
||||
return true;
|
||||
|
||||
case 'retry_with_credits':
|
||||
return true;
|
||||
|
||||
case 'stop':
|
||||
// Do not switch model on stop. User wants to stay on current model (and stop).
|
||||
return false;
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
export type FallbackIntent =
|
||||
| 'retry_always' // Retry with fallback model and stick to it for future requests.
|
||||
| 'retry_once' // Retry with fallback model for this request only.
|
||||
| 'retry_with_credits' // Retry the current request using Google One AI credits (and potentially future ones if strategy is 'always').
|
||||
| 'stop' // Switch to fallback for future requests, but stop the current request.
|
||||
| 'retry_later' // Stop the current request and do not fallback. Intend to try again later with the same model.
|
||||
| 'upgrade'; // Give user an option to upgrade the tier.
|
||||
|
||||
@@ -18,7 +18,6 @@ export * from './policy/policy-engine.js';
|
||||
export * from './policy/toml-loader.js';
|
||||
export * from './policy/config.js';
|
||||
export * from './policy/integrity.js';
|
||||
export * from './billing/index.js';
|
||||
export * from './confirmation-bus/types.js';
|
||||
export * from './confirmation-bus/message-bus.js';
|
||||
|
||||
@@ -77,7 +76,6 @@ export * from './utils/quotaErrorDetection.js';
|
||||
export * from './utils/userAccountManager.js';
|
||||
export * from './utils/authConsent.js';
|
||||
export * from './utils/googleQuotaErrors.js';
|
||||
export * from './utils/googleErrors.js';
|
||||
export * from './utils/fileUtils.js';
|
||||
export * from './utils/planUtils.js';
|
||||
export * from './utils/approvalModeUtils.js';
|
||||
@@ -100,7 +98,6 @@ export * from './utils/ignorePatterns.js';
|
||||
export * from './utils/partUtils.js';
|
||||
export * from './utils/promptIdContext.js';
|
||||
export * from './utils/thoughtUtils.js';
|
||||
export * from './utils/secure-browser-launcher.js';
|
||||
export * from './utils/debugLogger.js';
|
||||
export * from './utils/events.js';
|
||||
export * from './utils/extensionLoader.js';
|
||||
@@ -186,8 +183,6 @@ export { OAuthUtils } from './mcp/oauth-utils.js';
|
||||
|
||||
// Export telemetry functions
|
||||
export * from './telemetry/index.js';
|
||||
export * from './telemetry/billingEvents.js';
|
||||
export { logBillingEvent } from './telemetry/loggers.js';
|
||||
export { sessionId, createSessionId } from './utils/session.js';
|
||||
export * from './utils/compatibility.js';
|
||||
export * from './utils/browser.js';
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { createPolicyUpdater } from './config.js';
|
||||
import {
|
||||
MessageBusType,
|
||||
type UpdatePolicy,
|
||||
} from '../confirmation-bus/types.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import type { PolicyEngine } from './policy-engine.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { Storage } from '../config/storage.js';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('../utils/events.js', () => ({
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Policy Auto-add Safeguards', () => {
|
||||
let policyEngine: Mocked<PolicyEngine>;
|
||||
let messageBus: Mocked<MessageBus>;
|
||||
let storage: Mocked<Storage>;
|
||||
let updateCallback: (msg: UpdatePolicy) => Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
policyEngine = {
|
||||
addRule: vi.fn(),
|
||||
} as unknown as Mocked<PolicyEngine>;
|
||||
messageBus = {
|
||||
subscribe: vi.fn((type, cb) => {
|
||||
if (type === MessageBusType.UPDATE_POLICY) {
|
||||
updateCallback = cb;
|
||||
}
|
||||
}),
|
||||
publish: vi.fn(),
|
||||
} as unknown as Mocked<MessageBus>;
|
||||
storage = {
|
||||
getWorkspacePoliciesDir: vi.fn().mockReturnValue('/tmp/policies'),
|
||||
getAutoSavedPolicyPath: vi
|
||||
.fn()
|
||||
.mockReturnValue('/tmp/policies/autosaved.toml'),
|
||||
} as unknown as Mocked<Storage>;
|
||||
|
||||
const enoent = new Error('ENOENT');
|
||||
(enoent as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockRejectedValue(enoent);
|
||||
vi.mocked(fs.open).mockResolvedValue({
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as fs.FileHandle);
|
||||
vi.mocked(fs.rename).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should skip persistence for wildcard toolName', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, storage);
|
||||
expect(updateCallback).toBeDefined();
|
||||
|
||||
await updateCallback({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: '*',
|
||||
persist: true,
|
||||
});
|
||||
|
||||
expect(fs.open).not.toHaveBeenCalled();
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('Policy for all tools was not auto-saved'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip persistence for broad argsPattern (.*)', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, storage);
|
||||
|
||||
await updateCallback({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'test-tool',
|
||||
argsPattern: '.*',
|
||||
persist: true,
|
||||
});
|
||||
|
||||
expect(fs.open).not.toHaveBeenCalled();
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('was not auto-saved for safety reasons'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow persistence for specific argsPattern', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, storage);
|
||||
|
||||
await updateCallback({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'test-tool',
|
||||
argsPattern: '.*"file_path":"test.txt".*',
|
||||
persist: true,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fs.open).toHaveBeenCalled();
|
||||
});
|
||||
expect(fs.rename).toHaveBeenCalledWith(
|
||||
expect.stringContaining('autosaved.toml'),
|
||||
'/tmp/policies/autosaved.toml',
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip persistence for sensitive tool with no pattern', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, storage);
|
||||
|
||||
await updateCallback({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'sensitive-tool',
|
||||
isSensitive: true,
|
||||
persist: true,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
'Broad approval for "sensitive-tool" was not auto-saved',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(fs.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip persistence for MCP tool with no pattern', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, storage);
|
||||
|
||||
const mcpToolName = 'mcp-server__sensitive-tool';
|
||||
await updateCallback({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: mcpToolName,
|
||||
mcpName: 'mcp-server',
|
||||
persist: true,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
`Broad approval for "${mcpToolName}" was not auto-saved`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(fs.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should de-duplicate identical rules when auto-saving', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, storage);
|
||||
|
||||
// First call: file doesn't exist (ENOENT already mocked in beforeEach)
|
||||
await updateCallback({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'read_file',
|
||||
argsPattern: '.*"file_path":"test.txt".*',
|
||||
persist: true,
|
||||
isSensitive: true,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fs.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Mock file existing with the rule for the second call
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
'[[rule]]\ntoolName = "read_file"\ndecision = "allow"\npriority = 100\nargsPattern = \'.*"file_path":"test.txt".*\'\n',
|
||||
);
|
||||
|
||||
// Second call: should skip persistence because it's a duplicate
|
||||
await updateCallback({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'read_file',
|
||||
argsPattern: '.*"file_path":"test.txt".*',
|
||||
persist: true,
|
||||
isSensitive: true,
|
||||
});
|
||||
|
||||
// Still only 1 call to fs.open
|
||||
expect(fs.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -514,40 +514,6 @@ export function createPolicyUpdater(
|
||||
}
|
||||
|
||||
if (message.persist) {
|
||||
// Validation safeguards for auto-adding to persistent policy
|
||||
if (!toolName || toolName === '*') {
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
'Policy for all tools was not auto-saved for safety reasons. You can add it manually to your policy file if desired.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const broadPatternRegex = /^\s*(\^)?\.\*(\$)?\s*$/;
|
||||
if (
|
||||
message.argsPattern &&
|
||||
broadPatternRegex.test(message.argsPattern)
|
||||
) {
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
`Policy for "${toolName}" with all arguments was not auto-saved for safety reasons. You can add it manually to your policy file if desired.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isMcpTool = !!message.mcpName;
|
||||
if (
|
||||
(message.isSensitive || isMcpTool) &&
|
||||
!message.argsPattern &&
|
||||
!message.commandPrefix
|
||||
) {
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
`Broad approval for "${toolName}" was not auto-saved for safety reasons. Approvals for sensitive tools must be specific to be auto-saved.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
persistenceQueue = persistenceQueue.then(async () => {
|
||||
try {
|
||||
const policyFile = storage.getAutoSavedPolicyPath();
|
||||
@@ -604,36 +570,6 @@ export function createPolicyUpdater(
|
||||
newRule.argsPattern = message.argsPattern;
|
||||
}
|
||||
|
||||
// De-duplicate: check if an identical rule already exists
|
||||
const isDuplicate = existingData.rule.some((r) => {
|
||||
const matchesTool = r.toolName === newRule.toolName;
|
||||
const matchesMcp = r.mcpName === newRule.mcpName;
|
||||
const matchesPattern = r.argsPattern === newRule.argsPattern;
|
||||
|
||||
const rPrefix = r.commandPrefix;
|
||||
const newPrefix = newRule.commandPrefix;
|
||||
|
||||
let matchesPrefix = false;
|
||||
if (Array.isArray(rPrefix) && Array.isArray(newPrefix)) {
|
||||
matchesPrefix =
|
||||
rPrefix.length === newPrefix.length &&
|
||||
rPrefix.every((v, i) => v === newPrefix[i]);
|
||||
} else {
|
||||
matchesPrefix = rPrefix === newPrefix;
|
||||
}
|
||||
|
||||
return (
|
||||
matchesTool && matchesMcp && matchesPattern && matchesPrefix
|
||||
);
|
||||
});
|
||||
|
||||
if (isDuplicate) {
|
||||
debugLogger.debug(
|
||||
`Policy rule for ${toolName} already exists in persistent policy, skipping.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to rules
|
||||
existingData.rule.push(newRule);
|
||||
|
||||
|
||||
@@ -179,7 +179,6 @@ describe('createPolicyUpdater', () => {
|
||||
toolName,
|
||||
persist: true,
|
||||
mcpName,
|
||||
argsPattern: '{"issue": ".*"}',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -190,7 +189,6 @@ describe('createPolicyUpdater', () => {
|
||||
const writtenContent = writeCall[0] as string;
|
||||
expect(writtenContent).toContain(`mcpName = "${mcpName}"`);
|
||||
expect(writtenContent).toContain(`toolName = "${simpleToolName}"`);
|
||||
expect(writtenContent).toContain(`argsPattern = '{"issue": ".*"}'`);
|
||||
expect(writtenContent).toContain('priority = 200');
|
||||
});
|
||||
|
||||
@@ -220,7 +218,6 @@ describe('createPolicyUpdater', () => {
|
||||
toolName,
|
||||
persist: true,
|
||||
mcpName,
|
||||
argsPattern: '{"query": ".*"}',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -243,7 +240,5 @@ describe('createPolicyUpdater', () => {
|
||||
} catch {
|
||||
expect(writtenContent).toContain(`toolName = 'search"tool"'`);
|
||||
}
|
||||
|
||||
expect(writtenContent).toContain(`argsPattern = '{"query": ".*"}'`);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user