mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55086b9790 | |||
| 9bc9a31923 | |||
| 999f9b4bf1 | |||
| 81b171c1b4 | |||
| f883f74ad5 | |||
| 999516ddf7 | |||
| f9331b1602 | |||
| ed4b440ba0 | |||
| a6841f41d2 | |||
| a585bfa99e | |||
| c15547156d | |||
| 76414c1cc0 | |||
| d1e040aa91 | |||
| ce2eba283b | |||
| c5109d7531 | |||
| dbeda91ec4 | |||
| 4c0a24119b | |||
| f25944bdc6 | |||
| 4a83eb2402 | |||
| 20e67c7bda | |||
| 9ab79b712c | |||
| 16e06adb46 | |||
| 48ad6983a3 | |||
| ad994cfe8b | |||
| 5eb817c426 | |||
| 562d84545a | |||
| 06dcf216e1 |
Binary file not shown.
|
After Width: | Height: | Size: 387 KiB |
+30
-30
@@ -1,12 +1,13 @@
|
||||
# Gemini CLI model selection (`/model` command)
|
||||
|
||||
Select your Gemini CLI model. The `/model` command opens a dialog where you can
|
||||
configure the model used by Gemini CLI, giving you more control over your
|
||||
results.
|
||||
Select your Gemini CLI model. The `/model` command lets you configure the model
|
||||
used by Gemini CLI, giving you more control over your results. Use **Pro**
|
||||
models for complex tasks and reasoning, **Flash** models for high speed results,
|
||||
or the (recommended) **Auto** setting to choose the best model for your tasks.
|
||||
|
||||
**Note:** The `/model` command (and the `--model` flag) does not override the
|
||||
model used by sub-agents. Consequently, even when using the `/model` flag you
|
||||
may see other models used in your model usage reports.
|
||||
> **Note:** The `/model` command (and the `--model` flag) does not override the
|
||||
> model used by sub-agents. Consequently, even when using the `/model` flag you
|
||||
> may see other models used in your model usage reports.
|
||||
|
||||
## How to use the `/model` command
|
||||
|
||||
@@ -16,26 +17,25 @@ Use the following command in Gemini CLI:
|
||||
/model
|
||||
```
|
||||
|
||||
Running this command will open a dialog with your model options:
|
||||
Running this command will open a dialog with your options:
|
||||
|
||||
| Option | Description | Models |
|
||||
| ------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Auto (recommended) | Let the system choose the best model for your task. | gemini-3-pro-preview (if enabled), gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite |
|
||||
| Pro | For complex tasks that require deep reasoning and creativity. | gemini-3-pro-preview (if enabled), gemini-2.5-pro |
|
||||
| Flash | For tasks that need a balance of speed and reasoning. | gemini-2.5-flash |
|
||||
| Flash-Lite | For simple tasks that need to be done quickly. | gemini-2.5-flash-lite |
|
||||
| Option | Description | Models |
|
||||
| ----------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview (if enabled), gemini-3-flash-preview (if enabled) |
|
||||
| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash |
|
||||
| Manual | Select a specific model. | Any available model. |
|
||||
|
||||
### Gemini 3 Pro and preview features
|
||||
We recommend selecting one of the above **Auto** options. However, you can
|
||||
select **Manual** to select a specific model from those available.
|
||||
|
||||
Note: Gemini 3 is not currently available on all account types. To learn more
|
||||
about Gemini 3 access, refer to
|
||||
[Gemini 3 Pro on Gemini CLI](../get-started/gemini-3.md).
|
||||
### Gemini 3 and preview features
|
||||
|
||||
To enable Gemini 3 Pro (if available), enable
|
||||
[**Preview features** by using the `settings` command](../cli/settings.md). Once
|
||||
enabled, Gemini CLI will attempt to use Gemini 3 Pro when you select **Auto** or
|
||||
**Pro**. Both **Auto** and **Pro** will try to use Gemini 3 Pro before falling
|
||||
back to Gemini 2.5 Pro.
|
||||
> **Note:** Gemini 3 is not currently available on all account types. To learn
|
||||
> more about Gemini 3 access, refer to
|
||||
> [Gemini 3 on Gemini CLI](../get-started/gemini-3.md).
|
||||
|
||||
To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable
|
||||
[**Preview Features** by using the `settings` command](../cli/settings.md).
|
||||
|
||||
You can also use the `--model` flag to specify a particular Gemini model on
|
||||
startup. For more details, refer to the
|
||||
@@ -46,16 +46,16 @@ Gemini CLI.
|
||||
|
||||
## Best practices for model selection
|
||||
|
||||
- **Default to Auto (recommended).** For most users, the _Auto (recommended)_
|
||||
model provides a balance between speed and performance, automatically
|
||||
selecting the correct model based on the complexity of the task. Example:
|
||||
Developing a web application could include a mix of complex tasks (building
|
||||
architecture and scaffolding the project) and simple tasks (generating CSS).
|
||||
- **Default to Auto.** For most users, the _Auto_ option model provides a
|
||||
balance between speed and performance, automatically selecting the correct
|
||||
model based on the complexity of the task. Example: Developing a web
|
||||
application could include a mix of complex tasks (building architecture and
|
||||
scaffolding the project) and simple tasks (generating CSS).
|
||||
|
||||
- **Switch to Pro if you aren't getting the results you want.** If you think you
|
||||
need your model to be a little "smarter," use Pro. Pro will provide you with
|
||||
the highest levels of reasoning and creativity. Example: A complex or
|
||||
multi-stage debugging task.
|
||||
need your model to be a little "smarter," you can manually select Pro. Pro
|
||||
will provide you with the highest levels of reasoning and creativity. Example:
|
||||
A complex or multi-stage debugging task.
|
||||
|
||||
- **Switch to Flash or Flash-Lite if you need faster results.** If you need a
|
||||
simple response quickly, Flash or Flash-Lite is the best option. Example:
|
||||
|
||||
@@ -367,6 +367,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-preview": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
"extends": "chat-base-2.5",
|
||||
"modelConfig": {
|
||||
@@ -496,6 +502,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
},
|
||||
"chat-compression-3-flash": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"chat-compression-2.5-pro": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-pro"
|
||||
@@ -720,6 +731,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.enablePermanentToolApproval`** (boolean):
|
||||
- **Description:** Enable the "Allow for all future sessions" option in tool
|
||||
confirmation dialogs.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`security.blockGitExtensions`** (boolean):
|
||||
- **Description:** Blocks installing and loading extensions from Git.
|
||||
- **Default:** `false`
|
||||
@@ -821,7 +837,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.codebaseInvestigatorSettings.model`** (string):
|
||||
- **Description:** The model to use for the Codebase Investigator agent.
|
||||
- **Default:** `"pro"`
|
||||
- **Default:** `"auto"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `hooks`
|
||||
|
||||
@@ -24,7 +24,7 @@ Rename the photos in my "photos" directory based on their contents.
|
||||
|
||||
Result: Gemini will ask for permission to rename your files.
|
||||
|
||||
Select **Yes, allow once.** and your files will be renamed:
|
||||
Select **Allow once** and your files will be renamed:
|
||||
|
||||
```bash
|
||||
photos/yellow_flowers.png
|
||||
|
||||
@@ -1,56 +1,37 @@
|
||||
# Gemini 3 Pro on Gemini CLI
|
||||
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
|
||||
|
||||
We’re excited to bring Gemini 3 Pro to Gemini CLI. Gemini 3 Pro is **currently
|
||||
available** on Gemini CLI to all of the following subscribers:
|
||||
Gemini 3 Pro and Gemini 3 Flash are now available on Gemini CLI! Currently, most
|
||||
paid customers of Gemini CLI will have access to both Gemini 3 Pro and Gemini 3
|
||||
Flash, including the following subscribers:
|
||||
|
||||
- Google AI Ultra (except Google AI Ultra for Business).
|
||||
- Google AI Pro.
|
||||
- Gemini Code Assist Standard (requires
|
||||
- Google AI Pro and Google AI Ultra (excluding business customers).
|
||||
- Gemini Code Assist Standard and Enterprise (requires
|
||||
[administrative enablement](#administrator-instructions)).
|
||||
- Gemini Code Assist Enterprise (requires
|
||||
[administrative enablement](#administrator-instructions)).
|
||||
- Paid Gemini API key holders.
|
||||
- Paid Vertex API key holders.
|
||||
- Paid Gemini API and Vertex API key holders.
|
||||
|
||||
For **everyone else**, we're gradually expanding access
|
||||
[through a waitlist](https://goo.gle/geminicli-waitlist-signup). If you don't
|
||||
have one of the listed subscriptions, sign up for the waitlist to access Gemini
|
||||
3 Pro once approved.
|
||||
For free tier users:
|
||||
|
||||
**Note:** Whether you’re automatically granted access or accepted from the
|
||||
waitlist, you’ll still need to enable Gemini 3 Pro
|
||||
[using the `/settings` command](../cli/settings.md).
|
||||
- If you signed up for the waitlist, please check your email for details. We’ve
|
||||
onboarded everyone who signed up to the previously available waitlist.
|
||||
- If you were not on our waitlist, we’re rolling out additional access gradually
|
||||
to ensure the experience remains fast and reliable. Stay tuned for more
|
||||
details.
|
||||
|
||||
## How to join the waitlist
|
||||
## How to get started with Gemini 3 on Gemini CLI
|
||||
|
||||
Users not automatically granted access will need to join the waitlist. Follow
|
||||
these instructions to sign up:
|
||||
Get started by upgrading Gemini CLI to the latest version (0.21.1):
|
||||
|
||||
- Install Gemini CLI.
|
||||
- Authenticate using the **Login with Google** option. You’ll see a banner that
|
||||
says “Gemini 3 is now available.” If you do not see this banner, update your
|
||||
installation of Gemini CLI to the most recent version.
|
||||
- Fill out this Google form:
|
||||
[Access Gemini 3 in Gemini CLI](https://goo.gle/geminicli-waitlist-signup).
|
||||
Provide the email address of the account you used to authenticate with Gemini
|
||||
CLI.
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@latest
|
||||
```
|
||||
|
||||
Users will be onboarded in batches, subject to availability. When you’ve been
|
||||
granted access to Gemini 3 Pro, you’ll receive an acceptance email to your
|
||||
submitted email address.
|
||||
After you’ve confirmed your version is 0.21.1 or later:
|
||||
|
||||
**Note:** Please wait until you have been approved to use Gemini 3 Pro to enable
|
||||
**Preview Features**. If enabled early, the CLI will fallback to Gemini 2.5 Pro.
|
||||
1. Use the `/settings` command in Gemini CLI.
|
||||
2. Toggle **Preview Features** to `true`.
|
||||
3. Run `/model` and select **Auto (Gemini 3)**.
|
||||
|
||||
## How to use Gemini 3 Pro with Gemini CLI
|
||||
|
||||
Once you receive your acceptance email–or if you are automatically granted
|
||||
access–you still need to enable Gemini 3 Pro within Gemini CLI.
|
||||
|
||||
To enable Gemini 3 Pro, use the `/settings` command in Gemini CLI and set
|
||||
**Preview Features** to `true`.
|
||||
|
||||
For more information, see [Gemini CLI Settings](../cli/settings.md).
|
||||
For more information, see [Gemini CLI model selection](../cli/model.md).
|
||||
|
||||
### Usage limits and fallback
|
||||
|
||||
@@ -68,10 +49,10 @@ There may be times when the Gemini 3 Pro model is overloaded. When that happens,
|
||||
Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro
|
||||
or fallback to Gemini 2.5 Pro.
|
||||
|
||||
**Note:** The **Keep trying** option uses exponential backoff, in which Gemini
|
||||
CLI waits longer between each retry, when the system is busy. If the retry
|
||||
doesn't happen immediately, please wait a few minutes for the request to
|
||||
process.
|
||||
> **Note:** The **Keep trying** option uses exponential backoff, in which Gemini
|
||||
> CLI waits longer between each retry, when the system is busy. If the retry
|
||||
> doesn't happen immediately, please wait a few minutes for the request to
|
||||
> process.
|
||||
|
||||
### Model selection and routing types
|
||||
|
||||
@@ -92,7 +73,7 @@ manage your usage limits:
|
||||
To learn more about selecting a model and routing, refer to
|
||||
[Gemini CLI Model Selection](../cli/model.md).
|
||||
|
||||
## How to enable Gemini 3 Pro with Gemini CLI on Gemini Code Assist
|
||||
## How to enable Gemini 3 with Gemini CLI on Gemini Code Assist
|
||||
|
||||
If you're using Gemini Code Assist Standard or Gemini Code Assist Enterprise,
|
||||
enabling Gemini 3 Pro on Gemini CLI requires configuring your release channels.
|
||||
@@ -123,7 +104,7 @@ then:
|
||||
- Use the `/settings` command.
|
||||
- Set **Preview Features** to `true`.
|
||||
|
||||
Restart Gemini CLI and you should have access to Gemini 3 Pro.
|
||||
Restart Gemini CLI and you should have access to Gemini 3.
|
||||
|
||||
## Need help?
|
||||
|
||||
|
||||
@@ -128,8 +128,8 @@ editor.
|
||||
You can also **modify the suggested changes** directly in the diff view before
|
||||
accepting them.
|
||||
|
||||
If you select ‘Yes, allow always’ in the CLI, changes will no longer show up in
|
||||
the IDE as they will be auto-accepted.
|
||||
If you select ‘Allow for this session’ in the CLI, changes will no longer show
|
||||
up in the IDE as they will be auto-accepted.
|
||||
|
||||
## Using with sandboxing
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
"slug": "docs/get-started"
|
||||
},
|
||||
{
|
||||
"label": "Gemini 3 Pro on Gemini CLI",
|
||||
"label": "Gemini 3 on Gemini CLI",
|
||||
"slug": "docs/get-started/gemini-3"
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17671,7 +17671,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.2",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17981,7 +17981,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18083,7 +18083,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google-cloud/logging": "^11.2.1",
|
||||
@@ -18228,7 +18228,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
@@ -18239,7 +18239,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-nightly.20251213.977248e09"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.22.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-nightly.20251213.977248e09"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.22.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
|
||||
@@ -1283,7 +1283,7 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('auto');
|
||||
expect(config.getModel()).toBe('auto-gemini-2.5');
|
||||
});
|
||||
|
||||
it('always prefers model from argv', async () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
loadServerHierarchicalMemory,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
getVersion,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
|
||||
@@ -569,7 +570,9 @@ export async function loadCliConfig(
|
||||
extraExcludes.length > 0 ? extraExcludes : undefined,
|
||||
);
|
||||
|
||||
const defaultModel = DEFAULT_GEMINI_MODEL_AUTO;
|
||||
const defaultModel = settings.general?.previewFeatures
|
||||
? PREVIEW_GEMINI_MODEL_AUTO
|
||||
: DEFAULT_GEMINI_MODEL_AUTO;
|
||||
const resolvedModel: string =
|
||||
argv.model ||
|
||||
process.env['GEMINI_MODEL'] ||
|
||||
|
||||
@@ -291,7 +291,7 @@ describe('Settings Loading and Merging', () => {
|
||||
theme: 'legacy-dark',
|
||||
vimMode: true,
|
||||
contextFileName: 'LEGACY_CONTEXT.md',
|
||||
model: 'gemini-pro',
|
||||
model: 'gemini-2.5-pro',
|
||||
mcpServers: {
|
||||
'legacy-server-1': {
|
||||
command: 'npm',
|
||||
@@ -329,7 +329,7 @@ describe('Settings Loading and Merging', () => {
|
||||
fileName: 'LEGACY_CONTEXT.md',
|
||||
},
|
||||
model: {
|
||||
name: 'gemini-pro',
|
||||
name: 'gemini-2.5-pro',
|
||||
},
|
||||
mcpServers: {
|
||||
'legacy-server-1': {
|
||||
@@ -1960,7 +1960,7 @@ describe('Settings Loading and Merging', () => {
|
||||
usageStatisticsEnabled: false,
|
||||
},
|
||||
model: {
|
||||
name: 'gemini-pro',
|
||||
name: 'gemini-2.5-pro',
|
||||
},
|
||||
context: {
|
||||
fileName: 'CONTEXT.md',
|
||||
@@ -1999,7 +1999,7 @@ describe('Settings Loading and Merging', () => {
|
||||
vimMode: true,
|
||||
theme: 'dark',
|
||||
usageStatisticsEnabled: false,
|
||||
model: 'gemini-pro',
|
||||
model: 'gemini-2.5-pro',
|
||||
contextFileName: 'CONTEXT.md',
|
||||
includeDirectories: ['/src'],
|
||||
sandbox: true,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
DEFAULT_MODEL_CONFIGS,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { CustomTheme } from '../ui/themes/theme.js';
|
||||
import type { SessionRetentionSettings } from './settings.js';
|
||||
@@ -1164,6 +1164,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Disable YOLO mode, even if enabled by a flag.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enablePermanentToolApproval: {
|
||||
type: 'boolean',
|
||||
label: 'Allow Permanent Tool Approval',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the "Allow for all future sessions" option in tool confirmation dialogs.',
|
||||
showInDialog: true,
|
||||
},
|
||||
blockGitExtensions: {
|
||||
type: 'boolean',
|
||||
label: 'Blocks extensions from Git',
|
||||
@@ -1394,7 +1404,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Model',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: GEMINI_MODEL_ALIAS_PRO,
|
||||
default: GEMINI_MODEL_ALIAS_AUTO,
|
||||
description:
|
||||
'The model to use for the Codebase Investigator agent.',
|
||||
showInDialog: false,
|
||||
|
||||
@@ -91,6 +91,7 @@ const mockConfig = {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getEnableInteractiveShell: () => true,
|
||||
getPreviewFeatures: () => false,
|
||||
};
|
||||
|
||||
const configProxy = new Proxy(mockConfig, {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { render } from '../test-utils/render.js';
|
||||
import { waitFor } from '../test-utils/async.js';
|
||||
import { cleanup } from 'ink-testing-library';
|
||||
import { act, useContext } from 'react';
|
||||
import { act, useContext, type ReactElement } from 'react';
|
||||
import { AppContainer } from './AppContainer.js';
|
||||
import { SettingsContext } from './contexts/SettingsContext.js';
|
||||
import {
|
||||
@@ -71,6 +71,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
})),
|
||||
enableMouseEvents: vi.fn(),
|
||||
disableMouseEvents: vi.fn(),
|
||||
FileDiscoveryService: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
})),
|
||||
startupProfiler: {
|
||||
flush: vi.fn(),
|
||||
start: vi.fn(),
|
||||
end: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
@@ -344,7 +352,7 @@ describe('AppContainer State Management', () => {
|
||||
// Add other properties if AppContainer uses them
|
||||
});
|
||||
mockedUseLogger.mockReturnValue({
|
||||
getPreviousUserMessages: vi.fn().mockReturnValue(new Promise(() => {})),
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
mockedUseInputHistoryStore.mockReturnValue({
|
||||
inputHistory: [],
|
||||
@@ -361,6 +369,8 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock config's getTargetDir to return consistent workspace directory
|
||||
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
|
||||
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
|
||||
vi.spyOn(mockConfig, 'getDebugMode').mockReturnValue(false);
|
||||
|
||||
mockExtensionManager = vi.mockObject({
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
@@ -403,17 +413,25 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('renders without crashing with minimal props', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('renders with startup warnings', async () => {
|
||||
const startupWarnings = ['Warning 1', 'Warning 2'];
|
||||
|
||||
const { unmount } = renderAppContainer({ startupWarnings });
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ startupWarnings });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -424,11 +442,15 @@ describe('AppContainer State Management', () => {
|
||||
themeError: 'Failed to load theme',
|
||||
};
|
||||
|
||||
const { unmount } = renderAppContainer({
|
||||
initResult: initResultWithError,
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({
|
||||
initResult: initResultWithError,
|
||||
});
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('handles debug mode state', () => {
|
||||
@@ -443,29 +465,45 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
describe('Context Providers', () => {
|
||||
it('provides AppContext with correct values', async () => {
|
||||
const { unmount } = renderAppContainer({ version: '2.0.0' });
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ version: '2.0.0' });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Should render and unmount cleanly
|
||||
expect(() => unmount()).not.toThrow();
|
||||
expect(() => unmount!()).not.toThrow();
|
||||
});
|
||||
|
||||
it('provides UIStateContext with state management', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('provides UIActionsContext with action handlers', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('provides ConfigContext with config object', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -480,9 +518,13 @@ describe('AppContainer State Management', () => {
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const { unmount } = renderAppContainer({ settings: settingsAllHidden });
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ settings: settingsAllHidden });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('handles settings with memory usage enabled', async () => {
|
||||
@@ -495,9 +537,13 @@ describe('AppContainer State Management', () => {
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const { unmount } = renderAppContainer({ settings: settingsWithMemory });
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ settings: settingsWithMemory });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -505,9 +551,13 @@ describe('AppContainer State Management', () => {
|
||||
it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])(
|
||||
'handles version format: %s',
|
||||
async (version) => {
|
||||
const { unmount } = renderAppContainer({ version });
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ version });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -529,9 +579,13 @@ describe('AppContainer State Management', () => {
|
||||
merged: {},
|
||||
} as LoadedSettings;
|
||||
|
||||
const { unmount } = renderAppContainer({ settings: undefinedSettings });
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ settings: undefinedSettings });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -860,12 +914,16 @@ describe('AppContainer State Management', () => {
|
||||
describe('Quota and Fallback Integration', () => {
|
||||
it('passes a null proQuotaRequest to UIStateContext by default', async () => {
|
||||
// The default mock from beforeEach already sets proQuotaRequest to null
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Assert that the context value is as expected
|
||||
expect(capturedUIState.proQuotaRequest).toBeNull();
|
||||
});
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => {
|
||||
@@ -881,12 +939,16 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Assert: The mock request is correctly passed through the context
|
||||
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
|
||||
});
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('passes the handleProQuotaChoice function to UIActionsContext', async () => {
|
||||
@@ -898,7 +960,11 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Assert: The action in the context is the mock handler we provided
|
||||
expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler);
|
||||
@@ -909,7 +975,7 @@ describe('AppContainer State Management', () => {
|
||||
capturedUIActions.handleProQuotaChoice('retry_later');
|
||||
});
|
||||
expect(mockHandler).toHaveBeenCalledWith('retry_later');
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1327,13 +1393,17 @@ describe('AppContainer State Management', () => {
|
||||
activePtyId: 'some-id',
|
||||
});
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
|
||||
const lastCall =
|
||||
resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1];
|
||||
// Check the height argument specifically
|
||||
expect(lastCall[2]).toBe(1);
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1672,11 +1742,15 @@ describe('AppContainer State Management', () => {
|
||||
closeModelDialog: vi.fn(),
|
||||
});
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
expect(capturedUIState.isModelDialogOpen).toBe(true);
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('should provide model dialog actions in the UIActionsContext', async () => {
|
||||
@@ -1688,7 +1762,11 @@ describe('AppContainer State Management', () => {
|
||||
closeModelDialog: mockCloseModelDialog,
|
||||
});
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Verify that the actions are correctly passed through context
|
||||
@@ -1696,13 +1774,17 @@ describe('AppContainer State Management', () => {
|
||||
capturedUIActions.closeModelDialog();
|
||||
});
|
||||
expect(mockCloseModelDialog).toHaveBeenCalled();
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoreEvents Integration', () => {
|
||||
it('subscribes to UserFeedback and drains backlog on mount', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
expect(mockCoreEvents.on).toHaveBeenCalledWith(
|
||||
@@ -1710,14 +1792,18 @@ describe('AppContainer State Management', () => {
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('unsubscribes from UserFeedback on unmount', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
unmount();
|
||||
unmount!();
|
||||
|
||||
expect(mockCoreEvents.off).toHaveBeenCalledWith(
|
||||
CoreEvent.UserFeedback,
|
||||
@@ -1726,7 +1812,11 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('adds history item when UserFeedback event is received', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Get the registered handler
|
||||
@@ -1751,14 +1841,18 @@ describe('AppContainer State Management', () => {
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('updates currentModel when ModelChanged event is received', async () => {
|
||||
// Arrange: Mock initial model
|
||||
vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model');
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState?.currentModel).toBe('initial-model');
|
||||
});
|
||||
@@ -1770,13 +1864,15 @@ describe('AppContainer State Management', () => {
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
// Act: Simulate ModelChanged event
|
||||
// Update config mock to return new model since the handler reads from config
|
||||
vi.spyOn(mockConfig, 'getModel').mockReturnValue('new-model');
|
||||
act(() => {
|
||||
handler({ model: 'new-model' });
|
||||
});
|
||||
|
||||
// Assert: Verify model is updated
|
||||
expect(capturedUIState.currentModel).toBe('new-model');
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1799,10 +1895,14 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// The main assertion is that the render does not throw.
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
describe('Banner Text', () => {
|
||||
@@ -1812,10 +1912,14 @@ describe('AppContainer State Management', () => {
|
||||
authType: AuthType.USE_GEMINI,
|
||||
apiKey: 'fake-key',
|
||||
});
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.bannerData.defaultText).toBeDefined();
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1838,7 +1942,11 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
const { onCancelSubmit } = extractUseGeminiStreamArgs(
|
||||
@@ -1851,7 +1959,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
expect(mockSetText).toHaveBeenCalledWith('');
|
||||
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => {
|
||||
@@ -1862,7 +1970,11 @@ describe('AppContainer State Management', () => {
|
||||
initializeFromLogger: vi.fn(),
|
||||
});
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(capturedUIState.userMessages).toContain('previous message'),
|
||||
);
|
||||
@@ -1877,7 +1989,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
expect(mockSetText).toHaveBeenCalledWith('previous message');
|
||||
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('input history is independent from conversation history (survives /clear)', async () => {
|
||||
@@ -1890,7 +2002,13 @@ describe('AppContainer State Management', () => {
|
||||
initializeFromLogger: vi.fn(),
|
||||
});
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let rerender: (tree: ReactElement) => void;
|
||||
let unmount;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
rerender = result.rerender;
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
// Verify userMessages is populated from inputHistory
|
||||
await waitFor(() =>
|
||||
@@ -1908,12 +2026,17 @@ describe('AppContainer State Management', () => {
|
||||
loadHistory: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
// Rerender to apply the new mock.
|
||||
rerender(getAppContainer());
|
||||
});
|
||||
|
||||
// Verify that userMessages still contains the input history
|
||||
// (it should not be affected by clearing conversation history)
|
||||
expect(capturedUIState.userMessages).toContain('first prompt');
|
||||
expect(capturedUIState.userMessages).toContain('second prompt');
|
||||
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1928,7 +2051,11 @@ describe('AppContainer State Management', () => {
|
||||
// Clear previous calls
|
||||
mocks.mockStdout.write.mockClear();
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let compUnmount: () => void = () => {};
|
||||
await act(async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
compUnmount = unmount;
|
||||
});
|
||||
|
||||
// Allow async effects to run
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
@@ -1944,7 +2071,7 @@ describe('AppContainer State Management', () => {
|
||||
);
|
||||
|
||||
expect(clearTerminalCalls).toHaveLength(0);
|
||||
unmount();
|
||||
compUnmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
type IdeContext,
|
||||
type UserTierId,
|
||||
type UserFeedbackPayload,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
IdeClient,
|
||||
ideContextStore,
|
||||
getErrorMessage,
|
||||
@@ -50,7 +49,6 @@ import {
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
refreshServerHierarchicalMemory,
|
||||
type ModelChangedPayload,
|
||||
type MemoryChangedPayload,
|
||||
writeToStdout,
|
||||
disableMouseEvents,
|
||||
@@ -256,12 +254,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
|
||||
// Helper to determine the effective model, considering the fallback state.
|
||||
const getEffectiveModel = useCallback(() => {
|
||||
if (config.isInFallbackMode()) {
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
}
|
||||
return config.getModel();
|
||||
}, [config]);
|
||||
const getEffectiveModel = useCallback(() => config.getModel(), [config]);
|
||||
|
||||
const [currentModel, setCurrentModel] = useState(getEffectiveModel());
|
||||
|
||||
@@ -340,22 +333,15 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
// Subscribe to fallback mode and model changes from core
|
||||
useEffect(() => {
|
||||
const handleFallbackModeChanged = () => {
|
||||
const effectiveModel = getEffectiveModel();
|
||||
setCurrentModel(effectiveModel);
|
||||
const handleModelChanged = () => {
|
||||
setCurrentModel(config.getModel());
|
||||
};
|
||||
|
||||
const handleModelChanged = (payload: ModelChangedPayload) => {
|
||||
setCurrentModel(payload.model);
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.FallbackModeChanged, handleFallbackModeChanged);
|
||||
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.FallbackModeChanged, handleFallbackModeChanged);
|
||||
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
|
||||
};
|
||||
}, [getEffectiveModel]);
|
||||
}, [getEffectiveModel, config]);
|
||||
|
||||
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
|
||||
useConsoleMessages();
|
||||
@@ -1438,7 +1424,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
authType === AuthType.USE_VERTEX_AI
|
||||
) {
|
||||
setDefaultBannerText(
|
||||
'Gemini 3 is now available.\nTo use Gemini 3, enable "Preview features" in /settings\nLearn more at https://goo.gle/enable-preview-features',
|
||||
'Gemini 3 Flash and Pro are now available. \nEnable "Preview features" in /settings. \nLearn more at https://goo.gle/enable-preview-features',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { modelCommand } from './modelCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
describe('modelCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
@@ -29,6 +30,21 @@ describe('modelCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should call refreshUserQuota if config is available', async () => {
|
||||
if (!modelCommand.action) {
|
||||
throw new Error('The model command must have an action.');
|
||||
}
|
||||
|
||||
const mockRefreshUserQuota = vi.fn();
|
||||
mockContext.services.config = {
|
||||
refreshUserQuota: mockRefreshUserQuota,
|
||||
} as unknown as Config;
|
||||
|
||||
await modelCommand.action(mockContext, '');
|
||||
|
||||
expect(mockRefreshUserQuota).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(modelCommand.name).toBe('model');
|
||||
expect(modelCommand.description).toBe(
|
||||
|
||||
@@ -4,15 +4,24 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
CommandKind,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
|
||||
export const modelCommand: SlashCommand = {
|
||||
name: 'model',
|
||||
description: 'Opens a dialog to configure the model',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async () => ({
|
||||
type: 'dialog',
|
||||
dialog: 'model',
|
||||
}),
|
||||
action: async (context: CommandContext) => {
|
||||
if (context.services.config) {
|
||||
await context.services.config.refreshUserQuota();
|
||||
}
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'model',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
describe('statsCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
@@ -45,6 +46,26 @@ describe('statsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch and display quota if config is available', async () => {
|
||||
if (!statsCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const mockQuota = { buckets: [] };
|
||||
const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota);
|
||||
mockContext.services.config = {
|
||||
refreshUserQuota: mockRefreshUserQuota,
|
||||
} as unknown as Config;
|
||||
|
||||
await statsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockRefreshUserQuota).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
quotas: mockQuota,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display model stats when using the "model" subcommand', () => {
|
||||
const modelSubCommand = statsCommand.subCommands?.find(
|
||||
(sc) => sc.name === 'model',
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CodeAssistServer, getCodeAssistServer } from '@google/gemini-cli-core';
|
||||
import type { HistoryItemStats } from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
@@ -35,11 +34,8 @@ async function defaultSessionView(context: CommandContext) {
|
||||
};
|
||||
|
||||
if (context.services.config) {
|
||||
const server = getCodeAssistServer(context.services.config);
|
||||
if (server instanceof CodeAssistServer && server.projectId) {
|
||||
const quota = await server.retrieveUserQuota({
|
||||
project: server.projectId,
|
||||
});
|
||||
const quota = await context.services.config.refreshUserQuota();
|
||||
if (quota) {
|
||||
statsItem.quotas = quota;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { shortenPath, tildeifyPath } from '@google/gemini-cli-core';
|
||||
import {
|
||||
shortenPath,
|
||||
tildeifyPath,
|
||||
getDisplayString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
||||
import process from 'node:process';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
@@ -145,7 +149,8 @@ export const Footer: React.FC = () => {
|
||||
<Box alignItems="center" justifyContent="flex-end">
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.accent}>
|
||||
{model}
|
||||
{getDisplayString(model, config.getPreviewFeatures())}
|
||||
<Text color={theme.text.secondary}> /model</Text>
|
||||
{!hideContextPercentage && (
|
||||
<>
|
||||
{' '}
|
||||
|
||||
@@ -4,239 +4,240 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { cleanup } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
const mockedUseKeypress = vi.mocked(useKeypress);
|
||||
|
||||
vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({
|
||||
DescriptiveRadioButtonSelect: vi.fn(() => null),
|
||||
}));
|
||||
const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect);
|
||||
|
||||
const renderComponent = (
|
||||
props: Partial<React.ComponentProps<typeof ModelDialog>> = {},
|
||||
contextValue: Partial<Config> | undefined = undefined,
|
||||
) => {
|
||||
const defaultProps = {
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
const combinedProps = { ...defaultProps, ...props };
|
||||
|
||||
const mockConfig = contextValue
|
||||
? ({
|
||||
// --- Functions used by ModelDialog ---
|
||||
getModel: vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO),
|
||||
setModel: vi.fn(),
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
|
||||
// --- Functions used by ClearcutLogger ---
|
||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||
getSessionId: vi.fn(() => 'mock-session-id'),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
|
||||
getUseSmartEdit: vi.fn(() => false),
|
||||
getProxy: vi.fn(() => undefined),
|
||||
isInteractive: vi.fn(() => false),
|
||||
getExperiments: () => {},
|
||||
|
||||
// --- Spread test-specific overrides ---
|
||||
...contextValue,
|
||||
} as Config)
|
||||
: undefined;
|
||||
|
||||
const renderResult = render(
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
<ModelDialog {...combinedProps} />
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
// Mock dependencies
|
||||
const mockGetDisplayString = vi.fn();
|
||||
const mockLogModelSlashCommand = vi.fn();
|
||||
const mockModelSlashCommandEvent = vi.fn();
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
return {
|
||||
...renderResult,
|
||||
props: combinedProps,
|
||||
mockConfig,
|
||||
...actual,
|
||||
getDisplayString: (val: string) => mockGetDisplayString(val),
|
||||
logModelSlashCommand: (config: Config, event: ModelSlashCommandEvent) =>
|
||||
mockLogModelSlashCommand(config, event),
|
||||
ModelSlashCommandEvent: class {
|
||||
constructor(model: string) {
|
||||
mockModelSlashCommandEvent(model);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
describe('<ModelDialog />', () => {
|
||||
const mockSetModel = vi.fn();
|
||||
const mockGetModel = vi.fn();
|
||||
const mockGetPreviewFeatures = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
const mockGetHasAccessToPreviewModel = vi.fn();
|
||||
|
||||
interface MockConfig extends Partial<Config> {
|
||||
setModel: (model: string) => void;
|
||||
getModel: () => string;
|
||||
getPreviewFeatures: () => boolean;
|
||||
getHasAccessToPreviewModel: () => boolean;
|
||||
}
|
||||
|
||||
const mockConfig: MockConfig = {
|
||||
setModel: mockSetModel,
|
||||
getModel: mockGetModel,
|
||||
getPreviewFeatures: mockGetPreviewFeatures,
|
||||
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
mockGetPreviewFeatures.mockReturnValue(false);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
|
||||
// Default implementation for getDisplayString
|
||||
mockGetDisplayString.mockImplementation((val: string) => {
|
||||
if (val === 'auto-gemini-2.5') return 'Auto (Gemini 2.5)';
|
||||
if (val === 'auto-gemini-3') return 'Auto (Preview)';
|
||||
return val;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
const renderComponent = (contextValue = mockConfig as Config) =>
|
||||
render(
|
||||
<KeypressProvider>
|
||||
<ConfigContext.Provider value={contextValue}>
|
||||
<ModelDialog onClose={mockOnClose} />
|
||||
</ConfigContext.Provider>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
it('renders the title and help text', () => {
|
||||
const { lastFrame, unmount } = renderComponent();
|
||||
const waitForUpdate = () =>
|
||||
new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
it('renders the initial "main" view correctly', () => {
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Select Model');
|
||||
expect(lastFrame()).toContain('(Press Esc to close)');
|
||||
expect(lastFrame()).toContain(
|
||||
'To use a specific Gemini model on startup, use the --model flag.',
|
||||
);
|
||||
unmount();
|
||||
expect(lastFrame()).toContain('Auto');
|
||||
expect(lastFrame()).toContain('Manual');
|
||||
});
|
||||
|
||||
it('passes all model options to DescriptiveRadioButtonSelect', () => {
|
||||
const { unmount } = renderComponent();
|
||||
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
||||
|
||||
const props = mockedSelect.mock.calls[0][0];
|
||||
expect(props.items).toHaveLength(4);
|
||||
expect(props.items[0].value).toBe(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
expect(props.items[1].value).toBe(GEMINI_MODEL_ALIAS_PRO);
|
||||
expect(props.items[2].value).toBe(GEMINI_MODEL_ALIAS_FLASH);
|
||||
expect(props.items[3].value).toBe(GEMINI_MODEL_ALIAS_FLASH_LITE);
|
||||
expect(props.showNumbers).toBe(true);
|
||||
unmount();
|
||||
it('renders "main" view with preview options when preview features are enabled', () => {
|
||||
mockGetPreviewFeatures.mockReturnValue(true);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true); // Must have access
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Auto (Preview)');
|
||||
});
|
||||
|
||||
it('initializes with the model from ConfigContext', () => {
|
||||
const mockGetModel = vi.fn(() => GEMINI_MODEL_ALIAS_FLASH);
|
||||
const { unmount } = renderComponent({}, { getModel: mockGetModel });
|
||||
it('switches to "manual" view when "Manual" is selected', async () => {
|
||||
const { lastFrame, stdin } = renderComponent();
|
||||
|
||||
expect(mockGetModel).toHaveBeenCalled();
|
||||
expect(mockedSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialIndex: 2,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
// Select "Manual" (index 1)
|
||||
// Press down arrow to move to "Manual"
|
||||
stdin.write('\u001B[B'); // Arrow Down
|
||||
await waitForUpdate();
|
||||
|
||||
// Press enter to select
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
// Should now show manual options
|
||||
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
|
||||
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('initializes with "auto" model if context is not provided', () => {
|
||||
const { unmount } = renderComponent({}, undefined);
|
||||
it('renders "manual" view with preview options when preview features are enabled', async () => {
|
||||
mockGetPreviewFeatures.mockReturnValue(true);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true); // Must have access
|
||||
mockGetModel.mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
|
||||
const { lastFrame, stdin } = renderComponent();
|
||||
|
||||
expect(mockedSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialIndex: 0,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
// Select "Manual" (index 2 because Preview Auto is first, then Auto (Gemini 2.5))
|
||||
// Press down enough times to ensure we reach the bottom (Manual)
|
||||
stdin.write('\u001B[B'); // Arrow Down
|
||||
await waitForUpdate();
|
||||
stdin.write('\u001B[B'); // Arrow Down
|
||||
await waitForUpdate();
|
||||
|
||||
// Press enter to select Manual
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
expect(lastFrame()).toContain(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('initializes with "auto" model if getModel returns undefined', () => {
|
||||
const mockGetModel = vi.fn(() => undefined);
|
||||
// @ts-expect-error This test validates component robustness when getModel
|
||||
// returns an unexpected undefined value.
|
||||
const { unmount } = renderComponent({}, { getModel: mockGetModel });
|
||||
it('sets model and closes when a model is selected in "main" view', async () => {
|
||||
const { stdin } = renderComponent();
|
||||
|
||||
expect(mockGetModel).toHaveBeenCalled();
|
||||
// Select "Auto" (index 0)
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
// When getModel returns undefined, preferredModel falls back to DEFAULT_GEMINI_MODEL_AUTO
|
||||
// which has index 0, so initialIndex should be 0
|
||||
expect(mockedSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialIndex: 0,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
expect(mockSetModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
|
||||
const { props, mockConfig, unmount } = renderComponent({}, {}); // Pass empty object for contextValue
|
||||
it('sets model and closes when a model is selected in "manual" view', async () => {
|
||||
const { stdin } = renderComponent();
|
||||
|
||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||
expect(childOnSelect).toBeDefined();
|
||||
// Navigate to Manual (index 1) and select
|
||||
stdin.write('\u001B[B');
|
||||
await waitForUpdate();
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
childOnSelect(GEMINI_MODEL_ALIAS_PRO);
|
||||
// Now in manual view. Default selection is first item (DEFAULT_GEMINI_MODEL)
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
// Assert against the default mock provided by renderComponent
|
||||
expect(mockConfig?.setModel).toHaveBeenCalledWith(GEMINI_MODEL_ALIAS_PRO);
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
expect(mockSetModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not pass onHighlight to DescriptiveRadioButtonSelect', () => {
|
||||
const { unmount } = renderComponent();
|
||||
it('closes dialog on escape in "main" view', async () => {
|
||||
const { stdin } = renderComponent();
|
||||
|
||||
const childOnHighlight = mockedSelect.mock.calls[0][0].onHighlight;
|
||||
expect(childOnHighlight).toBeUndefined();
|
||||
unmount();
|
||||
stdin.write('\u001B'); // Escape
|
||||
await waitForUpdate();
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose prop when "escape" key is pressed', () => {
|
||||
const { props, unmount } = renderComponent();
|
||||
it('goes back to "main" view on escape in "manual" view', async () => {
|
||||
const { lastFrame, stdin } = renderComponent();
|
||||
|
||||
expect(mockedUseKeypress).toHaveBeenCalled();
|
||||
// Go to manual view
|
||||
stdin.write('\u001B[B');
|
||||
await waitForUpdate();
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
const keyPressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
const options = mockedUseKeypress.mock.calls[0][1];
|
||||
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
|
||||
|
||||
expect(options).toEqual({ isActive: true });
|
||||
// Press Escape
|
||||
stdin.write('\u001B');
|
||||
await waitForUpdate();
|
||||
|
||||
keyPressHandler({
|
||||
name: 'escape',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: false,
|
||||
sequence: '',
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
// Should be back to main view (Manual option visible)
|
||||
expect(lastFrame()).toContain('Manual');
|
||||
});
|
||||
|
||||
describe('Preview Logic', () => {
|
||||
it('should NOT show preview options if user has no access', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
mockGetPreviewFeatures.mockReturnValue(true); // Even if enabled
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).not.toContain('Auto (Preview)');
|
||||
});
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
keyPressHandler({
|
||||
name: 'a',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence: '',
|
||||
it('should NOT show preview options if user has access but preview features are disabled', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetPreviewFeatures.mockReturnValue(false);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).not.toContain('Auto (Preview)');
|
||||
});
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('updates initialIndex when config context changes', () => {
|
||||
const mockGetModel = vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO);
|
||||
const oldMockConfig = {
|
||||
getModel: mockGetModel,
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const { rerender, unmount } = render(
|
||||
<ConfigContext.Provider value={oldMockConfig}>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
it('should show preview options if user has access AND preview features are enabled', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetPreviewFeatures.mockReturnValue(true);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Auto (Preview)');
|
||||
});
|
||||
|
||||
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
|
||||
it('should show "Gemini 3 is now available" header if user has access but preview features disabled', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetPreviewFeatures.mockReturnValue(false);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Gemini 3 is now available.');
|
||||
expect(lastFrame()).toContain('Enable "Preview features" in /settings');
|
||||
});
|
||||
|
||||
mockGetModel.mockReturnValue(GEMINI_MODEL_ALIAS_FLASH_LITE);
|
||||
const newMockConfig = {
|
||||
getModel: mockGetModel,
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
it('should show "Gemini 3 is coming soon" header if user has no access', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
mockGetPreviewFeatures.mockReturnValue(false);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Gemini 3 is coming soon.');
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ConfigContext.Provider value={newMockConfig}>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
|
||||
// Should be called at least twice: initial render + re-render after context change
|
||||
expect(mockedSelect).toHaveBeenCalledTimes(2);
|
||||
expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(3);
|
||||
unmount();
|
||||
it('should NOT show header/subheader if preview options are shown', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetPreviewFeatures.mockReturnValue(true);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).not.toContain('Gemini 3 is now available.');
|
||||
expect(lastFrame()).not.toContain('Gemini 3 is coming soon.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
getDisplayString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
@@ -31,61 +31,131 @@ interface ModelDialogProps {
|
||||
|
||||
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const config = useContext(ConfigContext);
|
||||
const [view, setView] = useState<'main' | 'manual'>('main');
|
||||
|
||||
// Determine the Preferred Model (read once when the dialog opens).
|
||||
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
|
||||
|
||||
const shouldShowPreviewModels =
|
||||
config?.getPreviewFeatures() && config.getHasAccessToPreviewModel();
|
||||
|
||||
const manualModelSelected = useMemo(() => {
|
||||
const manualModels = [
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
];
|
||||
if (manualModels.includes(preferredModel)) {
|
||||
return preferredModel;
|
||||
}
|
||||
return '';
|
||||
}, [preferredModel]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onClose();
|
||||
if (view === 'manual') {
|
||||
setView('main');
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
const mainOptions = useMemo(() => {
|
||||
const list = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
title: 'Auto',
|
||||
description: 'Let the system choose the best model for your task.',
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
|
||||
description:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
|
||||
key: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
},
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_PRO,
|
||||
title: config?.getPreviewFeatures()
|
||||
? `Pro (${PREVIEW_GEMINI_MODEL}, ${DEFAULT_GEMINI_MODEL})`
|
||||
: `Pro (${DEFAULT_GEMINI_MODEL})`,
|
||||
value: 'Manual',
|
||||
title: manualModelSelected
|
||||
? `Manual (${manualModelSelected})`
|
||||
: 'Manual',
|
||||
description: 'Manually select a model',
|
||||
key: 'Manual',
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
list.unshift({
|
||||
value: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
|
||||
description:
|
||||
'For complex tasks that require deep reasoning and creativity',
|
||||
key: GEMINI_MODEL_ALIAS_PRO,
|
||||
'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
|
||||
key: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [shouldShowPreviewModels, manualModelSelected]);
|
||||
|
||||
const manualOptions = useMemo(() => {
|
||||
const list = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: DEFAULT_GEMINI_MODEL,
|
||||
key: DEFAULT_GEMINI_MODEL,
|
||||
},
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_FLASH,
|
||||
title: `Flash (${DEFAULT_GEMINI_FLASH_MODEL})`,
|
||||
description: 'For tasks that need a balance of speed and reasoning',
|
||||
key: GEMINI_MODEL_ALIAS_FLASH,
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
key: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
title: `Flash-Lite (${DEFAULT_GEMINI_FLASH_LITE_MODEL})`,
|
||||
description: 'For simple tasks that need to be done quickly',
|
||||
key: GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
},
|
||||
],
|
||||
[config],
|
||||
);
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
list.unshift(
|
||||
{
|
||||
value: PREVIEW_GEMINI_MODEL,
|
||||
title: PREVIEW_GEMINI_MODEL,
|
||||
key: PREVIEW_GEMINI_MODEL,
|
||||
},
|
||||
{
|
||||
value: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
title: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
key: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [shouldShowPreviewModels]);
|
||||
|
||||
const options = view === 'main' ? mainOptions : manualOptions;
|
||||
|
||||
// Calculate the initial index based on the preferred model.
|
||||
const initialIndex = useMemo(
|
||||
() => options.findIndex((option) => option.value === preferredModel),
|
||||
[preferredModel, options],
|
||||
);
|
||||
const initialIndex = useMemo(() => {
|
||||
const idx = options.findIndex((option) => option.value === preferredModel);
|
||||
if (idx !== -1) {
|
||||
return idx;
|
||||
}
|
||||
if (view === 'main') {
|
||||
const manualIdx = options.findIndex((o) => o.value === 'Manual');
|
||||
return manualIdx !== -1 ? manualIdx : 0;
|
||||
}
|
||||
return 0;
|
||||
}, [preferredModel, options, view]);
|
||||
|
||||
// Handle selection internally (Autonomous Dialog).
|
||||
const handleSelect = useCallback(
|
||||
(model: string) => {
|
||||
if (model === 'Manual') {
|
||||
setView('manual');
|
||||
return;
|
||||
}
|
||||
|
||||
if (config) {
|
||||
config.setModel(model);
|
||||
const event = new ModelSlashCommandEvent(model);
|
||||
@@ -96,13 +166,23 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
[config, onClose],
|
||||
);
|
||||
|
||||
const header = config?.getPreviewFeatures()
|
||||
? 'Gemini 3 is now enabled.'
|
||||
: 'Gemini 3 is now available.';
|
||||
let header;
|
||||
let subheader;
|
||||
|
||||
const subheader = config?.getPreviewFeatures()
|
||||
? `To disable Gemini 3, disable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features\n\nWhen you select Auto or Pro, Gemini CLI will attempt to use ${PREVIEW_GEMINI_MODEL} first, before falling back to ${DEFAULT_GEMINI_MODEL}.`
|
||||
: `To use Gemini 3, enable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features`;
|
||||
// Do not show any header or subheader since it's already showing preview model
|
||||
// options
|
||||
if (shouldShowPreviewModels) {
|
||||
header = undefined;
|
||||
subheader = undefined;
|
||||
// When a user has the access but has not enabled the preview features.
|
||||
} else if (config?.getHasAccessToPreviewModel()) {
|
||||
header = 'Gemini 3 is now available.';
|
||||
subheader =
|
||||
'Enable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features';
|
||||
} else {
|
||||
header = 'Gemini 3 is coming soon.';
|
||||
subheader = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -114,11 +194,15 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
>
|
||||
<Text bold>Select Model</Text>
|
||||
|
||||
<Box marginTop={1} marginBottom={1} flexDirection="column">
|
||||
<ThemedGradient>
|
||||
<Text>{header}</Text>
|
||||
</ThemedGradient>
|
||||
<Text>{subheader}</Text>
|
||||
<Box flexDirection="column">
|
||||
{header && (
|
||||
<Box marginTop={1}>
|
||||
<ThemedGradient>
|
||||
<Text>{header}</Text>
|
||||
</ThemedGradient>
|
||||
</Box>
|
||||
)}
|
||||
{subheader && <Text>{subheader}</Text>}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('ProQuotaDialog', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel={DEFAULT_GEMINI_FLASH_MODEL}
|
||||
fallbackModel="gemini-2.5-pro"
|
||||
fallbackModel={DEFAULT_GEMINI_FLASH_MODEL}
|
||||
message="flash error"
|
||||
isTerminalQuotaError={true} // should not matter
|
||||
onChoice={mockOnChoice}
|
||||
@@ -97,6 +97,38 @@ describe('ProQuotaDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render "Keep trying" and "Stop" options when failed model and fallback model are the same', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel={PREVIEW_GEMINI_MODEL}
|
||||
fallbackModel={PREVIEW_GEMINI_MODEL}
|
||||
message="flash error"
|
||||
isTerminalQuotaError={true}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
value: 'retry_once',
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later',
|
||||
key: 'retry_later',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render switch, upgrade, and stop options for free tier', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
@@ -137,7 +169,7 @@ describe('ProQuotaDialog', () => {
|
||||
});
|
||||
|
||||
describe('when it is a capacity error', () => {
|
||||
it('should render keep trying, switch, and stop options', () => {
|
||||
it('should render keep trying and stop options', () => {
|
||||
const { unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
@@ -158,11 +190,6 @@ describe('ProQuotaDialog', () => {
|
||||
value: 'retry_once',
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: 'Switch to gemini-2.5-flash',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{ label: 'Stop', value: 'retry_later', key: 'retry_later' },
|
||||
],
|
||||
}),
|
||||
@@ -263,44 +290,4 @@ describe('ProQuotaDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer note', () => {
|
||||
it('should show a special note for PREVIEW_GEMINI_MODEL', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel={PREVIEW_GEMINI_MODEL}
|
||||
fallbackModel="gemini-2.5-pro"
|
||||
message=""
|
||||
isTerminalQuotaError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(
|
||||
'Note: We will periodically retry Preview Model to see if congestion has cleared.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show the default note for other models', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message=""
|
||||
isTerminalQuotaError={false}
|
||||
onChoice={mockOnChoice}
|
||||
userTier={UserTierId.FREE}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(
|
||||
'Note: You can always use /model to select a different option.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,12 +9,7 @@ import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
UserTierId,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { UserTierId } from '@google/gemini-cli-core';
|
||||
|
||||
interface ProQuotaDialogProps {
|
||||
failedModel: string;
|
||||
@@ -41,11 +36,8 @@ export function ProQuotaDialog({
|
||||
const isPaidTier =
|
||||
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
|
||||
let items;
|
||||
// flash and flash lite don't have options to switch or upgrade.
|
||||
if (
|
||||
failedModel === DEFAULT_GEMINI_FLASH_MODEL ||
|
||||
failedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL
|
||||
) {
|
||||
// Do not provide a fallback option if failed model and fallbackmodel are same.
|
||||
if (failedModel === fallbackModel) {
|
||||
items = [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
@@ -99,11 +91,6 @@ export function ProQuotaDialog({
|
||||
value: 'retry_once' as const,
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: `Switch to ${fallbackModel}`,
|
||||
value: 'retry_always' as const,
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later' as const,
|
||||
@@ -118,19 +105,31 @@ export function ProQuotaDialog({
|
||||
onChoice(choice);
|
||||
};
|
||||
|
||||
// Helper to highlight simple slash commands in the message
|
||||
const renderMessage = (msg: string) => {
|
||||
const parts = msg.split(/(\s+)/);
|
||||
return (
|
||||
<Text>
|
||||
{parts.map((part, index) => {
|
||||
if (part.startsWith('/')) {
|
||||
return (
|
||||
<Text key={index} bold color={theme.text.accent}>
|
||||
{part}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return <Text key={index}>{part}</Text>;
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text>{message}</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>{renderMessage(message)}</Box>
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<RadioButtonSelect items={items} onSelect={handleSelect} />
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{fallbackModel === DEFAULT_GEMINI_MODEL && !isModelNotFoundError
|
||||
? 'Note: We will periodically retry Preview Model to see if congestion has cleared.'
|
||||
: 'Note: You can always use /model to select a different option.'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,23 +24,23 @@ describe('ShellConfirmationDialog', () => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onConfirm with ProceedOnce when "Yes, allow once" is selected', () => {
|
||||
it('calls onConfirm with ProceedOnce when "Allow once" is selected', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ShellConfirmationDialog request={request} />,
|
||||
);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the first option
|
||||
// This is a simplified way to test the selection
|
||||
expect(select).toContain('Yes, allow once');
|
||||
expect(select).toContain('Allow once');
|
||||
});
|
||||
|
||||
it('calls onConfirm with ProceedAlways when "Yes, allow always for this session" is selected', () => {
|
||||
it('calls onConfirm with ProceedAlways when "Allow for this session" is selected', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ShellConfirmationDialog request={request} />,
|
||||
);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the second option
|
||||
expect(select).toContain('Yes, allow always for this session');
|
||||
expect(select).toContain('Allow for this session');
|
||||
});
|
||||
|
||||
it('calls onConfirm with Cancel when "No (esc)" is selected', () => {
|
||||
|
||||
@@ -51,14 +51,14 @@ export const ShellConfirmationDialog: React.FC<
|
||||
|
||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
|
||||
{
|
||||
label: 'Yes, allow once',
|
||||
label: 'Allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
key: 'Allow once',
|
||||
},
|
||||
{
|
||||
label: 'Yes, allow always for this session',
|
||||
label: 'Allow for this session',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always for this session',
|
||||
key: 'Allow for this session',
|
||||
},
|
||||
{
|
||||
label: 'No (esc)',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro (100%)"`;
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox Manual (gemini-pro) /model (100%)"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...irectories/to/make/it/long no sandbox (see /docs) gemini-pro (100% context left)"`;
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...irectories/to/make/it/long no sandbox (see /docs) Manual (gemini-pro) /model (100% context left)"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ exports[`ShellConfirmationDialog > renders correctly 1`] = `
|
||||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Yes, allow once │
|
||||
│ 2. Yes, allow always for this session │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. No (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
|
||||
@@ -10,7 +10,10 @@ import type {
|
||||
ToolCallConfirmationDetails,
|
||||
Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import {
|
||||
renderWithProviders,
|
||||
createMockSettings,
|
||||
} from '../../../test-utils/render.js';
|
||||
|
||||
describe('ToolConfirmationMessage', () => {
|
||||
const mockConfig = {
|
||||
@@ -104,17 +107,17 @@ describe('ToolConfirmationMessage', () => {
|
||||
{
|
||||
description: 'for edit confirmations',
|
||||
details: editConfirmationDetails,
|
||||
alwaysAllowText: 'Yes, allow always',
|
||||
alwaysAllowText: 'Allow for this session',
|
||||
},
|
||||
{
|
||||
description: 'for exec confirmations',
|
||||
details: execConfirmationDetails,
|
||||
alwaysAllowText: 'Yes, allow always',
|
||||
alwaysAllowText: 'Allow for this session',
|
||||
},
|
||||
{
|
||||
description: 'for info confirmations',
|
||||
details: infoConfirmationDetails,
|
||||
alwaysAllowText: 'Yes, allow always',
|
||||
alwaysAllowText: 'Allow for this session',
|
||||
},
|
||||
{
|
||||
description: 'for mcp confirmations',
|
||||
@@ -159,4 +162,63 @@ describe('ToolConfirmationMessage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enablePermanentToolApproval setting', () => {
|
||||
const editConfirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'edit',
|
||||
title: 'Confirm Edit',
|
||||
fileName: 'test.txt',
|
||||
filePath: '/test.txt',
|
||||
fileDiff: '...diff...',
|
||||
originalContent: 'a',
|
||||
newContent: 'b',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
it('should NOT show "Allow for all future sessions" when setting is false (default)', () => {
|
||||
const mockConfig = {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
confirmationDetails={editConfirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
{
|
||||
settings: createMockSettings({
|
||||
security: { enablePermanentToolApproval: false },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('Allow for all future sessions');
|
||||
});
|
||||
|
||||
it('should show "Allow for all future sessions" when setting is true', () => {
|
||||
const mockConfig = {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
confirmationDetails={editConfirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
{
|
||||
settings: createMockSettings({
|
||||
security: { enablePermanentToolApproval: true },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Allow for all future sessions');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
|
||||
export interface ToolConfirmationMessageProps {
|
||||
confirmationDetails: ToolCallConfirmationDetails;
|
||||
@@ -41,6 +42,9 @@ export const ToolConfirmationMessage: React.FC<
|
||||
const { onConfirm } = confirmationDetails;
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const settings = useSettings();
|
||||
const allowPermanentApproval =
|
||||
settings.merged.security?.enablePermanentToolApproval ?? false;
|
||||
|
||||
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
|
||||
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
|
||||
@@ -102,21 +106,23 @@ export const ToolConfirmationMessage: React.FC<
|
||||
if (!confirmationDetails.isModifying) {
|
||||
question = `Apply this change?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
label: 'Allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
key: 'Allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Yes, allow always',
|
||||
label: 'Allow for this session',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
});
|
||||
options.push({
|
||||
label: 'Yes, allow always and save to policy',
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
key: 'Yes, allow always and save to policy',
|
||||
key: 'Allow for this session',
|
||||
});
|
||||
if (allowPermanentApproval) {
|
||||
options.push({
|
||||
label: 'Allow for all future sessions',
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
key: 'Allow for all future sessions',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!config.getIdeMode() || !isDiffingEnabled) {
|
||||
options.push({
|
||||
@@ -137,21 +143,23 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
question = `Allow execution of: '${executionProps.rootCommand}'?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
label: 'Allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
key: 'Allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: `Yes, allow always ...`,
|
||||
label: `Allow for this session`,
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: `Yes, allow always ...`,
|
||||
});
|
||||
options.push({
|
||||
label: `Yes, allow always and save to policy`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
key: `Yes, allow always and save to policy`,
|
||||
key: `Allow for this session`,
|
||||
});
|
||||
if (allowPermanentApproval) {
|
||||
options.push({
|
||||
label: `Allow for all future sessions`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
key: `Allow for all future sessions`,
|
||||
});
|
||||
}
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
@@ -161,21 +169,23 @@ export const ToolConfirmationMessage: React.FC<
|
||||
} else if (confirmationDetails.type === 'info') {
|
||||
question = `Do you want to proceed?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
label: 'Allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
key: 'Allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Yes, allow always',
|
||||
label: 'Allow for this session',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
});
|
||||
options.push({
|
||||
label: 'Yes, allow always and save to policy',
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
key: 'Yes, allow always and save to policy',
|
||||
key: 'Allow for this session',
|
||||
});
|
||||
if (allowPermanentApproval) {
|
||||
options.push({
|
||||
label: 'Allow for all future sessions',
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
key: 'Allow for all future sessions',
|
||||
});
|
||||
}
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
@@ -187,26 +197,28 @@ export const ToolConfirmationMessage: React.FC<
|
||||
const mcpProps = confirmationDetails;
|
||||
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
label: 'Allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
key: 'Allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
|
||||
key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
label: 'Allow tool for this session',
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
key: 'Allow tool for this session',
|
||||
});
|
||||
options.push({
|
||||
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
label: 'Allow all server tools for this session',
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
key: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
});
|
||||
options.push({
|
||||
label: `Yes, allow always tool "${mcpProps.toolName}" and save to policy`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
key: `Yes, allow always tool "${mcpProps.toolName}" and save to policy`,
|
||||
key: 'Allow all server tools for this session',
|
||||
});
|
||||
if (allowPermanentApproval) {
|
||||
options.push({
|
||||
label: 'Allow tool for all future sessions',
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
key: 'Allow tool for all future sessions',
|
||||
});
|
||||
}
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
@@ -327,6 +339,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
isAlternateBuffer,
|
||||
allowPermanentApproval,
|
||||
]);
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import {
|
||||
renderWithProviders,
|
||||
createMockSettings,
|
||||
} from '../../../test-utils/render.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
@@ -376,5 +379,57 @@ describe('<ToolGroupMessage />', () => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders confirmation with permanent approval enabled', () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
callId: 'tool-1',
|
||||
name: 'confirm-tool',
|
||||
status: ToolCallStatus.Confirming,
|
||||
confirmationDetails: {
|
||||
type: 'info',
|
||||
title: 'Confirm Tool',
|
||||
prompt: 'Do you want to proceed?',
|
||||
onConfirm: vi.fn(),
|
||||
},
|
||||
}),
|
||||
];
|
||||
const settings = createMockSettings({
|
||||
security: { enablePermanentToolApproval: true },
|
||||
});
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
|
||||
{ settings },
|
||||
);
|
||||
expect(lastFrame()).toContain('Allow for all future sessions');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders confirmation with permanent approval disabled', () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
callId: 'tool-1',
|
||||
name: 'confirm-tool',
|
||||
status: ToolCallStatus.Confirming,
|
||||
confirmationDetails: {
|
||||
type: 'info',
|
||||
title: 'Confirm Tool',
|
||||
prompt: 'Do you want to proceed?',
|
||||
onConfirm: vi.fn(),
|
||||
},
|
||||
}),
|
||||
];
|
||||
const settings = createMockSettings({
|
||||
security: { enablePermanentToolApproval: false },
|
||||
});
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
|
||||
{ settings },
|
||||
);
|
||||
expect(lastFrame()).not.toContain('Allow for all future sessions');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+24
-30
@@ -8,10 +8,9 @@ URLs to fetch:
|
||||
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Yes, allow once
|
||||
2. Yes, allow always
|
||||
3. Yes, allow always and save to policy
|
||||
4. No, suggest changes (esc)
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -20,10 +19,9 @@ exports[`ToolConfirmationMessage > should not display urls if prompt and url are
|
||||
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Yes, allow once
|
||||
2. Yes, allow always
|
||||
3. Yes, allow always and save to policy
|
||||
4. No, suggest changes (esc)
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -36,7 +34,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
|
||||
|
||||
Apply this change?
|
||||
|
||||
● 1. Yes, allow once
|
||||
● 1. Allow once
|
||||
2. Modify with external editor
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
@@ -51,11 +49,10 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
|
||||
|
||||
Apply this change?
|
||||
|
||||
● 1. Yes, allow once
|
||||
2. Yes, allow always
|
||||
3. Yes, allow always and save to policy
|
||||
4. Modify with external editor
|
||||
5. No, suggest changes (esc)
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. Modify with external editor
|
||||
4. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -64,7 +61,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
|
||||
|
||||
Allow execution of: 'echo'?
|
||||
|
||||
● 1. Yes, allow once
|
||||
● 1. Allow once
|
||||
2. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -74,10 +71,9 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
|
||||
|
||||
Allow execution of: 'echo'?
|
||||
|
||||
● 1. Yes, allow once
|
||||
2. Yes, allow always ...
|
||||
3. Yes, allow always and save to policy
|
||||
4. No, suggest changes (esc)
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -86,7 +82,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
|
||||
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Yes, allow once
|
||||
● 1. Allow once
|
||||
2. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -96,10 +92,9 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
|
||||
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Yes, allow once
|
||||
2. Yes, allow always
|
||||
3. Yes, allow always and save to policy
|
||||
4. No, suggest changes (esc)
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -109,7 +104,7 @@ Tool: test-tool
|
||||
|
||||
Allow execution of MCP tool "test-tool" from server "test-server"?
|
||||
|
||||
● 1. Yes, allow once
|
||||
● 1. Allow once
|
||||
2. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -120,10 +115,9 @@ Tool: test-tool
|
||||
|
||||
Allow execution of MCP tool "test-tool" from server "test-server"?
|
||||
|
||||
● 1. Yes, allow once
|
||||
2. Yes, always allow tool "test-tool" from server "test-server"
|
||||
3. Yes, always allow all tools from server "test-server"
|
||||
4. Yes, allow always tool "test-tool" and save to policy
|
||||
5. No, suggest changes (esc)
|
||||
● 1. Allow once
|
||||
2. Allow tool for this session
|
||||
3. Allow all server tools for this session
|
||||
4. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
+39
-8
@@ -28,6 +28,39 @@ exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when too
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation with permanent approval disabled 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ? confirm-tool A tool for testing ← │
|
||||
│ │
|
||||
│ Test result │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation with permanent approval enabled 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ? confirm-tool A tool for testing ← │
|
||||
│ │
|
||||
│ Test result │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. Allow for all future sessions │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ? first-confirm A tool for testing ← │
|
||||
@@ -37,10 +70,9 @@ exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialo
|
||||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Yes, allow once │
|
||||
│ 2. Yes, allow always │
|
||||
│ 3. Yes, allow always and save to policy │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. No, suggest changes (esc) │
|
||||
│ │
|
||||
│ │
|
||||
│ ? second-confirm A tool for testing │
|
||||
@@ -121,10 +153,9 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting co
|
||||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Yes, allow once │
|
||||
│ 2. Yes, allow always │
|
||||
│ 3. Yes, allow always and save to policy │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||
|
||||
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
|
||||
title: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface DescriptiveRadioButtonSelectProps<T> {
|
||||
@@ -62,7 +62,9 @@ export function DescriptiveRadioButtonSelect<T>({
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" key={item.key}>
|
||||
<Text color={titleColor}>{item.title}</Text>
|
||||
<Text color={theme.text.secondary}>{item.description}</Text>
|
||||
{item.description && (
|
||||
<Text color={theme.text.secondary}>{item.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -261,7 +261,7 @@ describe('useQuotaAndFallback', () => {
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
const message = request!.message;
|
||||
expect(message).toContain(
|
||||
'model-A is currently experiencing high demand. We apologize and appreciate your patience.',
|
||||
'We are currently experiencing high demand.',
|
||||
);
|
||||
|
||||
// Simulate the user choosing to continue with the fallback model
|
||||
@@ -283,7 +283,7 @@ describe('useQuotaAndFallback', () => {
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock
|
||||
.calls[0][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toContain('Switched to fallback model.');
|
||||
expect(lastCall.text).toContain('Switched to fallback model model-B');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -316,9 +316,9 @@ describe('useQuotaAndFallback', () => {
|
||||
|
||||
const message = request!.message;
|
||||
expect(message).toBe(
|
||||
`It seems like you don't have access to Gemini 3.
|
||||
`It seems like you don't have access to gemini-3-pro-preview.
|
||||
Learn more at https://goo.gle/enable-preview-features
|
||||
To disable Gemini 3, disable "Preview features" in /settings.`,
|
||||
To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
);
|
||||
|
||||
// Simulate the user choosing to switch
|
||||
@@ -415,7 +415,9 @@ To disable Gemini 3, disable "Preview features" in /settings.`,
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toContain('Switched to fallback model.');
|
||||
expect(lastCall.text).toContain(
|
||||
'Switched to fallback model gemini-flash',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show a special message when falling back from the preview model', async () => {
|
||||
@@ -449,7 +451,7 @@ To disable Gemini 3, disable "Preview features" in /settings.`,
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toContain(
|
||||
`Switched to fallback model gemini-2.5-pro. We will periodically check if ${PREVIEW_GEMINI_MODEL} is available again.`,
|
||||
`Switched to fallback model gemini-2.5-pro`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -484,7 +486,7 @@ To disable Gemini 3, disable "Preview features" in /settings.`,
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toContain(
|
||||
`Switched to fallback model gemini-2.5-flash.`,
|
||||
`Switched to fallback model gemini-2.5-flash`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type UserTierId,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
VALID_GEMINI_MODELS,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -68,19 +69,28 @@ export function useQuotaAndFallback({
|
||||
`Usage limit reached for ${usageLimitReachedModel}.`,
|
||||
error.retryDelayMs ? getResetTimeMessage(error.retryDelayMs) : null,
|
||||
`/stats for usage details`,
|
||||
`/model to switch models.`,
|
||||
`/auth to switch to API key.`,
|
||||
].filter(Boolean);
|
||||
message = messageLines.join('\n');
|
||||
} else if (error instanceof ModelNotFoundError) {
|
||||
} else if (
|
||||
error instanceof ModelNotFoundError &&
|
||||
VALID_GEMINI_MODELS.has(failedModel)
|
||||
) {
|
||||
isModelNotFoundError = true;
|
||||
const messageLines = [
|
||||
`It seems like you don't have access to Gemini 3.`,
|
||||
`It seems like you don't have access to ${failedModel}.`,
|
||||
`Learn more at https://goo.gle/enable-preview-features`,
|
||||
`To disable Gemini 3, disable "Preview features" in /settings.`,
|
||||
`To disable ${failedModel}, disable "Preview features" in /settings.`,
|
||||
];
|
||||
message = messageLines.join('\n');
|
||||
} else {
|
||||
message = `${failedModel} is currently experiencing high demand. We apologize and appreciate your patience.`;
|
||||
const messageLines = [
|
||||
`We are currently experiencing high demand.`,
|
||||
'We apologize and appreciate your patience.',
|
||||
'/model to switch models.',
|
||||
];
|
||||
message = messageLines.join('\n');
|
||||
}
|
||||
|
||||
setModelSwitchedFromQuotaError(true);
|
||||
@@ -120,30 +130,20 @@ export function useQuotaAndFallback({
|
||||
isDialogPending.current = false; // Reset the flag here
|
||||
|
||||
if (choice === 'retry_always') {
|
||||
// If we were recovering from a Preview Model failure, show a specific message.
|
||||
if (proQuotaRequest.failedModel === PREVIEW_GEMINI_MODEL) {
|
||||
const showPeriodicalCheckMessage =
|
||||
!proQuotaRequest.isModelNotFoundError &&
|
||||
proQuotaRequest.fallbackModel === DEFAULT_GEMINI_MODEL;
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Switched to fallback model ${proQuotaRequest.fallbackModel}. ${showPeriodicalCheckMessage ? `We will periodically check if ${PREVIEW_GEMINI_MODEL} is available again.` : ''}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} else {
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Switched to fallback model.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
// Explicitly set the model to the fallback model to persist the user's choice.
|
||||
// This ensures the Footer updates and future turns use this model.
|
||||
config.setModel(proQuotaRequest.fallbackModel);
|
||||
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Switched to fallback model ${proQuotaRequest.fallbackModel}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
},
|
||||
[proQuotaRequest, historyManager],
|
||||
[proQuotaRequest, historyManager, config],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -256,7 +256,6 @@ export class Session {
|
||||
|
||||
try {
|
||||
const model = getEffectiveModel(
|
||||
this.config.isInFallbackMode(),
|
||||
this.config.getModel(),
|
||||
this.config.getPreviewFeatures(),
|
||||
);
|
||||
|
||||
@@ -12,9 +12,6 @@ export {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
} from './src/config/models.js';
|
||||
export {
|
||||
serializeTerminalToObject,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
LS_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
import { GEMINI_MODEL_ALIAS_PRO } from '../config/models.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
|
||||
|
||||
describe('CodebaseInvestigatorAgent', () => {
|
||||
it('should have the correct agent definition', () => {
|
||||
@@ -26,7 +26,7 @@ describe('CodebaseInvestigatorAgent', () => {
|
||||
).toBe(true);
|
||||
expect(CodebaseInvestigatorAgent.outputConfig?.outputName).toBe('report');
|
||||
expect(CodebaseInvestigatorAgent.modelConfig?.model).toBe(
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
expect(CodebaseInvestigatorAgent.toolConfig?.tools).toEqual([
|
||||
LS_TOOL_NAME,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
LS_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
} from '../tools/tool-names.js';
|
||||
import { GEMINI_MODEL_ALIAS_PRO } from '../config/models.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define a type that matches the outputConfig schema for type safety.
|
||||
@@ -69,7 +69,7 @@ export const CodebaseInvestigatorAgent: AgentDefinition<
|
||||
processOutput: (output) => JSON.stringify(output, null, 2),
|
||||
|
||||
modelConfig: {
|
||||
model: GEMINI_MODEL_ALIAS_PRO,
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
temp: 0.1,
|
||||
top_p: 0.95,
|
||||
thinkingBudget: -1,
|
||||
|
||||
@@ -10,6 +10,13 @@ import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import type { AgentDefinition } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
} from '../config/models.js';
|
||||
|
||||
// A test-only subclass to expose the protected `registerAgent` method.
|
||||
class TestableAgentRegistry extends AgentRegistry {
|
||||
@@ -73,12 +80,12 @@ describe('AgentRegistry', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use preview model for codebase investigator if main model is preview', async () => {
|
||||
it('should use preview flash model for codebase investigator if main model is preview pro', async () => {
|
||||
const previewConfig = makeFakeConfig({
|
||||
model: 'gemini-3-pro-preview',
|
||||
model: PREVIEW_GEMINI_MODEL,
|
||||
codebaseInvestigatorSettings: {
|
||||
enabled: true,
|
||||
model: 'pro',
|
||||
model: GEMINI_MODEL_ALIAS_AUTO,
|
||||
},
|
||||
});
|
||||
const previewRegistry = new TestableAgentRegistry(previewConfig);
|
||||
@@ -89,7 +96,51 @@ describe('AgentRegistry', () => {
|
||||
'codebase_investigator',
|
||||
);
|
||||
expect(investigatorDef).toBeDefined();
|
||||
expect(investigatorDef?.modelConfig.model).toBe('gemini-3-pro-preview');
|
||||
expect(investigatorDef?.modelConfig.model).toBe(
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use preview flash model for codebase investigator if main model is preview auto', async () => {
|
||||
const previewConfig = makeFakeConfig({
|
||||
model: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
codebaseInvestigatorSettings: {
|
||||
enabled: true,
|
||||
model: GEMINI_MODEL_ALIAS_AUTO,
|
||||
},
|
||||
});
|
||||
const previewRegistry = new TestableAgentRegistry(previewConfig);
|
||||
|
||||
await previewRegistry.initialize();
|
||||
|
||||
const investigatorDef = previewRegistry.getDefinition(
|
||||
'codebase_investigator',
|
||||
);
|
||||
expect(investigatorDef).toBeDefined();
|
||||
expect(investigatorDef?.modelConfig.model).toBe(
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use the model from the investigator settings', async () => {
|
||||
const previewConfig = makeFakeConfig({
|
||||
model: PREVIEW_GEMINI_MODEL,
|
||||
codebaseInvestigatorSettings: {
|
||||
enabled: true,
|
||||
model: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
},
|
||||
});
|
||||
const previewRegistry = new TestableAgentRegistry(previewConfig);
|
||||
|
||||
await previewRegistry.initialize();
|
||||
|
||||
const investigatorDef = previewRegistry.getDefinition(
|
||||
'codebase_investigator',
|
||||
);
|
||||
expect(investigatorDef).toBeDefined();
|
||||
expect(investigatorDef?.modelConfig.model).toBe(
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
|
||||
import { type z } from 'zod';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
isPreviewModel,
|
||||
} from '../config/models.js';
|
||||
import type { ModelConfigAlias } from '../services/modelConfigService.js';
|
||||
import { coreEvents, CoreEvent } from '../utils/events.js';
|
||||
|
||||
/**
|
||||
* Returns the model config alias for a given agent definition.
|
||||
@@ -41,6 +43,10 @@ export class AgentRegistry {
|
||||
async initialize(): Promise<void> {
|
||||
this.loadBuiltInAgents();
|
||||
|
||||
coreEvents.on(CoreEvent.ModelChanged, () => {
|
||||
this.loadBuiltInAgents();
|
||||
});
|
||||
|
||||
if (this.config.getDebugMode()) {
|
||||
debugLogger.log(
|
||||
`[AgentRegistry] Initialized with ${this.agents.size} agents.`,
|
||||
@@ -53,19 +59,17 @@ export class AgentRegistry {
|
||||
|
||||
// Only register the agent if it's enabled in the settings.
|
||||
if (investigatorSettings?.enabled) {
|
||||
let model =
|
||||
investigatorSettings.model ??
|
||||
CodebaseInvestigatorAgent.modelConfig.model;
|
||||
|
||||
// If the user is using the preview model for the main agent, force the sub-agent to use it too
|
||||
// if it's configured to use 'pro' or 'auto'.
|
||||
if (this.config.getModel() === PREVIEW_GEMINI_MODEL) {
|
||||
if (
|
||||
model === GEMINI_MODEL_ALIAS_PRO ||
|
||||
model === DEFAULT_GEMINI_MODEL_AUTO
|
||||
) {
|
||||
model = PREVIEW_GEMINI_MODEL;
|
||||
}
|
||||
let model;
|
||||
const settingsModel = investigatorSettings.model;
|
||||
// Check if the user explicitly set a model in the settings.
|
||||
if (settingsModel && settingsModel !== GEMINI_MODEL_ALIAS_AUTO) {
|
||||
model = settingsModel;
|
||||
} else {
|
||||
// Use Preview Flash model if the main model is any of the preview models
|
||||
// If the main model is not preview model, use default pro model.
|
||||
model = isPreviewModel(this.config.getModel())
|
||||
? PREVIEW_GEMINI_FLASH_MODEL
|
||||
: DEFAULT_GEMINI_MODEL;
|
||||
}
|
||||
|
||||
const agentDef = {
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('policyCatalog', () => {
|
||||
it('returns preview chain when preview enabled', () => {
|
||||
const chain = getModelPolicyChain({ previewEnabled: true });
|
||||
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
expect(chain).toHaveLength(3);
|
||||
expect(chain).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns default chain when preview disabled', () => {
|
||||
@@ -31,7 +31,7 @@ describe('policyCatalog', () => {
|
||||
it('marks preview transients as sticky retries', () => {
|
||||
const [previewPolicy] = getModelPolicyChain({ previewEnabled: true });
|
||||
expect(previewPolicy.model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
expect(previewPolicy.stateTransitions.transient).toBe('sticky_retry');
|
||||
expect(previewPolicy.stateTransitions.transient).toBe('terminal');
|
||||
});
|
||||
|
||||
it('applies default actions and state transitions for unspecified kinds', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
import type { UserTierId } from '../code_assist/types.js';
|
||||
@@ -48,13 +49,8 @@ const DEFAULT_CHAIN: ModelPolicyChain = [
|
||||
];
|
||||
|
||||
const PREVIEW_CHAIN: ModelPolicyChain = [
|
||||
definePolicy({
|
||||
model: PREVIEW_GEMINI_MODEL,
|
||||
stateTransitions: { transient: 'sticky_retry' },
|
||||
actions: { transient: 'silent' },
|
||||
}),
|
||||
definePolicy({ model: DEFAULT_GEMINI_MODEL }),
|
||||
definePolicy({ model: DEFAULT_GEMINI_FLASH_MODEL, isLastResort: true }),
|
||||
definePolicy({ model: PREVIEW_GEMINI_MODEL }),
|
||||
definePolicy({ model: PREVIEW_GEMINI_FLASH_MODEL, isLastResort: true }),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -70,6 +66,10 @@ export function getModelPolicyChain(
|
||||
return cloneChain(DEFAULT_CHAIN);
|
||||
}
|
||||
|
||||
export function createSingleModelChain(model: string): ModelPolicyChain {
|
||||
return [definePolicy({ model, isLastResort: true })];
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a default policy scaffold for models not present in the catalog.
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './policyHelpers.js';
|
||||
import { createDefaultPolicy } from './policyCatalog.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { DEFAULT_GEMINI_MODEL_AUTO } from '../config/models.js';
|
||||
|
||||
const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
({
|
||||
@@ -24,7 +25,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
|
||||
describe('policyHelpers', () => {
|
||||
describe('resolvePolicyChain', () => {
|
||||
it('inserts the active model when missing from the catalog', () => {
|
||||
it('returns a single-model chain for a custom model', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => 'custom-model',
|
||||
});
|
||||
@@ -43,7 +44,7 @@ describe('policyHelpers', () => {
|
||||
|
||||
it('returns the default chain when active model is "auto"', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => 'auto',
|
||||
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
const chain = resolvePolicyChain(config);
|
||||
|
||||
@@ -52,6 +53,25 @@ describe('policyHelpers', () => {
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-pro');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('starts chain from preferredModel when model is "auto"', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
const chain = resolvePolicyChain(config, 'gemini-2.5-flash');
|
||||
expect(chain).toHaveLength(1);
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('wraps around the chain when wrapsAround is true', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
const chain = resolvePolicyChain(config, 'gemini-2.5-flash', true);
|
||||
expect(chain).toHaveLength(2);
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-flash');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-pro');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFallbackPolicyContext', () => {
|
||||
@@ -63,6 +83,17 @@ describe('policyHelpers', () => {
|
||||
];
|
||||
const context = buildFallbackPolicyContext(chain, 'b');
|
||||
expect(context.failedPolicy?.model).toBe('b');
|
||||
expect(context.candidates.map((p) => p.model)).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('wraps around when building fallback context if wrapsAround is true', () => {
|
||||
const chain = [
|
||||
createDefaultPolicy('a'),
|
||||
createDefaultPolicy('b'),
|
||||
createDefaultPolicy('c'),
|
||||
];
|
||||
const context = buildFallbackPolicyContext(chain, 'b', true);
|
||||
expect(context.failedPolicy?.model).toBe('b');
|
||||
expect(context.candidates.map((p) => p.model)).toEqual(['c', 'a']);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,17 @@ import type {
|
||||
ModelPolicyChain,
|
||||
RetryAvailabilityContext,
|
||||
} from './modelPolicy.js';
|
||||
import { createDefaultPolicy, getModelPolicyChain } from './policyCatalog.js';
|
||||
import { DEFAULT_GEMINI_MODEL, getEffectiveModel } from '../config/models.js';
|
||||
import {
|
||||
createDefaultPolicy,
|
||||
createSingleModelChain,
|
||||
getModelPolicyChain,
|
||||
} from './policyCatalog.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
resolveModel,
|
||||
} from '../config/models.js';
|
||||
import type { ModelSelectionResult } from './modelAvailabilityService.js';
|
||||
|
||||
/**
|
||||
@@ -24,27 +33,34 @@ import type { ModelSelectionResult } from './modelAvailabilityService.js';
|
||||
export function resolvePolicyChain(
|
||||
config: Config,
|
||||
preferredModel?: string,
|
||||
wrapsAround: boolean = false,
|
||||
): ModelPolicyChain {
|
||||
const chain = getModelPolicyChain({
|
||||
previewEnabled: !!config.getPreviewFeatures(),
|
||||
userTier: config.getUserTier(),
|
||||
});
|
||||
// TODO: This will be replaced when we get rid of Fallback Modes.
|
||||
// Switch to getActiveModel()
|
||||
const activeModel =
|
||||
preferredModel ??
|
||||
getEffectiveModel(
|
||||
config.isInFallbackMode(),
|
||||
config.getModel(),
|
||||
config.getPreviewFeatures(),
|
||||
);
|
||||
// Availability uses the active/requested model directly. Legacy fallback logic
|
||||
// (getEffectiveModel) only applies when availability is disabled.
|
||||
const modelFromConfig =
|
||||
preferredModel ?? config.getActiveModel?.() ?? config.getModel();
|
||||
|
||||
if (activeModel === 'auto') {
|
||||
return [...chain];
|
||||
let chain;
|
||||
|
||||
if (
|
||||
config.getModel() === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
config.getModel() === DEFAULT_GEMINI_MODEL_AUTO
|
||||
) {
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled: config.getModel() === PREVIEW_GEMINI_MODEL_AUTO,
|
||||
userTier: config.getUserTier(),
|
||||
});
|
||||
} else {
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
}
|
||||
|
||||
if (chain.some((policy) => policy.model === activeModel)) {
|
||||
return [...chain];
|
||||
const activeModel = resolveModel(modelFromConfig);
|
||||
|
||||
const activeIndex = chain.findIndex((policy) => policy.model === activeModel);
|
||||
if (activeIndex !== -1) {
|
||||
return wrapsAround
|
||||
? [...chain.slice(activeIndex), ...chain.slice(0, activeIndex)]
|
||||
: [...chain.slice(activeIndex)];
|
||||
}
|
||||
|
||||
// If the user specified a model not in the default chain, we assume they want
|
||||
@@ -55,10 +71,14 @@ export function resolvePolicyChain(
|
||||
/**
|
||||
* Produces the failed policy (if it exists in the chain) and the list of
|
||||
* fallback candidates that follow it.
|
||||
* @param chain - The ordered list of available model policies.
|
||||
* @param failedModel - The identifier of the model that failed.
|
||||
* @param wrapsAround - If true, treats the chain as a circular buffer.
|
||||
*/
|
||||
export function buildFallbackPolicyContext(
|
||||
chain: ModelPolicyChain,
|
||||
failedModel: string,
|
||||
wrapsAround: boolean = false,
|
||||
): {
|
||||
failedPolicy?: ModelPolicy;
|
||||
candidates: ModelPolicy[];
|
||||
@@ -69,9 +89,12 @@ export function buildFallbackPolicyContext(
|
||||
}
|
||||
// Return [candidates_after, candidates_before] to prioritize downgrades
|
||||
// (continuing the chain) before wrapping around to upgrades.
|
||||
const candidates = wrapsAround
|
||||
? [...chain.slice(index + 1), ...chain.slice(0, index)]
|
||||
: [...chain.slice(index + 1)];
|
||||
return {
|
||||
failedPolicy: chain[index],
|
||||
candidates: [...chain.slice(index + 1), ...chain.slice(0, index)],
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ import { logRipgrepFallback } from '../telemetry/loggers.js';
|
||||
import { RipgrepFallbackEvent } from '../telemetry/types.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
} from './models.js';
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
@@ -177,7 +183,7 @@ vi.mock('../code_assist/codeAssist.js');
|
||||
vi.mock('../code_assist/experiments/experiments.js');
|
||||
|
||||
describe('Server Config (config.ts)', () => {
|
||||
const MODEL = 'gemini-pro';
|
||||
const MODEL = DEFAULT_GEMINI_MODEL;
|
||||
const SANDBOX: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
@@ -769,6 +775,40 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('UseWriteTodos Configuration', () => {
|
||||
it('should default useWriteTodos to true when not provided', () => {
|
||||
const config = new Config(baseParams);
|
||||
expect(config.getUseWriteTodos()).toBe(true);
|
||||
});
|
||||
|
||||
it('should set useWriteTodos to false when provided as false', () => {
|
||||
const params: ConfigParameters = {
|
||||
...baseParams,
|
||||
useWriteTodos: false,
|
||||
};
|
||||
const config = new Config(params);
|
||||
expect(config.getUseWriteTodos()).toBe(false);
|
||||
});
|
||||
|
||||
it('should disable useWriteTodos for preview models', () => {
|
||||
const params: ConfigParameters = {
|
||||
...baseParams,
|
||||
model: 'gemini-3-pro-preview',
|
||||
};
|
||||
const config = new Config(params);
|
||||
expect(config.getUseWriteTodos()).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT disable useWriteTodos for non-preview models', () => {
|
||||
const params: ConfigParameters = {
|
||||
...baseParams,
|
||||
model: 'gemini-2.5-pro',
|
||||
};
|
||||
const config = new Config(params);
|
||||
expect(config.getUseWriteTodos()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell Tool Inactivity Timeout', () => {
|
||||
it('should default to 300000ms (300 seconds) when not provided', () => {
|
||||
const config = new Config(baseParams);
|
||||
@@ -1703,18 +1743,16 @@ describe('Availability Service Integration', () => {
|
||||
cwd: '.',
|
||||
};
|
||||
|
||||
it('setActiveModel updates active model and emits event', async () => {
|
||||
it('setActiveModel updates active model', async () => {
|
||||
const config = new Config(baseParams);
|
||||
const model1 = 'model1';
|
||||
const model2 = 'model2';
|
||||
|
||||
config.setActiveModel(model1);
|
||||
expect(config.getActiveModel()).toBe(model1);
|
||||
expect(mockCoreEvents.emitModelChanged).toHaveBeenCalledWith(model1);
|
||||
|
||||
config.setActiveModel(model2);
|
||||
expect(config.getActiveModel()).toBe(model2);
|
||||
expect(mockCoreEvents.emitModelChanged).toHaveBeenCalledWith(model2);
|
||||
});
|
||||
|
||||
it('getActiveModel defaults to configured model if not set', () => {
|
||||
@@ -1731,3 +1769,116 @@ describe('Availability Service Integration', () => {
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config Quota & Preview Model Access', () => {
|
||||
let config: Config;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let mockCodeAssistServer: any;
|
||||
|
||||
const baseParams: ConfigParameters = {
|
||||
cwd: '/tmp',
|
||||
targetDir: '/tmp',
|
||||
debugMode: false,
|
||||
sessionId: 'test-session',
|
||||
model: 'gemini-pro',
|
||||
usageStatisticsEnabled: false,
|
||||
embeddingModel: 'gemini-embedding', // required in type but not in the original file I copied, adding here
|
||||
sandbox: {
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCodeAssistServer = {
|
||||
projectId: 'test-project',
|
||||
retrieveUserQuota: vi.fn(),
|
||||
};
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue(mockCodeAssistServer);
|
||||
config = new Config(baseParams);
|
||||
});
|
||||
|
||||
describe('refreshUserQuota', () => {
|
||||
it('should update hasAccessToPreviewModel to true if quota includes preview model', async () => {
|
||||
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
|
||||
buckets: [{ modelId: PREVIEW_GEMINI_MODEL }],
|
||||
});
|
||||
|
||||
await config.refreshUserQuota();
|
||||
expect(config.getHasAccessToPreviewModel()).toBe(true);
|
||||
});
|
||||
|
||||
it('should update hasAccessToPreviewModel to false if quota does not include preview model', async () => {
|
||||
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
|
||||
buckets: [{ modelId: 'some-other-model' }],
|
||||
});
|
||||
|
||||
await config.refreshUserQuota();
|
||||
expect(config.getHasAccessToPreviewModel()).toBe(false);
|
||||
});
|
||||
|
||||
it('should update hasAccessToPreviewModel to false if buckets are undefined', async () => {
|
||||
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({});
|
||||
|
||||
await config.refreshUserQuota();
|
||||
expect(config.getHasAccessToPreviewModel()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined and not update if codeAssistServer is missing', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue(undefined);
|
||||
const result = await config.refreshUserQuota();
|
||||
expect(result).toBeUndefined();
|
||||
expect(config.getHasAccessToPreviewModel()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined if retrieveUserQuota fails', async () => {
|
||||
mockCodeAssistServer.retrieveUserQuota.mockRejectedValue(
|
||||
new Error('Network error'),
|
||||
);
|
||||
const result = await config.refreshUserQuota();
|
||||
expect(result).toBeUndefined();
|
||||
// Should remain default (false)
|
||||
expect(config.getHasAccessToPreviewModel()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPreviewFeatures', () => {
|
||||
it('should reset model to default auto if disabling preview features while using a preview model', () => {
|
||||
config.setPreviewFeatures(true);
|
||||
config.setModel(PREVIEW_GEMINI_MODEL);
|
||||
|
||||
config.setPreviewFeatures(false);
|
||||
|
||||
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
});
|
||||
|
||||
it('should NOT reset model if disabling preview features while NOT using a preview model', () => {
|
||||
config.setPreviewFeatures(true);
|
||||
const nonPreviewModel = 'gemini-1.5-pro';
|
||||
config.setModel(nonPreviewModel);
|
||||
|
||||
config.setPreviewFeatures(false);
|
||||
|
||||
expect(config.getModel()).toBe(nonPreviewModel);
|
||||
});
|
||||
|
||||
it('should switch to preview auto model if enabling preview features while using default auto model', () => {
|
||||
config.setPreviewFeatures(false);
|
||||
config.setModel(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
|
||||
config.setPreviewFeatures(true);
|
||||
|
||||
expect(config.getModel()).toBe(PREVIEW_GEMINI_MODEL_AUTO);
|
||||
});
|
||||
|
||||
it('should NOT reset model if enabling preview features', () => {
|
||||
config.setPreviewFeatures(false);
|
||||
config.setModel(PREVIEW_GEMINI_MODEL); // Just pretending it was set somehow
|
||||
|
||||
config.setPreviewFeatures(true);
|
||||
|
||||
expect(config.getModel()).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,11 @@ import { tokenLimit } from '../core/tokenLimits.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_THINKING_MODE,
|
||||
isPreviewModel,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
} from './models.js';
|
||||
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
|
||||
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
||||
@@ -80,6 +84,7 @@ import { PolicyEngine } from '../policy/policy-engine.js';
|
||||
import type { PolicyEngineConfig } from '../policy/types.js';
|
||||
import { HookSystem } from '../hooks/index.js';
|
||||
import type { UserTierId } from '../code_assist/types.js';
|
||||
import type { RetrieveUserQuotaResponse } from '../code_assist/types.js';
|
||||
import { getCodeAssistServer } from '../code_assist/codeAssist.js';
|
||||
import type { Experiments } from '../code_assist/experiments/experiments.js';
|
||||
import { AgentRegistry } from '../agents/registry.js';
|
||||
@@ -356,7 +361,6 @@ export class Config {
|
||||
private userMemory: string;
|
||||
private geminiMdFileCount: number;
|
||||
private geminiMdFilePaths: string[];
|
||||
private approvalMode: ApprovalMode;
|
||||
private readonly showMemoryUsage: boolean;
|
||||
private readonly accessibility: AccessibilitySettings;
|
||||
private readonly telemetrySettings: TelemetrySettings;
|
||||
@@ -379,6 +383,7 @@ export class Config {
|
||||
private readonly bugCommand: BugCommandSettings | undefined;
|
||||
private model: string;
|
||||
private previewFeatures: boolean | undefined;
|
||||
private hasAccessToPreviewModel: boolean = false;
|
||||
private readonly noBrowser: boolean;
|
||||
private readonly folderTrust: boolean;
|
||||
private ideMode: boolean;
|
||||
@@ -475,7 +480,6 @@ export class Config {
|
||||
this.userMemory = params.userMemory ?? '';
|
||||
this.geminiMdFileCount = params.geminiMdFileCount ?? 0;
|
||||
this.geminiMdFilePaths = params.geminiMdFilePaths ?? [];
|
||||
this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT;
|
||||
this.showMemoryUsage = params.showMemoryUsage ?? false;
|
||||
this.accessibility = params.accessibility ?? {};
|
||||
this.telemetrySettings = {
|
||||
@@ -508,8 +512,7 @@ export class Config {
|
||||
this.bugCommand = params.bugCommand;
|
||||
this.model = params.model;
|
||||
this._activeModel = params.model;
|
||||
this.enableModelAvailabilityService =
|
||||
params.enableModelAvailabilityService ?? false;
|
||||
this.enableModelAvailabilityService = true;
|
||||
this.enableAgents = params.enableAgents ?? false;
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
||||
this.modelAvailabilityService = new ModelAvailabilityService();
|
||||
@@ -551,7 +554,10 @@ export class Config {
|
||||
params.truncateToolOutputLines ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES;
|
||||
this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true;
|
||||
this.useSmartEdit = params.useSmartEdit ?? true;
|
||||
this.useWriteTodos = params.useWriteTodos ?? true;
|
||||
// // TODO(joshualitt): Re-evaluate the todo tool for 3 family.
|
||||
this.useWriteTodos = isPreviewModel(this.model)
|
||||
? false
|
||||
: (params.useWriteTodos ?? true);
|
||||
this.enableHooks = params.enableHooks ?? false;
|
||||
this.disabledHooks =
|
||||
(params.hooks && 'disabled' in params.hooks
|
||||
@@ -588,7 +594,11 @@ export class Config {
|
||||
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
|
||||
this.fileExclusions = new FileExclusions(this);
|
||||
this.eventEmitter = params.eventEmitter;
|
||||
this.policyEngine = new PolicyEngine(params.policyEngineConfig);
|
||||
this.policyEngine = new PolicyEngine({
|
||||
...params.policyEngineConfig,
|
||||
approvalMode:
|
||||
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
|
||||
});
|
||||
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
|
||||
this.outputSettings = {
|
||||
format: params.output?.format ?? OutputFormat.TEXT,
|
||||
@@ -716,6 +726,9 @@ export class Config {
|
||||
this.geminiClient.stripThoughtsFromHistory();
|
||||
}
|
||||
|
||||
// Reset availability status when switching auth (e.g. from limited key to OAuth)
|
||||
this.modelAvailabilityService.reset();
|
||||
|
||||
const newContentGeneratorConfig = await createContentGeneratorConfig(
|
||||
this,
|
||||
authMethod,
|
||||
@@ -731,16 +744,18 @@ export class Config {
|
||||
// Initialize BaseLlmClient now that the ContentGenerator is available
|
||||
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
|
||||
|
||||
const previewFeatures = this.getPreviewFeatures();
|
||||
|
||||
const codeAssistServer = getCodeAssistServer(this);
|
||||
if (codeAssistServer) {
|
||||
if (codeAssistServer.projectId) {
|
||||
await this.refreshUserQuota();
|
||||
}
|
||||
|
||||
this.experimentsPromise = getExperiments(codeAssistServer)
|
||||
.then((experiments) => {
|
||||
this.setExperiments(experiments);
|
||||
|
||||
// If preview features have not been set and the user authenticated through Google, we enable preview based on remote config only if it's true
|
||||
if (previewFeatures === undefined) {
|
||||
if (this.getPreviewFeatures() === undefined) {
|
||||
const remotePreviewFeatures =
|
||||
experiments.flags[ExperimentFlags.ENABLE_PREVIEW]?.boolValue;
|
||||
if (remotePreviewFeatures === true) {
|
||||
@@ -756,8 +771,21 @@ export class Config {
|
||||
this.experimentsPromise = undefined;
|
||||
}
|
||||
|
||||
const authType = this.contentGeneratorConfig.authType;
|
||||
if (
|
||||
authType === AuthType.USE_GEMINI ||
|
||||
authType === AuthType.USE_VERTEX_AI
|
||||
) {
|
||||
this.setHasAccessToPreviewModel(true);
|
||||
}
|
||||
|
||||
// Reset the session flag since we're explicitly changing auth and using default model
|
||||
this.inFallbackMode = false;
|
||||
|
||||
// Update model if user no longer has access to the preview model
|
||||
if (!this.hasAccessToPreviewModel && isPreviewModel(this.model)) {
|
||||
this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
}
|
||||
}
|
||||
|
||||
async getExperimentsAsync(): Promise<Experiments | undefined> {
|
||||
@@ -841,7 +869,6 @@ export class Config {
|
||||
setActiveModel(model: string): void {
|
||||
if (this._activeModel !== model) {
|
||||
this._activeModel = model;
|
||||
coreEvents.emitModelChanged(model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,7 +979,49 @@ export class Config {
|
||||
}
|
||||
|
||||
setPreviewFeatures(previewFeatures: boolean) {
|
||||
// No change in state, no action needed
|
||||
if (this.previewFeatures === previewFeatures) {
|
||||
return;
|
||||
}
|
||||
this.previewFeatures = previewFeatures;
|
||||
const currentModel = this.getModel();
|
||||
|
||||
// Case 1: Disabling preview features while on a preview model
|
||||
if (!previewFeatures && isPreviewModel(currentModel)) {
|
||||
this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
}
|
||||
|
||||
// Case 2: Enabling preview features while on the default auto model
|
||||
else if (previewFeatures && currentModel === DEFAULT_GEMINI_MODEL_AUTO) {
|
||||
this.setModel(PREVIEW_GEMINI_MODEL_AUTO);
|
||||
}
|
||||
}
|
||||
|
||||
getHasAccessToPreviewModel(): boolean {
|
||||
return this.hasAccessToPreviewModel;
|
||||
}
|
||||
|
||||
setHasAccessToPreviewModel(hasAccess: boolean): void {
|
||||
this.hasAccessToPreviewModel = hasAccess;
|
||||
}
|
||||
|
||||
async refreshUserQuota(): Promise<RetrieveUserQuotaResponse | undefined> {
|
||||
const codeAssistServer = getCodeAssistServer(this);
|
||||
if (!codeAssistServer || !codeAssistServer.projectId) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const quota = await codeAssistServer.retrieveUserQuota({
|
||||
project: codeAssistServer.projectId,
|
||||
});
|
||||
const hasAccess =
|
||||
quota.buckets?.some((b) => b.modelId === PREVIEW_GEMINI_MODEL) ?? false;
|
||||
this.setHasAccessToPreviewModel(hasAccess);
|
||||
return quota;
|
||||
} catch (e) {
|
||||
debugLogger.debug('Failed to retrieve user quota', e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getCoreTools(): string[] | undefined {
|
||||
@@ -1060,7 +1129,7 @@ export class Config {
|
||||
}
|
||||
|
||||
getApprovalMode(): ApprovalMode {
|
||||
return this.approvalMode;
|
||||
return this.policyEngine.getApprovalMode();
|
||||
}
|
||||
|
||||
setApprovalMode(mode: ApprovalMode): void {
|
||||
@@ -1069,7 +1138,7 @@ export class Config {
|
||||
'Cannot enable privileged approval modes in an untrusted folder.',
|
||||
);
|
||||
}
|
||||
this.approvalMode = mode;
|
||||
this.policyEngine.setApprovalMode(mode);
|
||||
}
|
||||
|
||||
isYoloModeDisabled(): boolean {
|
||||
|
||||
@@ -65,6 +65,12 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
model: 'gemini-3-pro-preview',
|
||||
},
|
||||
},
|
||||
'gemini-3-flash-preview': {
|
||||
extends: 'chat-base-3',
|
||||
modelConfig: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
},
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
extends: 'chat-base-2.5',
|
||||
modelConfig: {
|
||||
@@ -188,6 +194,11 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
model: 'gemini-3-pro-preview',
|
||||
},
|
||||
},
|
||||
'chat-compression-3-flash': {
|
||||
modelConfig: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
},
|
||||
},
|
||||
'chat-compression-2.5-pro': {
|
||||
modelConfig: {
|
||||
model: 'gemini-2.5-pro',
|
||||
|
||||
@@ -7,14 +7,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getEffectiveModel,
|
||||
resolveClassifierModel,
|
||||
isGemini2Model,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
supportsMultimodalFunctionResponse,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
supportsMultimodalFunctionResponse,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
} from './models.js';
|
||||
|
||||
describe('supportsMultimodalFunctionResponse', () => {
|
||||
@@ -34,210 +39,136 @@ describe('supportsMultimodalFunctionResponse', () => {
|
||||
});
|
||||
|
||||
describe('getEffectiveModel', () => {
|
||||
describe('When NOT in fallback mode', () => {
|
||||
const isInFallbackMode = false;
|
||||
describe('delegation to resolveModel', () => {
|
||||
it('should return the Preview Pro model when auto-gemini-3 is requested', () => {
|
||||
const model = getEffectiveModel(PREVIEW_GEMINI_MODEL_AUTO, false);
|
||||
expect(model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should return the Pro model when Pro is requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
false,
|
||||
);
|
||||
it('should return the Default Pro model when auto-gemini-2.5 is requested', () => {
|
||||
const model = getEffectiveModel(DEFAULT_GEMINI_MODEL_AUTO, false);
|
||||
expect(model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should return the Flash model when Flash is requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
it('should return the requested model as-is for explicit specific models', () => {
|
||||
expect(getEffectiveModel(DEFAULT_GEMINI_MODEL, false)).toBe(
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
expect(getEffectiveModel(DEFAULT_GEMINI_FLASH_MODEL, false)).toBe(
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the Lite model when Lite is requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
expect(getEffectiveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, false)).toBe(
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
false,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should return a custom model name when requested', () => {
|
||||
const customModel = 'custom-model-v1';
|
||||
const model = getEffectiveModel(isInFallbackMode, customModel, false);
|
||||
const model = getEffectiveModel(customModel, false);
|
||||
expect(model).toBe(customModel);
|
||||
});
|
||||
|
||||
describe('with preview features', () => {
|
||||
it('should return the preview model when pro alias is requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
true,
|
||||
);
|
||||
const model = getEffectiveModel(GEMINI_MODEL_ALIAS_PRO, true);
|
||||
expect(model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should return the default pro model when pro alias is requested and preview is off', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
false,
|
||||
);
|
||||
const model = getEffectiveModel(GEMINI_MODEL_ALIAS_PRO, false);
|
||||
expect(model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should return the flash model when flash is requested and preview is on', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
const model = getEffectiveModel(GEMINI_MODEL_ALIAS_FLASH, true);
|
||||
expect(model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the flash model when lite is requested and preview is on', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
true,
|
||||
);
|
||||
const model = getEffectiveModel(GEMINI_MODEL_ALIAS_FLASH_LITE, true);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should return the flash model when the flash model name is explicitly requested and preview is on', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
true,
|
||||
);
|
||||
const model = getEffectiveModel(DEFAULT_GEMINI_FLASH_MODEL, true);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the lite model when the lite model name is requested and preview is on', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
true,
|
||||
);
|
||||
const model = getEffectiveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, true);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should return the default gemini model when the model is explicitly set and preview is on', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
true,
|
||||
);
|
||||
const model = getEffectiveModel(DEFAULT_GEMINI_MODEL, true);
|
||||
expect(model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When IN fallback mode', () => {
|
||||
const isInFallbackMode = true;
|
||||
describe('isGemini2Model', () => {
|
||||
it('should return true for gemini-2.5-pro', () => {
|
||||
expect(isGemini2Model('gemini-2.5-pro')).toBe(true);
|
||||
});
|
||||
|
||||
it('should downgrade the Pro model to the Flash model', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
false,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
it('should return true for gemini-2.5-flash', () => {
|
||||
expect(isGemini2Model('gemini-2.5-flash')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return the Flash model when Flash is requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
false,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
it('should return true for gemini-2.0-flash', () => {
|
||||
expect(isGemini2Model('gemini-2.0-flash')).toBe(true);
|
||||
});
|
||||
|
||||
it('should HONOR the Lite model when Lite is requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
false,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
it('should return false for gemini-1.5-pro', () => {
|
||||
expect(isGemini2Model('gemini-1.5-pro')).toBe(false);
|
||||
});
|
||||
|
||||
it('should HONOR any model with "lite" in its name', () => {
|
||||
const customLiteModel = 'gemini-2.5-custom-lite-vNext';
|
||||
const model = getEffectiveModel(isInFallbackMode, customLiteModel, false);
|
||||
expect(model).toBe(customLiteModel);
|
||||
});
|
||||
it('should return false for gemini-3-pro', () => {
|
||||
expect(isGemini2Model('gemini-3-pro')).toBe(false);
|
||||
});
|
||||
|
||||
it('should downgrade any other custom model to the Flash model', () => {
|
||||
const customModel = 'custom-model-v1-unlisted';
|
||||
const model = getEffectiveModel(isInFallbackMode, customModel, false);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
describe('with preview features', () => {
|
||||
it('should downgrade the Pro alias to the Flash model', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the Flash alias when requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the Lite alias when requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should downgrade the default Gemini model to the Flash model', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the default Flash model when requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return the default Lite model when requested', () => {
|
||||
const model = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
true,
|
||||
);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('should downgrade any other custom model to the Flash model', () => {
|
||||
const customModel = 'custom-model-v1-unlisted';
|
||||
const model = getEffectiveModel(isInFallbackMode, customModel, true);
|
||||
expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
});
|
||||
it('should return false for arbitrary strings', () => {
|
||||
expect(isGemini2Model('gpt-4')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClassifierModel', () => {
|
||||
it('should return flash model when alias is flash', () => {
|
||||
expect(
|
||||
resolveClassifierModel(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
),
|
||||
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(
|
||||
resolveClassifierModel(
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
),
|
||||
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
|
||||
it('should return pro model when alias is pro', () => {
|
||||
expect(
|
||||
resolveClassifierModel(DEFAULT_GEMINI_MODEL_AUTO, GEMINI_MODEL_ALIAS_PRO),
|
||||
).toBe(DEFAULT_GEMINI_MODEL);
|
||||
expect(
|
||||
resolveClassifierModel(PREVIEW_GEMINI_MODEL_AUTO, GEMINI_MODEL_ALIAS_PRO),
|
||||
).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should handle preview features being enabled', () => {
|
||||
// If preview is enabled, resolving 'flash' without context (fallback) might switch to preview flash,
|
||||
// but here we test explicit auto models which should stick to their families if possible?
|
||||
// Actually our logic forces DEFAULT_GEMINI_FLASH_MODEL for DEFAULT_GEMINI_MODEL_AUTO even if preview is on,
|
||||
// because the USER requested 2.5 explicitly via "auto-gemini-2.5".
|
||||
expect(
|
||||
resolveClassifierModel(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
true,
|
||||
),
|
||||
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,20 +5,24 @@
|
||||
*/
|
||||
|
||||
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
|
||||
export const PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview';
|
||||
export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
|
||||
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
|
||||
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
|
||||
|
||||
export const VALID_GEMINI_MODELS = new Set([
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
]);
|
||||
|
||||
export const DEFAULT_GEMINI_MODEL_AUTO = 'auto';
|
||||
export const PREVIEW_GEMINI_MODEL_AUTO = 'auto-gemini-3';
|
||||
export const DEFAULT_GEMINI_MODEL_AUTO = 'auto-gemini-2.5';
|
||||
|
||||
// Model aliases for user convenience.
|
||||
export const GEMINI_MODEL_ALIAS_AUTO = 'auto';
|
||||
export const GEMINI_MODEL_ALIAS_PRO = 'pro';
|
||||
export const GEMINI_MODEL_ALIAS_FLASH = 'flash';
|
||||
export const GEMINI_MODEL_ALIAS_FLASH_LITE = 'flash-lite';
|
||||
@@ -38,17 +42,24 @@ export const DEFAULT_THINKING_MODE = 8192;
|
||||
*/
|
||||
export function resolveModel(
|
||||
requestedModel: string,
|
||||
previewFeaturesEnabled: boolean | undefined,
|
||||
previewFeaturesEnabled: boolean = false,
|
||||
): string {
|
||||
switch (requestedModel) {
|
||||
case DEFAULT_GEMINI_MODEL_AUTO:
|
||||
case PREVIEW_GEMINI_MODEL_AUTO: {
|
||||
return PREVIEW_GEMINI_MODEL;
|
||||
}
|
||||
case DEFAULT_GEMINI_MODEL_AUTO: {
|
||||
return DEFAULT_GEMINI_MODEL;
|
||||
}
|
||||
case GEMINI_MODEL_ALIAS_PRO: {
|
||||
return previewFeaturesEnabled
|
||||
? PREVIEW_GEMINI_MODEL
|
||||
: DEFAULT_GEMINI_MODEL;
|
||||
}
|
||||
case GEMINI_MODEL_ALIAS_FLASH: {
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
return previewFeaturesEnabled
|
||||
? PREVIEW_GEMINI_FLASH_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_MODEL;
|
||||
}
|
||||
case GEMINI_MODEL_ALIAS_FLASH_LITE: {
|
||||
return DEFAULT_GEMINI_FLASH_LITE_MODEL;
|
||||
@@ -60,39 +71,86 @@ export function resolveModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the effective model to use, applying fallback logic if necessary.
|
||||
* Resolves the appropriate model based on the classifier's decision.
|
||||
*
|
||||
* When fallback mode is active, this function enforces the use of the standard
|
||||
* fallback model. However, it makes an exception for "lite" models (any model
|
||||
* with "lite" in its name), allowing them to be used to preserve cost savings.
|
||||
* This ensures that "pro" models are always downgraded, while "lite" model
|
||||
* requests are honored.
|
||||
* @param requestedModel The current requested model (e.g. auto-gemini-2.5).
|
||||
* @param modelAlias The alias selected by the classifier ('flash' or 'pro').
|
||||
* @param previewFeaturesEnabled Whether preview features are enabled.
|
||||
* @returns The resolved concrete model name.
|
||||
*/
|
||||
export function resolveClassifierModel(
|
||||
requestedModel: string,
|
||||
modelAlias: string,
|
||||
previewFeaturesEnabled: boolean = false,
|
||||
): string {
|
||||
if (modelAlias === GEMINI_MODEL_ALIAS_FLASH) {
|
||||
if (
|
||||
requestedModel === DEFAULT_GEMINI_MODEL_AUTO ||
|
||||
requestedModel === DEFAULT_GEMINI_MODEL
|
||||
) {
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
}
|
||||
if (
|
||||
requestedModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
requestedModel === PREVIEW_GEMINI_MODEL
|
||||
) {
|
||||
return PREVIEW_GEMINI_FLASH_MODEL;
|
||||
}
|
||||
return resolveModel(GEMINI_MODEL_ALIAS_FLASH, previewFeaturesEnabled);
|
||||
}
|
||||
return resolveModel(requestedModel, previewFeaturesEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the effective model to use.
|
||||
*
|
||||
* @param isInFallbackMode Whether the application is in fallback mode.
|
||||
* @param requestedModel The model that was originally requested.
|
||||
* @param previewFeaturesEnabled A boolean indicating if preview features are enabled.
|
||||
* @returns The effective model name.
|
||||
*/
|
||||
export function getEffectiveModel(
|
||||
isInFallbackMode: boolean,
|
||||
requestedModel: string,
|
||||
previewFeaturesEnabled: boolean | undefined,
|
||||
): string {
|
||||
const resolvedModel = resolveModel(requestedModel, previewFeaturesEnabled);
|
||||
return resolveModel(requestedModel, previewFeaturesEnabled);
|
||||
}
|
||||
|
||||
// If we are not in fallback mode, simply use the resolved model.
|
||||
if (!isInFallbackMode) {
|
||||
return resolvedModel;
|
||||
export function getDisplayString(
|
||||
model: string,
|
||||
previewFeaturesEnabled: boolean = false,
|
||||
) {
|
||||
switch (model) {
|
||||
case PREVIEW_GEMINI_MODEL_AUTO:
|
||||
return 'Auto (Gemini 3)';
|
||||
case DEFAULT_GEMINI_MODEL_AUTO:
|
||||
return 'Auto (Gemini 2.5)';
|
||||
case GEMINI_MODEL_ALIAS_PRO:
|
||||
return `Manual (${
|
||||
previewFeaturesEnabled ? PREVIEW_GEMINI_MODEL : DEFAULT_GEMINI_MODEL
|
||||
})`;
|
||||
case GEMINI_MODEL_ALIAS_FLASH:
|
||||
return `Manual (${
|
||||
previewFeaturesEnabled
|
||||
? PREVIEW_GEMINI_FLASH_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_MODEL
|
||||
})`;
|
||||
default:
|
||||
return `Manual (${model})`;
|
||||
}
|
||||
}
|
||||
|
||||
// If a "lite" model is requested, honor it. This allows for variations of
|
||||
// lite models without needing to list them all as constants.
|
||||
if (resolvedModel.includes('lite')) {
|
||||
return resolvedModel;
|
||||
}
|
||||
|
||||
// Default fallback for Gemini CLI.
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
/**
|
||||
* Checks if the model is a preview model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @returns True if the model is a preview model.
|
||||
*/
|
||||
export function isPreviewModel(model: string): boolean {
|
||||
return (
|
||||
model === PREVIEW_GEMINI_MODEL ||
|
||||
model === PREVIEW_GEMINI_FLASH_MODEL ||
|
||||
model === PREVIEW_GEMINI_MODEL_AUTO
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,11 +21,10 @@ Mock Agent Directory
|
||||
|
||||
## Software Engineering Tasks
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution.
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core
|
||||
Mandates').
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
|
||||
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
|
||||
@@ -125,11 +124,10 @@ Mock Agent Directory
|
||||
|
||||
## Software Engineering Tasks
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution.
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core
|
||||
Mandates').
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
|
||||
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
|
||||
@@ -224,11 +222,10 @@ Mock Agent Directory
|
||||
|
||||
## Software Engineering Tasks
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution.
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core
|
||||
Mandates').
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
|
||||
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
|
||||
@@ -338,11 +335,10 @@ Mock Agent Directory
|
||||
|
||||
## Software Engineering Tasks
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution.
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core
|
||||
Mandates').
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
|
||||
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
|
||||
@@ -437,11 +433,10 @@ Mock Agent Directory
|
||||
|
||||
## Software Engineering Tasks
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution.
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core
|
||||
Mandates').
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
|
||||
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
|
||||
@@ -536,11 +531,10 @@ Mock Agent Directory
|
||||
|
||||
## Software Engineering Tasks
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution.
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core
|
||||
Mandates').
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
|
||||
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
|
||||
@@ -635,11 +629,10 @@ Mock Agent Directory
|
||||
|
||||
## Software Engineering Tasks
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution.
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core
|
||||
Mandates').
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
|
||||
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
|
||||
@@ -734,11 +727,10 @@ Mock Agent Directory
|
||||
|
||||
## Software Engineering Tasks
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution.
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core
|
||||
Mandates').
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
|
||||
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
|
||||
@@ -833,11 +825,10 @@ Mock Agent Directory
|
||||
|
||||
## Software Engineering Tasks
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution.
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core
|
||||
Mandates').
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
|
||||
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
|
||||
@@ -933,11 +924,10 @@ Mock Agent Directory
|
||||
|
||||
## Software Engineering Tasks
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution.
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core
|
||||
Mandates').
|
||||
3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
|
||||
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
} from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import type { AuthType } from './contentGenerator.js';
|
||||
import { handleFallback } from '../fallback/handler.js';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
import { reportError } from '../utils/errorReporting.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
@@ -86,6 +88,7 @@ export class BaseLlmClient {
|
||||
constructor(
|
||||
private readonly contentGenerator: ContentGenerator,
|
||||
private readonly config: Config,
|
||||
private readonly authType?: AuthType,
|
||||
) {}
|
||||
|
||||
async generateJson(
|
||||
@@ -286,6 +289,12 @@ export class BaseLlmClient {
|
||||
maxAttempts:
|
||||
availabilityMaxAttempts ?? maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
||||
getAvailabilityContext,
|
||||
onPersistent429: this.config.isInteractive()
|
||||
? (authType, error) =>
|
||||
handleFallback(this.config, requestParams.model, authType, error)
|
||||
: undefined,
|
||||
authType:
|
||||
this.authType ?? this.config.getContentGeneratorConfig()?.authType,
|
||||
});
|
||||
} catch (error) {
|
||||
if (abortSignal?.aborted) {
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
type ChatCompressionInfo,
|
||||
} from './turn.js';
|
||||
import { getCoreSystemPrompt } from './prompts.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
} from '../config/models.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { setSimulate429 } from '../utils/testUtils.js';
|
||||
import { tokenLimit } from './tokenLimits.js';
|
||||
@@ -2044,7 +2047,9 @@ ${JSON.stringify(
|
||||
skipped: [],
|
||||
},
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'Hi' }],
|
||||
new AbortController().signal,
|
||||
@@ -2074,7 +2079,9 @@ ${JSON.stringify(
|
||||
skipped: [],
|
||||
},
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'Hi' }],
|
||||
new AbortController().signal,
|
||||
|
||||
@@ -31,10 +31,7 @@ import type {
|
||||
ResumedSessionData,
|
||||
} from '../services/chatRecordingService.js';
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
getEffectiveModel,
|
||||
} from '../config/models.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import { LoopDetectionService } from '../services/loopDetectionService.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
@@ -395,12 +392,9 @@ export class GeminiClient {
|
||||
return this.currentSequenceModel;
|
||||
}
|
||||
|
||||
const configModel = this.config.getModel();
|
||||
return getEffectiveModel(
|
||||
this.config.isInFallbackMode(),
|
||||
configModel,
|
||||
this.config.getPreviewFeatures(),
|
||||
);
|
||||
// Availability logic: The configured model is the source of truth,
|
||||
// including any permanent fallbacks (config.setModel) or manual overrides.
|
||||
return this.config.getActiveModel();
|
||||
}
|
||||
|
||||
async *sendMessageStream(
|
||||
|
||||
@@ -118,7 +118,6 @@ export async function createContentGenerator(
|
||||
}
|
||||
const version = await getVersion();
|
||||
const model = getEffectiveModel(
|
||||
gcConfig.isInFallbackMode(),
|
||||
gcConfig.getModel(),
|
||||
gcConfig.getPreviewFeatures(),
|
||||
);
|
||||
|
||||
@@ -19,13 +19,13 @@ import type { Config } from '../config/config.js';
|
||||
import { setSimulate429 } from '../utils/testUtils.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_THINKING_MODE,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { AuthType } from './contentGenerator.js';
|
||||
import { TerminalQuotaError } from '../utils/googleQuotaErrors.js';
|
||||
import { retryWithBackoff, type RetryOptions } from '../utils/retry.js';
|
||||
import { type RetryOptions } from '../utils/retry.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
import { HookSystem } from '../hooks/hookSystem.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
@@ -127,18 +127,25 @@ describe('GeminiChat', () => {
|
||||
}
|
||||
return result;
|
||||
});
|
||||
let currentModel = 'gemini-pro';
|
||||
let currentActiveModel = 'gemini-pro';
|
||||
|
||||
mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getTelemetryLogPromptsEnabled: () => true,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getPreviewFeatures: () => false,
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'oauth-personal', // Ensure this is set for fallback tests
|
||||
model: 'test-model',
|
||||
getContentGeneratorConfig: vi.fn().mockImplementation(() => ({
|
||||
authType: 'oauth-personal',
|
||||
model: currentModel,
|
||||
})),
|
||||
getModel: vi.fn().mockImplementation(() => currentModel),
|
||||
setModel: vi.fn().mockImplementation((m: string) => {
|
||||
currentModel = m;
|
||||
// When model is explicitly set, active model usually resets or updates to it
|
||||
currentActiveModel = m;
|
||||
}),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
setModel: vi.fn(),
|
||||
isInFallbackMode: vi.fn().mockReturnValue(false),
|
||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||
setQuotaErrorOccurred: vi.fn(),
|
||||
@@ -155,7 +162,8 @@ describe('GeminiChat', () => {
|
||||
getUserTier: vi.fn().mockReturnValue(undefined),
|
||||
modelConfigService: {
|
||||
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => {
|
||||
const thinkingConfig = modelConfigKey.model.startsWith('gemini-3')
|
||||
const model = modelConfigKey.model ?? mockConfig.getModel();
|
||||
const thinkingConfig = model.startsWith('gemini-3')
|
||||
? {
|
||||
thinkingLevel: ThinkingLevel.HIGH,
|
||||
}
|
||||
@@ -163,7 +171,7 @@ describe('GeminiChat', () => {
|
||||
thinkingBudget: DEFAULT_THINKING_MODE,
|
||||
};
|
||||
return {
|
||||
model: modelConfigKey.model,
|
||||
model,
|
||||
generateContentConfig: {
|
||||
temperature: 0,
|
||||
thinkingConfig,
|
||||
@@ -178,8 +186,10 @@ describe('GeminiChat', () => {
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
getEnableHooks: vi.fn().mockReturnValue(false),
|
||||
isModelAvailabilityServiceEnabled: vi.fn().mockReturnValue(false),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
setActiveModel: vi.fn(),
|
||||
getActiveModel: vi.fn().mockImplementation(() => currentActiveModel),
|
||||
setActiveModel: vi
|
||||
.fn()
|
||||
.mockImplementation((m: string) => (currentActiveModel = m)),
|
||||
getModelAvailabilityService: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -570,6 +580,39 @@ describe('GeminiChat', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use maxAttempts=1 for retryWithBackoff when in Preview Model Fallback Mode (Flash)', async () => {
|
||||
vi.mocked(mockConfig.isPreviewModelFallbackMode).mockReturnValue(true);
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: PREVIEW_GEMINI_FLASH_MODEL },
|
||||
'test',
|
||||
'prompt-id-fast-retry-flash',
|
||||
new AbortController().signal,
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockRetryWithBackoff).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
maxAttempts: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT use maxAttempts=1 for other models even in Preview Model Fallback Mode', async () => {
|
||||
vi.mocked(mockConfig.isPreviewModelFallbackMode).mockReturnValue(true);
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
@@ -603,62 +646,6 @@ describe('GeminiChat', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass DEFAULT_GEMINI_MODEL to handleFallback when Preview Model is bypassed (downgraded)', async () => {
|
||||
// ARRANGE
|
||||
vi.mocked(mockConfig.isPreviewModelBypassMode).mockReturnValue(true);
|
||||
// Mock retryWithBackoff to simulate catching the error and calling onPersistent429
|
||||
vi.mocked(retryWithBackoff).mockImplementation(
|
||||
async (apiCall, options) => {
|
||||
const onPersistent429 = options?.onPersistent429;
|
||||
try {
|
||||
await apiCall();
|
||||
} catch (error) {
|
||||
if (onPersistent429) {
|
||||
await onPersistent429(AuthType.LOGIN_WITH_GOOGLE, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// We need the API call to fail so retryWithBackoff calls the callback.
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(
|
||||
new TerminalQuotaError('Simulated Quota Error', {
|
||||
code: 429,
|
||||
message: 'Simulated Quota Error',
|
||||
details: [],
|
||||
}),
|
||||
);
|
||||
|
||||
// ACT
|
||||
const consumeStream = async () => {
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: PREVIEW_GEMINI_MODEL },
|
||||
'test',
|
||||
'prompt-id-bypass',
|
||||
new AbortController().signal,
|
||||
);
|
||||
// Consume the stream to trigger execution
|
||||
for await (const _ of stream) {
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
|
||||
await expect(consumeStream()).rejects.toThrow('Simulated Quota Error');
|
||||
|
||||
expect(retryWithBackoff).toHaveBeenCalled();
|
||||
|
||||
// ASSERT
|
||||
// handleFallback is called via onPersistent429Callback
|
||||
// We verify it was called with DEFAULT_GEMINI_MODEL
|
||||
expect(mockHandleFallback).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
DEFAULT_GEMINI_MODEL, // This is the key assertion
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when a tool call is followed by an empty stream response', async () => {
|
||||
// 1. Setup: A history where the model has just made a function call.
|
||||
const initialHistory: Content[] = [
|
||||
@@ -1848,45 +1835,6 @@ describe('GeminiChat', () => {
|
||||
expect(turn4.parts[0].text).toBe('second response');
|
||||
});
|
||||
|
||||
describe('Model Resolution', () => {
|
||||
const mockResponse = {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'response' }], role: 'model' },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
|
||||
it('should use the FLASH model when in fallback mode (sendMessageStream)', async () => {
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-pro');
|
||||
vi.mocked(mockConfig.isInFallbackMode).mockReturnValue(true);
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
|
||||
async () =>
|
||||
(async function* () {
|
||||
yield mockResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: 'test-model' },
|
||||
'test message',
|
||||
'prompt-id-res3',
|
||||
new AbortController().signal,
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
}),
|
||||
'prompt-id-res3',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback Integration (Retries)', () => {
|
||||
const error429 = new ApiError({
|
||||
message: 'API Error 429: Quota exceeded',
|
||||
@@ -1983,92 +1931,6 @@ describe('GeminiChat', () => {
|
||||
expect(modelTurn.parts![0].text).toBe('Success on retry');
|
||||
});
|
||||
|
||||
it('should switch to DEFAULT_GEMINI_FLASH_MODEL and use thinkingBudget when falling back from a gemini-3 model', async () => {
|
||||
// ARRANGE
|
||||
const authType = AuthType.LOGIN_WITH_GOOGLE;
|
||||
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
|
||||
authType,
|
||||
});
|
||||
|
||||
// Initial state: Not in fallback mode
|
||||
const isInFallbackModeSpy = vi.spyOn(mockConfig, 'isInFallbackMode');
|
||||
isInFallbackModeSpy.mockReturnValue(false);
|
||||
|
||||
// Mock API calls:
|
||||
// 1. Fails with 429 (simulating gemini-3 failure)
|
||||
// 2. Succeeds (simulating fallback success)
|
||||
vi.mocked(mockContentGenerator.generateContentStream)
|
||||
.mockRejectedValueOnce(error429)
|
||||
.mockResolvedValueOnce(
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Fallback success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
// Mock handleFallback to enable fallback mode and signal retry
|
||||
mockHandleFallback.mockImplementation(async () => {
|
||||
isInFallbackModeSpy.mockReturnValue(true); // Next call will see fallback mode = true
|
||||
return true;
|
||||
});
|
||||
|
||||
// ACT
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: 'gemini-3-test-model' }, // Start with a gemini-3 model
|
||||
'test fallback thinking',
|
||||
'prompt-id-fb3',
|
||||
new AbortController().signal,
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
// ASSERT
|
||||
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
|
||||
// First call: gemini-3 model, thinkingLevel set
|
||||
expect(
|
||||
mockContentGenerator.generateContentStream,
|
||||
).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
model: 'gemini-3-test-model',
|
||||
config: expect.objectContaining({
|
||||
thinkingConfig: {
|
||||
thinkingBudget: undefined,
|
||||
thinkingLevel: ThinkingLevel.HIGH,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'prompt-id-fb3',
|
||||
);
|
||||
|
||||
// Second call: DEFAULT_GEMINI_FLASH_MODEL (due to fallback), thinkingBudget set (due to fix)
|
||||
expect(
|
||||
mockContentGenerator.generateContentStream,
|
||||
).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
config: expect.objectContaining({
|
||||
thinkingConfig: {
|
||||
thinkingBudget: DEFAULT_THINKING_MODE,
|
||||
thinkingLevel: undefined,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'prompt-id-fb3',
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop retrying if handleFallback returns false (e.g., auth intent)', async () => {
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-pro');
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(
|
||||
@@ -2200,92 +2062,6 @@ describe('GeminiChat', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Preview Model Fallback Logic', () => {
|
||||
it('should reset previewModelBypassMode to false at the start of sendMessageStream', async () => {
|
||||
const stream = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
|
||||
await chat.sendMessageStream(
|
||||
{ model: 'test-model' },
|
||||
'test',
|
||||
'prompt-id-preview-model-reset',
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should reset previewModelFallbackMode to false upon successful Preview Model usage', async () => {
|
||||
const stream = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
|
||||
const resultStream = await chat.sendMessageStream(
|
||||
{ model: PREVIEW_GEMINI_MODEL },
|
||||
'test',
|
||||
'prompt-id-preview-model-healing',
|
||||
new AbortController().signal,
|
||||
);
|
||||
for await (const _ of resultStream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it('should NOT reset previewModelFallbackMode if Preview Model was bypassed (downgraded)', async () => {
|
||||
const stream = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
// Simulate bypass mode being active (downgrade happened)
|
||||
vi.mocked(mockConfig.isPreviewModelBypassMode).mockReturnValue(true);
|
||||
|
||||
const resultStream = await chat.sendMessageStream(
|
||||
{ model: PREVIEW_GEMINI_MODEL },
|
||||
'test',
|
||||
'prompt-id-bypass-no-healing',
|
||||
new AbortController().signal,
|
||||
);
|
||||
for await (const _ of resultStream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockConfig.setPreviewModelFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureActiveLoopHasThoughtSignatures', () => {
|
||||
it('should add thoughtSignature to the first functionCall in each model turn of the active loop', () => {
|
||||
const chat = new GeminiChat(mockConfig, '', [], []);
|
||||
|
||||
@@ -22,11 +22,10 @@ import { createUserContent, FinishReason } from '@google/genai';
|
||||
import { retryWithBackoff, isRetryableError } from '../utils/retry.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_THINKING_MODE,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
getEffectiveModel,
|
||||
resolveModel,
|
||||
isGemini2Model,
|
||||
isPreviewModel,
|
||||
} from '../config/models.js';
|
||||
import { hasCycleInSchema } from '../tools/tools.js';
|
||||
import type { StructuredError } from './turn.js';
|
||||
@@ -306,10 +305,7 @@ export class GeminiChat {
|
||||
let maxAttempts = INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
|
||||
// If we are in Preview Model Fallback Mode, we want to fail fast (1 attempt)
|
||||
// when probing the Preview Model.
|
||||
if (
|
||||
this.config.isPreviewModelFallbackMode() &&
|
||||
model === PREVIEW_GEMINI_MODEL
|
||||
) {
|
||||
if (this.config.isPreviewModelFallbackMode() && isPreviewModel(model)) {
|
||||
maxAttempts = 1;
|
||||
}
|
||||
|
||||
@@ -388,7 +384,7 @@ export class GeminiChat {
|
||||
// Preview Model successfully used, disable fallback mode.
|
||||
// We only do this if we didn't bypass Preview Model (i.e. we actually used it).
|
||||
if (
|
||||
model === PREVIEW_GEMINI_MODEL &&
|
||||
isPreviewModel(model) &&
|
||||
!this.config.isPreviewModelBypassMode()
|
||||
) {
|
||||
this.config.setPreviewModelFallbackMode(false);
|
||||
@@ -435,11 +431,24 @@ export class GeminiChat {
|
||||
this.config,
|
||||
() => lastModelToUse,
|
||||
);
|
||||
const apiCall = async () => {
|
||||
let modelToUse: string;
|
||||
// Track initial active model to detect fallback changes
|
||||
const initialActiveModel = this.config.getActiveModel();
|
||||
|
||||
const apiCall = async () => {
|
||||
// Default to the last used model (which respects arguments/availability selection)
|
||||
let modelToUse = resolveModel(
|
||||
lastModelToUse,
|
||||
this.config.getPreviewFeatures(),
|
||||
);
|
||||
|
||||
// If the active model has changed (e.g. due to a fallback updating the config),
|
||||
// we switch to the new active model.
|
||||
if (this.config.getActiveModel() !== initialActiveModel) {
|
||||
modelToUse = resolveModel(
|
||||
this.config.getActiveModel(),
|
||||
this.config.getPreviewFeatures(),
|
||||
);
|
||||
|
||||
if (this.config.isModelAvailabilityServiceEnabled()) {
|
||||
modelToUse = this.config.getActiveModel();
|
||||
if (modelToUse !== lastModelToUse) {
|
||||
const { generateContentConfig: newConfig } =
|
||||
this.config.modelConfigService.getResolvedConfig({
|
||||
@@ -453,24 +462,6 @@ export class GeminiChat {
|
||||
currentGenerateContentConfig.abortSignal = abortSignal;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
modelToUse = getEffectiveModel(
|
||||
this.config.isInFallbackMode(),
|
||||
model,
|
||||
this.config.getPreviewFeatures(),
|
||||
);
|
||||
|
||||
// Preview Model Bypass Logic:
|
||||
// If we are in "Preview Model Bypass Mode" (transient failure), we force downgrade to 2.5 Pro
|
||||
// IF the effective model is currently Preview Model.
|
||||
// Note: In availability mode, this should ideally be handled by policy, but preserving
|
||||
// bypass logic for now as it handles specific transient behavior.
|
||||
if (
|
||||
this.config.isPreviewModelBypassMode() &&
|
||||
modelToUse === PREVIEW_GEMINI_MODEL
|
||||
) {
|
||||
modelToUse = DEFAULT_GEMINI_MODEL;
|
||||
}
|
||||
}
|
||||
|
||||
lastModelToUse = modelToUse;
|
||||
@@ -498,10 +489,9 @@ export class GeminiChat {
|
||||
};
|
||||
delete config.thinkingConfig?.thinkingLevel;
|
||||
}
|
||||
let contentsToUse =
|
||||
modelToUse === PREVIEW_GEMINI_MODEL
|
||||
? contentsForPreviewModel
|
||||
: requestContents;
|
||||
let contentsToUse = isPreviewModel(modelToUse)
|
||||
? contentsForPreviewModel
|
||||
: requestContents;
|
||||
|
||||
// Fire BeforeModel and BeforeToolSelection hooks if enabled
|
||||
const hooksEnabled = this.config.getEnableHooks();
|
||||
@@ -589,8 +579,7 @@ export class GeminiChat {
|
||||
signal: generateContentConfig.abortSignal,
|
||||
maxAttempts:
|
||||
availabilityMaxAttempts ??
|
||||
(this.config.isPreviewModelFallbackMode() &&
|
||||
model === PREVIEW_GEMINI_MODEL
|
||||
(this.config.isPreviewModelFallbackMode() && isPreviewModel(model)
|
||||
? 1
|
||||
: undefined),
|
||||
getAvailabilityContext,
|
||||
|
||||
@@ -78,6 +78,8 @@ describe('GeminiChat Network Retries', () => {
|
||||
model: 'test-model',
|
||||
}),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
setActiveModel: vi.fn(),
|
||||
isInFallbackMode: vi.fn().mockReturnValue(false),
|
||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
|
||||
|
||||
@@ -15,9 +15,10 @@ import { CodebaseInvestigatorAgent } from '../agents/codebase-investigator.js';
|
||||
import { GEMINI_DIR } from '../utils/paths.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
getEffectiveModel,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
|
||||
// Mock tool names if they are dynamically generated or complex
|
||||
@@ -43,10 +44,9 @@ vi.mock('../utils/gitUtils', () => ({
|
||||
}));
|
||||
vi.mock('node:fs');
|
||||
vi.mock('../config/models.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config/models.js')>();
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
getEffectiveModel: vi.fn(),
|
||||
...(actual as object),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -66,24 +66,32 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
},
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
|
||||
getModel: vi.fn().mockReturnValue('auto'),
|
||||
getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO),
|
||||
getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
isInFallbackMode: vi.fn().mockReturnValue(false),
|
||||
getAgentRegistry: vi.fn().mockReturnValue({
|
||||
getDirectoryContext: vi.fn().mockReturnValue('Mock Agent Directory'),
|
||||
}),
|
||||
} as unknown as Config;
|
||||
vi.mocked(getEffectiveModel).mockReturnValue(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should use chatty system prompt for preview model', () => {
|
||||
vi.mocked(getEffectiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
||||
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
||||
const prompt = getCoreSystemPrompt(mockConfig);
|
||||
expect(prompt).toContain('You are an interactive CLI agent'); // Check for core content
|
||||
expect(prompt).not.toContain('No Chitchat:');
|
||||
expect(prompt).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should use chatty system prompt for preview flash model', () => {
|
||||
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
const prompt = getCoreSystemPrompt(mockConfig);
|
||||
expect(prompt).toContain('Do not call tools in silence');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty string', ''],
|
||||
['whitespace only', ' \n \t '],
|
||||
@@ -163,6 +171,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
|
||||
getModel: vi.fn().mockReturnValue('auto'),
|
||||
getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
isInFallbackMode: vi.fn().mockReturnValue(false),
|
||||
getAgentRegistry: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { Config } from '../config/config.js';
|
||||
import { GEMINI_DIR } from '../utils/paths.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { WriteTodosTool } from '../tools/write-todos.js';
|
||||
import { getEffectiveModel, PREVIEW_GEMINI_MODEL } from '../config/models.js';
|
||||
import { resolveModel, isPreviewModel } from '../config/models.js';
|
||||
|
||||
export function resolvePathFromEnv(envVar?: string): {
|
||||
isSwitch: boolean;
|
||||
@@ -106,13 +106,12 @@ export function getCoreSystemPrompt(
|
||||
}
|
||||
|
||||
// TODO(joshualitt): Replace with system instructions on model configs.
|
||||
const desiredModel = getEffectiveModel(
|
||||
config.isInFallbackMode(),
|
||||
config.getModel(),
|
||||
const desiredModel = resolveModel(
|
||||
config.getActiveModel(),
|
||||
config.getPreviewFeatures(),
|
||||
);
|
||||
|
||||
const isGemini3 = desiredModel === PREVIEW_GEMINI_MODEL;
|
||||
const isGemini3 = isPreviewModel(desiredModel);
|
||||
|
||||
const mandatesVariant = isGemini3
|
||||
? `
|
||||
@@ -161,7 +160,7 @@ ${config.getAgentRegistry().getDirectoryContext()}`,
|
||||
|
||||
## Software Engineering Tasks
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use '${GREP_TOOL_NAME}' and '${GLOB_TOOL_NAME}' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use '${GREP_TOOL_NAME}' and '${GLOB_TOOL_NAME}' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions.
|
||||
Use '${READ_FILE_TOOL_NAME}' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to '${READ_FILE_TOOL_NAME}'.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution.`,
|
||||
|
||||
@@ -188,8 +187,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring,
|
||||
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
|
||||
1. **Understand:** Think about the user's request and the relevant codebase context. Use '${GREP_TOOL_NAME}' and '${GLOB_TOOL_NAME}' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use '${READ_FILE_TOOL_NAME}' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to '${READ_FILE_TOOL_NAME}'.
|
||||
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`${WRITE_TODOS_TOOL_NAME}\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution.`,
|
||||
primaryWorkflows_suffix: `3. **Implement:** Use the available tools (e.g., '${EDIT_TOOL_NAME}', '${WRITE_FILE_TOOL_NAME}' '${SHELL_TOOL_NAME}' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core
|
||||
Mandates').
|
||||
primaryWorkflows_suffix: `3. **Implement:** Use the available tools (e.g., '${EDIT_TOOL_NAME}', '${WRITE_FILE_TOOL_NAME}' '${SHELL_TOOL_NAME}' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
|
||||
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards.${interactiveMode ? " If unsure about these commands, you can ask the user if they'd like you to run them and if so how to." : ''}
|
||||
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
|
||||
@@ -416,7 +414,7 @@ The structure MUST be as follows:
|
||||
- Build Command: \`npm run build\`
|
||||
- Testing: Tests are run with \`npm test\`. Test files must end in \`.test.ts\`.
|
||||
- API Endpoint: The primary API endpoint is \`https://api.example.com/v2\`.
|
||||
|
||||
|
||||
-->
|
||||
</key_knowledge>
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ import { AuthType } from '../core/contentGenerator.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
} from '../config/models.js';
|
||||
import { logFlashFallback } from '../telemetry/index.js';
|
||||
import type { FallbackModelHandler } from './types.js';
|
||||
import { ModelNotFoundError } from '../utils/httpErrors.js';
|
||||
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
@@ -64,7 +65,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
({
|
||||
isInFallbackMode: vi.fn(() => false),
|
||||
setFallbackMode: vi.fn(),
|
||||
isModelAvailabilityServiceEnabled: vi.fn(() => false),
|
||||
isModelAvailabilityServiceEnabled: vi.fn(() => true),
|
||||
isPreviewModelFallbackMode: vi.fn(() => false),
|
||||
setPreviewModelFallbackMode: vi.fn(),
|
||||
isPreviewModelBypassMode: vi.fn(() => false),
|
||||
@@ -78,6 +79,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
skipped: [],
|
||||
}),
|
||||
),
|
||||
getActiveModel: vi.fn(() => MOCK_PRO_MODEL),
|
||||
getModel: vi.fn(() => MOCK_PRO_MODEL),
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
getUserTier: vi.fn(() => undefined),
|
||||
@@ -113,430 +115,6 @@ describe('handleFallback', () => {
|
||||
fallbackEventSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return null immediately if authType is not OAuth', async () => {
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_API_KEY,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mockHandler).not.toHaveBeenCalled();
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still consult the handler if the failed model is the fallback model', async () => {
|
||||
mockHandler.mockResolvedValue('stop');
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
FALLBACK_MODEL, // Failed model is Flash
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null if no fallbackHandler is injected in config', async () => {
|
||||
const configWithoutHandler = createMockConfig({
|
||||
fallbackModelHandler: undefined,
|
||||
});
|
||||
const result = await handleFallback(
|
||||
configWithoutHandler,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe('when handler returns "retry_always"', () => {
|
||||
it('should activate fallback mode, log telemetry, and return true', async () => {
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockConfig.setFallbackMode).toHaveBeenCalledWith(true);
|
||||
expect(logFlashFallback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when handler returns "stop"', () => {
|
||||
it('should activate fallback mode, log telemetry, and return false', async () => {
|
||||
mockHandler.mockResolvedValue('stop');
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockConfig.setFallbackMode).toHaveBeenCalledWith(true);
|
||||
expect(logFlashFallback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false without toggling fallback when handler returns "retry_later"', async () => {
|
||||
mockHandler.mockResolvedValue('retry_later');
|
||||
|
||||
const result = await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
expect(logFlashFallback).not.toHaveBeenCalled();
|
||||
expect(fallbackEventSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should launch upgrade flow and avoid fallback mode when handler returns "upgrade"', async () => {
|
||||
mockHandler.mockResolvedValue('upgrade');
|
||||
vi.mocked(openBrowserSecurely).mockResolvedValue(undefined);
|
||||
|
||||
const result = await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(openBrowserSecurely).toHaveBeenCalledWith(
|
||||
'https://goo.gle/set-up-gemini-code-assist',
|
||||
);
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
expect(logFlashFallback).not.toHaveBeenCalled();
|
||||
expect(fallbackEventSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log a warning and continue when upgrade flow fails to open a browser', async () => {
|
||||
mockHandler.mockResolvedValue('upgrade');
|
||||
const debugWarnSpy = vi.spyOn(debugLogger, 'warn');
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
vi.mocked(openBrowserSecurely).mockRejectedValue(new Error('blocked'));
|
||||
|
||||
const result = await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(debugWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to open browser automatically:',
|
||||
'blocked',
|
||||
);
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
expect(fallbackEventSpy).not.toHaveBeenCalled();
|
||||
debugWarnSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('when handler returns an unexpected value', () => {
|
||||
it('should log an error and return null', async () => {
|
||||
mockHandler.mockResolvedValue(null);
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Fallback UI handler failed:',
|
||||
new Error(
|
||||
'Unexpected fallback intent received from fallbackModelHandler: "null"',
|
||||
),
|
||||
);
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass the correct context (failedModel, fallbackModel, error) to the handler', async () => {
|
||||
const mockError = new Error('Quota Exceeded');
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH, mockError);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledWith(
|
||||
MOCK_PRO_MODEL,
|
||||
FALLBACK_MODEL,
|
||||
mockError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call setFallbackMode or log telemetry if already in fallback mode', async () => {
|
||||
// Setup config where fallback mode is already active
|
||||
const activeFallbackConfig = createMockConfig({
|
||||
fallbackModelHandler: mockHandler,
|
||||
isInFallbackMode: vi.fn(() => true), // Already active
|
||||
setFallbackMode: vi.fn(),
|
||||
});
|
||||
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
const result = await handleFallback(
|
||||
activeFallbackConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
// Should still return true to allow the retry (which will use the active fallback mode)
|
||||
expect(result).toBe(true);
|
||||
// Should still consult the handler
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
// But should not mutate state or log telemetry again
|
||||
expect(activeFallbackConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
expect(logFlashFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should catch errors from the handler, log an error, and return null', async () => {
|
||||
const handlerError = new Error('UI interaction failed');
|
||||
mockHandler.mockRejectedValue(handlerError);
|
||||
|
||||
const result = await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Fallback UI handler failed:',
|
||||
handlerError,
|
||||
);
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Preview Model Fallback Logic', () => {
|
||||
const previewModel = PREVIEW_GEMINI_MODEL;
|
||||
|
||||
it('should only set Preview Model bypass mode on retryable quota failure', async () => {
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const retryableQuotaError = new RetryableQuotaError(
|
||||
'Capacity error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
await handleFallback(
|
||||
mockConfig,
|
||||
previewModel,
|
||||
AUTH_OAUTH,
|
||||
retryableQuotaError,
|
||||
);
|
||||
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should not set Preview Model bypass mode on non-retryable quota failure', async () => {
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const terminalQuotaError = new TerminalQuotaError(
|
||||
'quota error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
await handleFallback(
|
||||
mockConfig,
|
||||
previewModel,
|
||||
AUTH_OAUTH,
|
||||
terminalQuotaError,
|
||||
);
|
||||
|
||||
expect(mockConfig.setPreviewModelBypassMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should silently retry if Preview Model fallback mode is already active and error is retryable error', async () => {
|
||||
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(true);
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const retryableQuotaError = new RetryableQuotaError(
|
||||
'Capacity error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
previewModel,
|
||||
AUTH_OAUTH,
|
||||
retryableQuotaError,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should activate Preview Model fallback mode when handler returns "retry_always" and is RetryableQuotaError', async () => {
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const retryableQuotaError = new RetryableQuotaError(
|
||||
'Capacity error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
previewModel,
|
||||
AUTH_OAUTH,
|
||||
retryableQuotaError,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
|
||||
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should activate regular fallback when handler returns "retry_always" and is TerminalQuotaError', async () => {
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
const mockGoogleApiError = {
|
||||
code: 503,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const terminalError = new TerminalQuotaError(
|
||||
'Quota error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
previewModel,
|
||||
AUTH_OAUTH,
|
||||
terminalError,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockConfig.setPreviewModelFallbackMode).not.toBeCalled();
|
||||
expect(mockConfig.setFallbackMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should NOT set fallback mode if user chooses "retry_once"', async () => {
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const terminalQuotaError = new TerminalQuotaError(
|
||||
'quota error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
mockHandler.mockResolvedValue('retry_once');
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
terminalQuotaError,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockConfig.setPreviewModelBypassMode).not.toHaveBeenCalled();
|
||||
expect(mockConfig.setPreviewModelFallbackMode).not.toHaveBeenCalled();
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass DEFAULT_GEMINI_MODEL as fallback when Preview Model fails with Retryable Error', async () => {
|
||||
const mockFallbackHandler = vi.fn().mockResolvedValue('stop');
|
||||
vi.mocked(mockConfig.fallbackModelHandler!).mockImplementation(
|
||||
mockFallbackHandler,
|
||||
);
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const retryableQuotaError = new RetryableQuotaError(
|
||||
'Capacity error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
|
||||
await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
retryableQuotaError,
|
||||
);
|
||||
|
||||
expect(mockConfig.fallbackModelHandler).toHaveBeenCalledWith(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
retryableQuotaError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass DEFAULT_GEMINI_MODEL as fallback when Preview Model fails with other error', async () => {
|
||||
await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
|
||||
expect(mockConfig.fallbackModelHandler).toHaveBeenCalledWith(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass DEFAULT_GEMINI_FLASH_MODEL as fallback when Preview Model fails with other error', async () => {
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const terminalQuotaError = new TerminalQuotaError(
|
||||
'quota error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
terminalQuotaError,
|
||||
);
|
||||
|
||||
expect(mockConfig.fallbackModelHandler).toHaveBeenCalledWith(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
terminalQuotaError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if ModelNotFoundError occurs for a non-preview model', async () => {
|
||||
const modelNotFoundError = new ModelNotFoundError('Not found');
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
DEFAULT_GEMINI_MODEL, // Not preview model
|
||||
AUTH_OAUTH,
|
||||
modelNotFoundError,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mockHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should consult handler if ModelNotFoundError occurs for preview model', async () => {
|
||||
const modelNotFoundError = new ModelNotFoundError('Not found');
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AUTH_OAUTH,
|
||||
modelNotFoundError,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('policy-driven flow', () => {
|
||||
let policyConfig: Config;
|
||||
let availability: ModelAvailabilityService;
|
||||
@@ -550,31 +128,47 @@ describe('handleFallback', () => {
|
||||
});
|
||||
policyHandler = vi.fn().mockResolvedValue('retry_once');
|
||||
policyConfig = createMockConfig();
|
||||
vi.spyOn(
|
||||
policyConfig,
|
||||
'isModelAvailabilityServiceEnabled',
|
||||
).mockReturnValue(true);
|
||||
vi.spyOn(policyConfig, 'getModelAvailabilityService').mockReturnValue(
|
||||
|
||||
// Ensure we test the availability path
|
||||
vi.mocked(policyConfig.isModelAvailabilityServiceEnabled).mockReturnValue(
|
||||
true,
|
||||
);
|
||||
vi.mocked(policyConfig.getModelAvailabilityService).mockReturnValue(
|
||||
availability,
|
||||
);
|
||||
vi.spyOn(policyConfig, 'getFallbackModelHandler').mockReturnValue(
|
||||
vi.mocked(policyConfig.getFallbackModelHandler).mockReturnValue(
|
||||
policyHandler,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null immediately if authType is not OAuth', async () => {
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_API_KEY,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(policyHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses availability selection with correct candidates when enabled', async () => {
|
||||
vi.spyOn(policyConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.spyOn(policyConfig, 'getModel').mockReturnValue(DEFAULT_GEMINI_MODEL);
|
||||
// Direct mock manipulation since it's already a vi.fn()
|
||||
vi.mocked(policyConfig.getPreviewFeatures).mockReturnValue(true);
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
await handleFallback(policyConfig, DEFAULT_GEMINI_MODEL, AUTH_OAUTH);
|
||||
|
||||
expect(availability.selectFirstAvailable).toHaveBeenCalledWith([
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to last resort when availability returns null', async () => {
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
availability.selectFirstAvailable = vi
|
||||
.fn()
|
||||
.mockReturnValue({ selectedModel: null, skipped: [] });
|
||||
@@ -634,9 +228,12 @@ describe('handleFallback', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('wraps around to upgrade candidates if the current model was selected mid-chain (e.g. by router)', async () => {
|
||||
it('does not wrap around to upgrade candidates if the current model was selected at the end (e.g. by router)', async () => {
|
||||
// Last-resort failure (Flash) in [Preview, Pro, Flash] checks Preview then Pro (all upstream).
|
||||
vi.spyOn(policyConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.mocked(policyConfig.getPreviewFeatures).mockReturnValue(true);
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
availability.selectFirstAvailable = vi.fn().mockReturnValue({
|
||||
selectedModel: MOCK_PRO_MODEL,
|
||||
@@ -650,43 +247,27 @@ describe('handleFallback', () => {
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(availability.selectFirstAvailable).toHaveBeenCalledWith([
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
MOCK_PRO_MODEL,
|
||||
]);
|
||||
expect(availability.selectFirstAvailable).not.toHaveBeenCalled();
|
||||
expect(policyHandler).toHaveBeenCalledWith(
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
MOCK_PRO_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('logs and returns null when handler resolves to null', async () => {
|
||||
policyHandler.mockResolvedValue(null);
|
||||
const debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error');
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Fallback handler failed:',
|
||||
new Error(
|
||||
'Unexpected fallback intent received from fallbackModelHandler: "null"',
|
||||
),
|
||||
);
|
||||
debugLoggerErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('successfully follows expected availability response for Preview Chain', async () => {
|
||||
availability.selectFirstAvailable = vi
|
||||
.fn()
|
||||
.mockReturnValue({ selectedModel: DEFAULT_GEMINI_MODEL, skipped: [] });
|
||||
availability.selectFirstAvailable = vi.fn().mockReturnValue({
|
||||
selectedModel: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
skipped: [],
|
||||
});
|
||||
policyHandler.mockResolvedValue('retry_once');
|
||||
vi.spyOn(policyConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.spyOn(policyConfig, 'getModel').mockReturnValue(PREVIEW_GEMINI_MODEL);
|
||||
vi.mocked(policyConfig.getPreviewFeatures).mockReturnValue(true);
|
||||
vi.mocked(policyConfig.getActiveModel).mockReturnValue(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
@@ -696,21 +277,112 @@ describe('handleFallback', () => {
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(availability.selectFirstAvailable).toHaveBeenCalledWith([
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
]);
|
||||
expect(policyHandler).toHaveBeenCalledWith(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
undefined,
|
||||
});
|
||||
|
||||
it('should launch upgrade flow and avoid fallback mode when handler returns "upgrade"', async () => {
|
||||
policyHandler.mockResolvedValue('upgrade');
|
||||
vi.mocked(openBrowserSecurely).mockResolvedValue(undefined);
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(openBrowserSecurely).toHaveBeenCalledWith(
|
||||
'https://goo.gle/set-up-gemini-code-assist',
|
||||
);
|
||||
expect(policyConfig.setActiveModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should catch errors from the handler, log an error, and return null', async () => {
|
||||
const handlerError = new Error('UI interaction failed');
|
||||
policyHandler.mockRejectedValue(handlerError);
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(debugLogger.error).toHaveBeenCalledWith(
|
||||
'Fallback handler failed:',
|
||||
handlerError,
|
||||
);
|
||||
});
|
||||
|
||||
it('short-circuits when the failed model is the last-resort policy AND candidates are unavailable', async () => {
|
||||
it('should pass TerminalQuotaError (429) correctly to the handler', async () => {
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const terminalError = new TerminalQuotaError(
|
||||
'Quota error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
policyHandler.mockResolvedValue('retry_always');
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
terminalError,
|
||||
);
|
||||
|
||||
expect(policyHandler).toHaveBeenCalledWith(
|
||||
MOCK_PRO_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
terminalError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass RetryableQuotaError correctly to the handler', async () => {
|
||||
const mockGoogleApiError = {
|
||||
code: 503,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const retryableError = new RetryableQuotaError(
|
||||
'Service unavailable',
|
||||
mockGoogleApiError,
|
||||
1000,
|
||||
);
|
||||
policyHandler.mockResolvedValue('retry_once');
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
retryableError,
|
||||
);
|
||||
|
||||
expect(policyHandler).toHaveBeenCalledWith(
|
||||
MOCK_PRO_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
retryableError,
|
||||
);
|
||||
});
|
||||
|
||||
it('Call the handler with fallback model same as the failed model when the failed model is the last-resort policy', async () => {
|
||||
// Ensure short-circuit when wrapping to an unavailable upstream model.
|
||||
availability.selectFirstAvailable = vi
|
||||
.fn()
|
||||
.mockReturnValue({ selectedModel: null, skipped: [] });
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
@@ -718,14 +390,21 @@ describe('handleFallback', () => {
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
// Service called to check upstream; no UI handler since nothing selected.
|
||||
expect(policyConfig.getModelAvailabilityService).toHaveBeenCalled();
|
||||
expect(policyConfig.getFallbackModelHandler).not.toHaveBeenCalled();
|
||||
policyHandler.mockResolvedValue('retry_once');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(policyHandler).toHaveBeenCalledWith(
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls setActiveModel and logs telemetry when handler returns "retry_always"', async () => {
|
||||
policyHandler.mockResolvedValue('retry_always');
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
@@ -739,7 +418,7 @@ describe('handleFallback', () => {
|
||||
// TODO: add logging expect statement
|
||||
});
|
||||
|
||||
it('calls setActiveModel when handler returns "stop"', async () => {
|
||||
it('does NOT call setActiveModel when handler returns "stop"', async () => {
|
||||
policyHandler.mockResolvedValue('stop');
|
||||
|
||||
const result = await handleFallback(
|
||||
@@ -749,8 +428,21 @@ describe('handleFallback', () => {
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(policyConfig.setActiveModel).toHaveBeenCalledWith(FALLBACK_MODEL);
|
||||
expect(policyConfig.setActiveModel).not.toHaveBeenCalled();
|
||||
// TODO: add logging expect statement
|
||||
});
|
||||
|
||||
it('does NOT call setActiveModel when handler returns "retry_once"', async () => {
|
||||
policyHandler.mockResolvedValue('retry_once');
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(policyConfig.setActiveModel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,24 +6,16 @@
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { logFlashFallback, FlashFallbackEvent } from '../telemetry/index.js';
|
||||
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { ModelNotFoundError } from '../utils/httpErrors.js';
|
||||
import { TerminalQuotaError } from '../utils/googleQuotaErrors.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import type { FallbackIntent, FallbackRecommendation } from './types.js';
|
||||
import { classifyFailureKind } from '../availability/errorClassification.js';
|
||||
import {
|
||||
buildFallbackPolicyContext,
|
||||
resolvePolicyChain,
|
||||
resolvePolicyAction,
|
||||
applyAvailabilityTransition,
|
||||
} from '../availability/policyHelpers.js';
|
||||
|
||||
const UPGRADE_URL_PAGE = 'https://goo.gle/set-up-gemini-code-assist';
|
||||
@@ -34,75 +26,7 @@ export async function handleFallback(
|
||||
authType?: string,
|
||||
error?: unknown,
|
||||
): Promise<string | boolean | null> {
|
||||
if (config.isModelAvailabilityServiceEnabled()) {
|
||||
return handlePolicyDrivenFallback(config, failedModel, authType, error);
|
||||
}
|
||||
return legacyHandleFallback(config, failedModel, authType, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Old fallback logic relying on hard coded strings
|
||||
*/
|
||||
async function legacyHandleFallback(
|
||||
config: Config,
|
||||
failedModel: string,
|
||||
authType?: string,
|
||||
error?: unknown,
|
||||
): Promise<string | boolean | null> {
|
||||
if (authType !== AuthType.LOGIN_WITH_GOOGLE) return null;
|
||||
|
||||
// Guardrail: If it's a ModelNotFoundError but NOT the preview model, do not handle it.
|
||||
if (
|
||||
error instanceof ModelNotFoundError &&
|
||||
failedModel !== PREVIEW_GEMINI_MODEL
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const shouldActivatePreviewFallback =
|
||||
failedModel === PREVIEW_GEMINI_MODEL &&
|
||||
!(error instanceof TerminalQuotaError);
|
||||
// Preview Model Specific Logic
|
||||
if (shouldActivatePreviewFallback) {
|
||||
// Always set bypass mode for the immediate retry, for non-TerminalQuotaErrors.
|
||||
// This ensures the next attempt uses 2.5 Pro.
|
||||
config.setPreviewModelBypassMode(true);
|
||||
|
||||
// If we are already in Preview Model fallback mode (user previously said "Always"),
|
||||
// we silently retry (which will use 2.5 Pro due to bypass mode).
|
||||
if (config.isPreviewModelFallbackMode()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackModel = shouldActivatePreviewFallback
|
||||
? DEFAULT_GEMINI_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_MODEL;
|
||||
|
||||
// Consult UI Handler for Intent
|
||||
const fallbackModelHandler = config.fallbackModelHandler;
|
||||
if (typeof fallbackModelHandler !== 'function') return null;
|
||||
|
||||
try {
|
||||
// Pass the specific failed model to the UI handler.
|
||||
const intent = await fallbackModelHandler(
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
error,
|
||||
);
|
||||
|
||||
// Process Intent and Update State
|
||||
return await processIntent(
|
||||
config,
|
||||
intent,
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
authType,
|
||||
error,
|
||||
);
|
||||
} catch (handlerError) {
|
||||
console.error('Fallback UI handler failed:', handlerError);
|
||||
return null;
|
||||
}
|
||||
return handlePolicyDrivenFallback(config, failedModel, authType, error);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,50 +49,56 @@ async function handlePolicyDrivenFallback(
|
||||
);
|
||||
|
||||
const failureKind = classifyFailureKind(error);
|
||||
|
||||
if (!candidates.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const availability = config.getModelAvailabilityService();
|
||||
const selection = availability.selectFirstAvailable(
|
||||
candidates.map((policy) => policy.model),
|
||||
);
|
||||
|
||||
const lastResortPolicy = candidates.find((policy) => policy.isLastResort);
|
||||
const fallbackModel = selection.selectedModel ?? lastResortPolicy?.model;
|
||||
const selectedPolicy = candidates.find(
|
||||
(policy) => policy.model === fallbackModel,
|
||||
);
|
||||
|
||||
if (!fallbackModel || fallbackModel === failedModel || !selectedPolicy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// failureKind is already declared and calculated above
|
||||
const action = resolvePolicyAction(failureKind, selectedPolicy);
|
||||
|
||||
if (action === 'silent') {
|
||||
return processIntent(
|
||||
config,
|
||||
'retry_always',
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
authType,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// This will be used in the future when FallbackRecommendation is passed through UI
|
||||
const recommendation: FallbackRecommendation = {
|
||||
...selection,
|
||||
selectedModel: fallbackModel,
|
||||
action,
|
||||
failureKind,
|
||||
failedPolicy,
|
||||
selectedPolicy,
|
||||
const getAvailabilityContext = () => {
|
||||
if (!failedPolicy) return undefined;
|
||||
return { service: availability, policy: failedPolicy };
|
||||
};
|
||||
void recommendation;
|
||||
|
||||
let fallbackModel: string;
|
||||
if (!candidates.length) {
|
||||
fallbackModel = failedModel;
|
||||
} else {
|
||||
const selection = availability.selectFirstAvailable(
|
||||
candidates.map((policy) => policy.model),
|
||||
);
|
||||
|
||||
const lastResortPolicy = candidates.find((policy) => policy.isLastResort);
|
||||
const selectedFallbackModel =
|
||||
selection.selectedModel ?? lastResortPolicy?.model;
|
||||
const selectedPolicy = candidates.find(
|
||||
(policy) => policy.model === selectedFallbackModel,
|
||||
);
|
||||
|
||||
if (
|
||||
!selectedFallbackModel ||
|
||||
selectedFallbackModel === failedModel ||
|
||||
!selectedPolicy
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
fallbackModel = selectedFallbackModel;
|
||||
|
||||
// failureKind is already declared and calculated above
|
||||
const action = resolvePolicyAction(failureKind, selectedPolicy);
|
||||
|
||||
if (action === 'silent') {
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
return processIntent(config, 'retry_always', fallbackModel);
|
||||
}
|
||||
|
||||
// This will be used in the future when FallbackRecommendation is passed through UI
|
||||
const recommendation: FallbackRecommendation = {
|
||||
...selection,
|
||||
selectedModel: fallbackModel,
|
||||
action,
|
||||
failureKind,
|
||||
failedPolicy,
|
||||
selectedPolicy,
|
||||
};
|
||||
void recommendation;
|
||||
}
|
||||
|
||||
const handler = config.getFallbackModelHandler();
|
||||
if (typeof handler !== 'function') {
|
||||
@@ -177,14 +107,16 @@ async function handlePolicyDrivenFallback(
|
||||
|
||||
try {
|
||||
const intent = await handler(failedModel, fallbackModel, error);
|
||||
return await processIntent(
|
||||
config,
|
||||
intent,
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
authType,
|
||||
error, // Pass the error so processIntent can handle preview-specific logic
|
||||
);
|
||||
|
||||
// If the user chose to switch/retry, we apply the availability transition
|
||||
// to the failed model (e.g. marking it terminal if it had a quota error).
|
||||
// We DO NOT apply it if the user chose 'stop' or 'retry_later', allowing
|
||||
// them to try again later with the same model state.
|
||||
if (intent === 'retry_always' || intent === 'retry_once') {
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
}
|
||||
|
||||
return await processIntent(config, intent, fallbackModel);
|
||||
} catch (handlerError) {
|
||||
debugLogger.error('Fallback handler failed:', handlerError);
|
||||
return null;
|
||||
@@ -205,47 +137,23 @@ async function handleUpgrade() {
|
||||
async function processIntent(
|
||||
config: Config,
|
||||
intent: FallbackIntent | null,
|
||||
failedModel: string,
|
||||
fallbackModel: string,
|
||||
authType?: string,
|
||||
error?: unknown,
|
||||
): Promise<boolean> {
|
||||
const isAvailabilityEnabled = config.isModelAvailabilityServiceEnabled();
|
||||
|
||||
switch (intent) {
|
||||
case 'retry_always':
|
||||
if (isAvailabilityEnabled) {
|
||||
// TODO(telemetry): Implement generic fallback event logging. Existing
|
||||
// logFlashFallback is specific to a single Model.
|
||||
config.setActiveModel(fallbackModel);
|
||||
} else {
|
||||
// If the error is non-retryable, e.g. TerminalQuota Error, trigger a regular fallback to flash.
|
||||
// For all other errors, activate previewModel fallback.
|
||||
if (
|
||||
failedModel === PREVIEW_GEMINI_MODEL &&
|
||||
!(error instanceof TerminalQuotaError)
|
||||
) {
|
||||
activatePreviewModelFallbackMode(config);
|
||||
} else {
|
||||
activateFallbackMode(config, authType);
|
||||
}
|
||||
}
|
||||
// TODO(telemetry): Implement generic fallback event logging. Existing
|
||||
// logFlashFallback is specific to a single Model.
|
||||
config.setActiveModel(fallbackModel);
|
||||
return true;
|
||||
|
||||
case 'retry_once':
|
||||
if (isAvailabilityEnabled) {
|
||||
config.setActiveModel(fallbackModel);
|
||||
}
|
||||
// For distinct retry (retry_once), we do NOT set the active model permanently.
|
||||
// The FallbackStrategy will handle routing to the available model for this turn
|
||||
// based on the availability service state (which is updated before this).
|
||||
return true;
|
||||
|
||||
case 'stop':
|
||||
if (isAvailabilityEnabled) {
|
||||
// TODO(telemetry): Implement generic fallback event logging. Existing
|
||||
// logFlashFallback is specific to a single Model.
|
||||
config.setActiveModel(fallbackModel);
|
||||
} else {
|
||||
activateFallbackMode(config, authType);
|
||||
}
|
||||
// Do not switch model on stop. User wants to stay on current model (and stop).
|
||||
return false;
|
||||
|
||||
case 'retry_later':
|
||||
@@ -261,20 +169,3 @@ async function processIntent(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function activateFallbackMode(config: Config, authType: string | undefined) {
|
||||
if (!config.isInFallbackMode()) {
|
||||
config.setFallbackMode(true);
|
||||
coreEvents.emitFallbackModeChanged(true);
|
||||
if (authType) {
|
||||
logFlashFallback(config, new FlashFallbackEvent(authType));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function activatePreviewModelFallbackMode(config: Config) {
|
||||
if (!config.isPreviewModelFallbackMode()) {
|
||||
config.setPreviewModelFallbackMode(true);
|
||||
// We might want a specific event for Preview Model fallback, but for now we just set the mode.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export async function createPolicyEngineConfig(
|
||||
rules: tomlRules,
|
||||
checkers: tomlCheckers,
|
||||
errors,
|
||||
} = await loadPoliciesFromToml(approvalMode, policyDirs, (dir) =>
|
||||
} = await loadPoliciesFromToml(policyDirs, (dir) =>
|
||||
getPolicyTier(dir, defaultPoliciesDir),
|
||||
);
|
||||
|
||||
@@ -236,6 +236,7 @@ export async function createPolicyEngineConfig(
|
||||
rules,
|
||||
checkers,
|
||||
defaultDecision: PolicyDecision.ASK_USER,
|
||||
approvalMode,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { PolicyEngine } from './policy-engine.js';
|
||||
import { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { MessageBusType } from '../confirmation-bus/types.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { ApprovalMode } from './types.js';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('../config/storage.js');
|
||||
@@ -29,7 +30,11 @@ describe('createPolicyUpdater', () => {
|
||||
let messageBus: MessageBus;
|
||||
|
||||
beforeEach(() => {
|
||||
policyEngine = new PolicyEngine({ rules: [], checkers: [] });
|
||||
policyEngine = new PolicyEngine({
|
||||
rules: [],
|
||||
checkers: [],
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
});
|
||||
messageBus = new MessageBus(policyEngine);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type PolicyEngineConfig,
|
||||
type SafetyCheckerRule,
|
||||
InProcessCheckerType,
|
||||
ApprovalMode,
|
||||
} from './types.js';
|
||||
import type { FunctionCall } from '@google/genai';
|
||||
import { SafetyCheckDecision } from '../safety/protocol.js';
|
||||
@@ -25,7 +26,10 @@ describe('PolicyEngine', () => {
|
||||
mockCheckerRunner = {
|
||||
runChecker: vi.fn(),
|
||||
} as unknown as CheckerRunner;
|
||||
engine = new PolicyEngine({}, mockCheckerRunner);
|
||||
engine = new PolicyEngine(
|
||||
{ approvalMode: ApprovalMode.DEFAULT },
|
||||
mockCheckerRunner,
|
||||
);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
@@ -163,6 +167,41 @@ describe('PolicyEngine', () => {
|
||||
(await engine.check({ name: 'unknown-tool' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should dynamically switch between modes and respect rule modes', async () => {
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName: 'edit',
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
toolName: 'edit',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 20,
|
||||
modes: [ApprovalMode.AUTO_EDIT],
|
||||
},
|
||||
];
|
||||
|
||||
engine = new PolicyEngine({ rules });
|
||||
|
||||
// Default mode: priority 20 rule doesn't match, falls back to priority 10
|
||||
expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe(
|
||||
PolicyDecision.ASK_USER,
|
||||
);
|
||||
|
||||
// Switch to autoEdit mode
|
||||
engine.setApprovalMode(ApprovalMode.AUTO_EDIT);
|
||||
expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
|
||||
// Switch back to default
|
||||
engine.setApprovalMode(ApprovalMode.DEFAULT);
|
||||
expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe(
|
||||
PolicyDecision.ASK_USER,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRule', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type HookCheckerRule,
|
||||
type HookExecutionContext,
|
||||
getHookSource,
|
||||
ApprovalMode,
|
||||
} from './types.js';
|
||||
import { stableStringify } from './stable-stringify.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
@@ -30,7 +31,15 @@ function ruleMatches(
|
||||
toolCall: FunctionCall,
|
||||
stringifiedArgs: string | undefined,
|
||||
serverName: string | undefined,
|
||||
currentApprovalMode: ApprovalMode,
|
||||
): boolean {
|
||||
// Check if rule applies to current approval mode
|
||||
if (rule.modes && rule.modes.length > 0) {
|
||||
if (!rule.modes.includes(currentApprovalMode)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check tool name if specified
|
||||
if (rule.toolName) {
|
||||
// Support wildcard patterns: "serverName__*" matches "serverName__anyTool"
|
||||
@@ -98,6 +107,7 @@ export class PolicyEngine {
|
||||
private readonly nonInteractive: boolean;
|
||||
private readonly checkerRunner?: CheckerRunner;
|
||||
private readonly allowHooks: boolean;
|
||||
private approvalMode: ApprovalMode;
|
||||
|
||||
constructor(config: PolicyEngineConfig = {}, checkerRunner?: CheckerRunner) {
|
||||
this.rules = (config.rules ?? []).sort(
|
||||
@@ -113,6 +123,21 @@ export class PolicyEngine {
|
||||
this.nonInteractive = config.nonInteractive ?? false;
|
||||
this.checkerRunner = checkerRunner;
|
||||
this.allowHooks = config.allowHooks ?? true;
|
||||
this.approvalMode = config.approvalMode ?? ApprovalMode.DEFAULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current approval mode.
|
||||
*/
|
||||
setApprovalMode(mode: ApprovalMode): void {
|
||||
this.approvalMode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current approval mode.
|
||||
*/
|
||||
getApprovalMode(): ApprovalMode {
|
||||
return this.approvalMode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,7 +170,15 @@ export class PolicyEngine {
|
||||
let decision: PolicyDecision | undefined;
|
||||
|
||||
for (const rule of this.rules) {
|
||||
if (ruleMatches(rule, toolCall, stringifiedArgs, serverName)) {
|
||||
if (
|
||||
ruleMatches(
|
||||
rule,
|
||||
toolCall,
|
||||
stringifiedArgs,
|
||||
serverName,
|
||||
this.approvalMode,
|
||||
)
|
||||
) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] MATCHED rule: toolName=${rule.toolName}, decision=${rule.decision}, priority=${rule.priority}, argsPattern=${rule.argsPattern?.source || 'none'}`,
|
||||
);
|
||||
@@ -225,7 +258,15 @@ export class PolicyEngine {
|
||||
// If decision is not DENY, run safety checkers
|
||||
if (decision !== PolicyDecision.DENY && this.checkerRunner) {
|
||||
for (const checkerRule of this.checkers) {
|
||||
if (ruleMatches(checkerRule, toolCall, stringifiedArgs, serverName)) {
|
||||
if (
|
||||
ruleMatches(
|
||||
checkerRule,
|
||||
toolCall,
|
||||
stringifiedArgs,
|
||||
serverName,
|
||||
this.approvalMode,
|
||||
)
|
||||
) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Running safety checker: ${checkerRule.checker.name}`,
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { PolicyEngine } from './policy-engine.js';
|
||||
import { PolicyDecision } from './types.js';
|
||||
import { PolicyDecision, ApprovalMode } from './types.js';
|
||||
import type { FunctionCall } from '@google/genai';
|
||||
|
||||
describe('Shell Safety Policy', () => {
|
||||
@@ -25,6 +25,7 @@ describe('Shell Safety Policy', () => {
|
||||
},
|
||||
],
|
||||
defaultDecision: PolicyDecision.ASK_USER,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { ApprovalMode, PolicyDecision } from './types.js';
|
||||
import { PolicyDecision } from './types.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -36,7 +36,7 @@ describe('policy-toml-loader', () => {
|
||||
): Promise<PolicyLoadResult> {
|
||||
await fs.writeFile(path.join(tempDir, fileName), tomlContent);
|
||||
const getPolicyTier = (_dir: string) => 1;
|
||||
return loadPoliciesFromToml(ApprovalMode.DEFAULT, [tempDir], getPolicyTier);
|
||||
return loadPoliciesFromToml([tempDir], getPolicyTier);
|
||||
}
|
||||
|
||||
describe('loadPoliciesFromToml', () => {
|
||||
@@ -133,7 +133,7 @@ priority = 100
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should filter rules by mode', async () => {
|
||||
it('should NOT filter rules by mode at load time but preserve modes property', async () => {
|
||||
const result = await runLoadPoliciesFromToml(`
|
||||
[[rule]]
|
||||
toolName = "glob"
|
||||
@@ -148,12 +148,39 @@ priority = 100
|
||||
modes = ["yolo"]
|
||||
`);
|
||||
|
||||
// Only the first rule should be included (modes includes "default")
|
||||
expect(result.rules).toHaveLength(1);
|
||||
// Both rules should be included
|
||||
expect(result.rules).toHaveLength(2);
|
||||
expect(result.rules[0].toolName).toBe('glob');
|
||||
expect(result.rules[0].modes).toEqual(['default', 'yolo']);
|
||||
expect(result.rules[1].toolName).toBe('grep');
|
||||
expect(result.rules[1].modes).toEqual(['yolo']);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return error if modes property is used for Tier 2 and Tier 3 policies', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'tier2.toml'),
|
||||
`
|
||||
[[rule]]
|
||||
toolName = "tier2-tool"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["autoEdit"]
|
||||
`,
|
||||
);
|
||||
|
||||
const getPolicyTier = (_dir: string) => 2; // Tier 2
|
||||
const result = await loadPoliciesFromToml([tempDir], getPolicyTier);
|
||||
|
||||
// It still transforms the rule, but it should also report an error
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].toolName).toBe('tier2-tool');
|
||||
expect(result.rules[0].modes).toBeUndefined(); // Should be restricted
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].errorType).toBe('rule_validation');
|
||||
expect(result.errors[0].message).toContain('Restricted property "modes"');
|
||||
});
|
||||
|
||||
it('should handle TOML parse errors', async () => {
|
||||
const result = await runLoadPoliciesFromToml(`
|
||||
[[rule]
|
||||
@@ -267,11 +294,7 @@ priority = -1
|
||||
);
|
||||
|
||||
const getPolicyTier = (_dir: string) => 1;
|
||||
const result = await loadPoliciesFromToml(
|
||||
ApprovalMode.DEFAULT,
|
||||
[tempDir],
|
||||
getPolicyTier,
|
||||
);
|
||||
const result = await loadPoliciesFromToml([tempDir], getPolicyTier);
|
||||
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].toolName).toBe('glob');
|
||||
@@ -439,11 +462,7 @@ priority = 100
|
||||
await fs.writeFile(filePath, 'content');
|
||||
|
||||
const getPolicyTier = (_dir: string) => 1;
|
||||
const result = await loadPoliciesFromToml(
|
||||
ApprovalMode.DEFAULT,
|
||||
[filePath],
|
||||
getPolicyTier,
|
||||
);
|
||||
const result = await loadPoliciesFromToml([filePath], getPolicyTier);
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
const error = result.errors[0];
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import {
|
||||
type PolicyRule,
|
||||
PolicyDecision,
|
||||
type ApprovalMode,
|
||||
ApprovalMode,
|
||||
type SafetyCheckerConfig,
|
||||
type SafetyCheckerRule,
|
||||
InProcessCheckerType,
|
||||
@@ -43,7 +43,7 @@ const PolicyRuleSchema = z.object({
|
||||
message:
|
||||
'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.',
|
||||
}),
|
||||
modes: z.array(z.string()).optional(),
|
||||
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -56,7 +56,7 @@ const SafetyCheckerRuleSchema = z.object({
|
||||
commandPrefix: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
commandRegex: z.string().optional(),
|
||||
priority: z.number().int().default(0),
|
||||
modes: z.array(z.string()).optional(),
|
||||
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
|
||||
checker: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('in-process'),
|
||||
@@ -216,16 +216,13 @@ function transformPriority(priority: number, tier: number): number {
|
||||
* 1. Scans directories for .toml files
|
||||
* 2. Parses and validates each file
|
||||
* 3. Transforms rules (commandPrefix, arrays, mcpName, priorities)
|
||||
* 4. Filters rules by approval mode
|
||||
* 5. Collects detailed error information for any failures
|
||||
* 4. Collects detailed error information for any failures
|
||||
*
|
||||
* @param approvalMode The current approval mode (for filtering rules by mode)
|
||||
* @param policyDirs Array of directory paths to scan for policy files
|
||||
* @param getPolicyTier Function to determine tier (1-3) for a directory
|
||||
* @returns Object containing successfully parsed rules and any errors encountered
|
||||
*/
|
||||
export async function loadPoliciesFromToml(
|
||||
approvalMode: ApprovalMode,
|
||||
policyDirs: string[],
|
||||
getPolicyTier: (dir: string) => number,
|
||||
): Promise<PolicyLoadResult> {
|
||||
@@ -305,6 +302,8 @@ export async function loadPoliciesFromToml(
|
||||
|
||||
// Validate shell command convenience syntax
|
||||
const tomlRules = validationResult.data.rule ?? [];
|
||||
const tomlCheckers = validationResult.data.safety_checker ?? [];
|
||||
|
||||
for (let i = 0; i < tomlRules.length; i++) {
|
||||
const rule = tomlRules[i];
|
||||
const validationError = validateShellCommandSyntax(rule, i);
|
||||
@@ -320,17 +319,40 @@ export async function loadPoliciesFromToml(
|
||||
});
|
||||
// Continue to next rule, don't skip the entire file
|
||||
}
|
||||
|
||||
if (tier > 1 && rule.modes && rule.modes.length > 0) {
|
||||
errors.push({
|
||||
filePath,
|
||||
fileName: file,
|
||||
tier: tierName,
|
||||
ruleIndex: i,
|
||||
errorType: 'rule_validation',
|
||||
message: 'Restricted property "modes"',
|
||||
details: `Rule #${i + 1}: The "modes" property is currently reserved for Tier 1 (system) policies and cannot be used in ${tierName} policies.`,
|
||||
suggestion: 'Remove the "modes" property from this rule.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < tomlCheckers.length; i++) {
|
||||
const checker = tomlCheckers[i];
|
||||
if (tier > 1 && checker.modes && checker.modes.length > 0) {
|
||||
errors.push({
|
||||
filePath,
|
||||
fileName: file,
|
||||
tier: tierName,
|
||||
ruleIndex: i,
|
||||
errorType: 'rule_validation',
|
||||
message: 'Restricted property "modes" in safety checker',
|
||||
details: `Safety Checker #${i + 1}: The "modes" property is currently reserved for Tier 1 (system) policies and cannot be used in ${tierName} policies.`,
|
||||
suggestion:
|
||||
'Remove the "modes" property from this safety checker.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Transform rules
|
||||
const parsedRules: PolicyRule[] = (validationResult.data.rule ?? [])
|
||||
.filter((rule) => {
|
||||
// Filter by mode
|
||||
if (!rule.modes || rule.modes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return rule.modes.includes(approvalMode);
|
||||
})
|
||||
.flatMap((rule) => {
|
||||
// Transform commandPrefix/commandRegex to argsPattern
|
||||
let effectiveArgsPattern = rule.argsPattern;
|
||||
@@ -377,6 +399,7 @@ export async function loadPoliciesFromToml(
|
||||
toolName: effectiveToolName,
|
||||
decision: rule.decision,
|
||||
priority: transformPriority(rule.priority, tier),
|
||||
modes: tier === 1 ? rule.modes : undefined,
|
||||
};
|
||||
|
||||
// Compile regex pattern
|
||||
@@ -412,12 +435,6 @@ export async function loadPoliciesFromToml(
|
||||
const parsedCheckers: SafetyCheckerRule[] = (
|
||||
validationResult.data.safety_checker ?? []
|
||||
)
|
||||
.filter((checker) => {
|
||||
if (!checker.modes || checker.modes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return checker.modes.includes(approvalMode);
|
||||
})
|
||||
.flatMap((checker) => {
|
||||
let effectiveArgsPattern = checker.argsPattern;
|
||||
const commandPrefixes: string[] = [];
|
||||
@@ -459,6 +476,7 @@ export async function loadPoliciesFromToml(
|
||||
toolName: effectiveToolName,
|
||||
priority: checker.priority,
|
||||
checker: checker.checker as SafetyCheckerConfig,
|
||||
modes: tier === 1 ? checker.modes : undefined,
|
||||
};
|
||||
|
||||
if (argsPattern) {
|
||||
|
||||
@@ -117,6 +117,12 @@ export interface PolicyRule {
|
||||
* Default is 0.
|
||||
*/
|
||||
priority?: number;
|
||||
|
||||
/**
|
||||
* Approval modes this rule applies to.
|
||||
* If undefined or empty, it applies to all modes.
|
||||
*/
|
||||
modes?: ApprovalMode[];
|
||||
}
|
||||
|
||||
export interface SafetyCheckerRule {
|
||||
@@ -143,6 +149,12 @@ export interface SafetyCheckerRule {
|
||||
* additional validation of a tool call.
|
||||
*/
|
||||
checker: SafetyCheckerConfig;
|
||||
|
||||
/**
|
||||
* Approval modes this rule applies to.
|
||||
* If undefined or empty, it applies to all modes.
|
||||
*/
|
||||
modes?: ApprovalMode[];
|
||||
}
|
||||
|
||||
export interface HookExecutionContext {
|
||||
@@ -215,6 +227,12 @@ export interface PolicyEngineConfig {
|
||||
* Defaults to true.
|
||||
*/
|
||||
allowHooks?: boolean;
|
||||
|
||||
/**
|
||||
* Current approval mode.
|
||||
* Used to filter rules that have specific 'modes' defined.
|
||||
*/
|
||||
approvalMode?: ApprovalMode;
|
||||
}
|
||||
|
||||
export interface PolicySettings {
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ModelRouterService } from './modelRouterService.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
|
||||
import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import type { RoutingContext, RoutingDecision } from './routingStrategy.js';
|
||||
import { DefaultStrategy } from './strategies/defaultStrategy.js';
|
||||
@@ -151,81 +148,5 @@ describe('ModelRouterService', () => {
|
||||
expect.any(ModelRoutingEvent),
|
||||
);
|
||||
});
|
||||
|
||||
it('should upgrade to Preview Model when preview features are enabled and model is 2.5 Pro', async () => {
|
||||
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: { source: 'test', latencyMs: 0, reasoning: 'test' },
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(false);
|
||||
|
||||
const decision = await service.route(mockContext);
|
||||
|
||||
expect(decision.model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should NOT upgrade to Preview Model when preview features are disabled', async () => {
|
||||
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: { source: 'test', latencyMs: 0, reasoning: 'test' },
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(false);
|
||||
|
||||
const decision = await service.route(mockContext);
|
||||
|
||||
expect(decision.model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should upgrade to Preview Model when preview features are enabled and model is explicitly set to Pro', async () => {
|
||||
// Simulate OverrideStrategy returning Preview Model (as resolveModel would do for "pro")
|
||||
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
|
||||
model: PREVIEW_GEMINI_MODEL,
|
||||
metadata: {
|
||||
source: 'override',
|
||||
latencyMs: 0,
|
||||
reasoning: 'User selected',
|
||||
},
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(false);
|
||||
|
||||
const decision = await service.route(mockContext);
|
||||
|
||||
expect(decision.model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should NOT upgrade to Preview Model when preview features are enabled and model is explicitly set to a specific string', async () => {
|
||||
// Simulate OverrideStrategy returning a specific model (e.g. "gemini-2.5-pro")
|
||||
// This happens when user explicitly sets model to "gemini-2.5-pro" instead of "pro"
|
||||
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: {
|
||||
source: 'override',
|
||||
latencyMs: 0,
|
||||
reasoning: 'User selected',
|
||||
},
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(false);
|
||||
|
||||
const decision = await service.route(mockContext);
|
||||
|
||||
// Should NOT upgrade to Preview Model because source is 'override' and model is specific
|
||||
expect(decision.model).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should upgrade to Preview Model even if fallback mode is active (probing behavior)', async () => {
|
||||
vi.spyOn(mockCompositeStrategy, 'route').mockResolvedValue({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: { source: 'default', latencyMs: 0, reasoning: 'Default' },
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(true);
|
||||
|
||||
const decision = await service.route(mockContext);
|
||||
|
||||
expect(decision.model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
import type {
|
||||
RoutingContext,
|
||||
RoutingDecision,
|
||||
@@ -66,23 +62,6 @@ export class ModelRouterService {
|
||||
this.config.getBaseLlmClient(),
|
||||
);
|
||||
|
||||
// Unified Preview Model Logic:
|
||||
// If the decision is to use 'gemini-2.5-pro' and preview features are enabled,
|
||||
// we attempt to upgrade to 'gemini-3.0-pro' (Preview Model).
|
||||
if (
|
||||
decision.model === DEFAULT_GEMINI_MODEL &&
|
||||
this.config.getPreviewFeatures() &&
|
||||
!decision.metadata.source.includes('override')
|
||||
) {
|
||||
// We ALWAYS attempt to upgrade to Preview Model here.
|
||||
// If we are in fallback mode, the 'previewModelBypassMode' flag (handled in handler.ts/geminiChat.ts)
|
||||
// will ensure we downgrade to 2.5 Pro for the actual API call if needed.
|
||||
// This allows us to "probe" Preview Model periodically (i.e., every new request tries Preview Model first).
|
||||
decision.model = PREVIEW_GEMINI_MODEL;
|
||||
decision.metadata.source += ' (Preview Model)';
|
||||
decision.metadata.reasoning += ' (Upgraded to Preview Model)';
|
||||
}
|
||||
|
||||
const event = new ModelRoutingEvent(
|
||||
decision.model,
|
||||
decision.metadata.source,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
} from '../../config/models.js';
|
||||
import { promptIdContext } from '../../utils/promptIdContext.js';
|
||||
import type { Content } from '@google/genai';
|
||||
@@ -50,6 +51,7 @@ describe('ClassifierStrategy', () => {
|
||||
modelConfigService: {
|
||||
getResolvedConfig: vi.fn().mockReturnValue(mockResolvedConfig),
|
||||
},
|
||||
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
|
||||
getPreviewFeatures: () => false,
|
||||
} as unknown as Config;
|
||||
mockBaseLlmClient = {
|
||||
|
||||
@@ -12,11 +12,7 @@ import type {
|
||||
RoutingDecision,
|
||||
RoutingStrategy,
|
||||
} from '../routingStrategy.js';
|
||||
import {
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
resolveModel,
|
||||
} from '../../config/models.js';
|
||||
import { resolveClassifierModel } from '../../config/models.js';
|
||||
import { createUserContent, Type } from '@google/genai';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import {
|
||||
@@ -171,32 +167,20 @@ export class ClassifierStrategy implements RoutingStrategy {
|
||||
|
||||
const reasoning = routerResponse.reasoning;
|
||||
const latencyMs = Date.now() - startTime;
|
||||
const selectedModel = resolveClassifierModel(
|
||||
config.getModel(),
|
||||
routerResponse.model_choice,
|
||||
config.getPreviewFeatures(),
|
||||
);
|
||||
|
||||
if (routerResponse.model_choice === FLASH_MODEL) {
|
||||
return {
|
||||
model: resolveModel(
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
config.getPreviewFeatures(),
|
||||
),
|
||||
metadata: {
|
||||
source: 'Classifier',
|
||||
latencyMs,
|
||||
reasoning,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
model: resolveModel(
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
config.getPreviewFeatures(),
|
||||
),
|
||||
metadata: {
|
||||
source: 'Classifier',
|
||||
reasoning,
|
||||
latencyMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
model: selectedModel,
|
||||
metadata: {
|
||||
source: 'Classifier',
|
||||
latencyMs,
|
||||
reasoning,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If the classifier fails for any reason (API error, parsing error, etc.),
|
||||
// we log it and return null to allow the composite strategy to proceed.
|
||||
|
||||
@@ -4,87 +4,118 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FallbackStrategy } from './fallbackStrategy.js';
|
||||
import type { RoutingContext } from '../routingStrategy.js';
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { ModelAvailabilityService } from '../../availability/modelAvailabilityService.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
} from '../../config/models.js';
|
||||
import { selectModelForAvailability } from '../../availability/policyHelpers.js';
|
||||
|
||||
vi.mock('../../availability/policyHelpers.js', () => ({
|
||||
selectModelForAvailability: vi.fn(),
|
||||
}));
|
||||
|
||||
const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
({
|
||||
isModelAvailabilityServiceEnabled: vi.fn().mockReturnValue(true),
|
||||
getModelAvailabilityService: vi.fn(),
|
||||
getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
describe('FallbackStrategy', () => {
|
||||
const strategy = new FallbackStrategy();
|
||||
const mockContext = {} as RoutingContext;
|
||||
const mockClient = {} as BaseLlmClient;
|
||||
let mockService: ModelAvailabilityService;
|
||||
let mockConfig: Config;
|
||||
|
||||
it('should return null when not in fallback mode', async () => {
|
||||
const mockConfig = {
|
||||
isInFallbackMode: () => false,
|
||||
getModel: () => DEFAULT_GEMINI_MODEL,
|
||||
getPreviewFeatures: () => false,
|
||||
} as Config;
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockService = {
|
||||
snapshot: vi.fn(),
|
||||
} as unknown as ModelAvailabilityService;
|
||||
|
||||
mockConfig = createMockConfig({
|
||||
getModelAvailabilityService: vi.fn().mockReturnValue(mockService),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if service is disabled', async () => {
|
||||
vi.mocked(mockConfig.isModelAvailabilityServiceEnabled).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
expect(decision).toBeNull();
|
||||
});
|
||||
|
||||
describe('when in fallback mode', () => {
|
||||
it('should downgrade a pro model to the flash model', async () => {
|
||||
const mockConfig = {
|
||||
isInFallbackMode: () => true,
|
||||
getModel: () => DEFAULT_GEMINI_MODEL,
|
||||
getPreviewFeatures: () => false,
|
||||
} as Config;
|
||||
it('should return null if the requested model is available', async () => {
|
||||
// Mock snapshot to return available
|
||||
vi.mocked(mockService.snapshot).mockReturnValue({ available: true });
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockClient,
|
||||
);
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
expect(decision).toBeNull();
|
||||
// Should check availability of the resolved model (DEFAULT_GEMINI_MODEL)
|
||||
expect(mockService.snapshot).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
expect(decision).not.toBeNull();
|
||||
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(decision?.metadata.source).toBe('fallback');
|
||||
expect(decision?.metadata.reasoning).toContain('In fallback mode');
|
||||
it('should return null if fallback selection is same as requested model', async () => {
|
||||
// Mock snapshot to return unavailable
|
||||
vi.mocked(mockService.snapshot).mockReturnValue({
|
||||
available: false,
|
||||
reason: 'quota',
|
||||
});
|
||||
// Mock selectModelForAvailability to return the SAME model (no fallback found)
|
||||
vi.mocked(selectModelForAvailability).mockReturnValue({
|
||||
selectedModel: DEFAULT_GEMINI_MODEL,
|
||||
skipped: [],
|
||||
});
|
||||
|
||||
it('should honor a lite model request', async () => {
|
||||
const mockConfig = {
|
||||
isInFallbackMode: () => true,
|
||||
getModel: () => DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
getPreviewFeatures: () => false,
|
||||
} as Config;
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
expect(decision).toBeNull();
|
||||
});
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockClient,
|
||||
);
|
||||
|
||||
expect(decision).not.toBeNull();
|
||||
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
expect(decision?.metadata.source).toBe('fallback');
|
||||
it('should return fallback decision if model is unavailable and fallback found', async () => {
|
||||
// Mock snapshot to return unavailable
|
||||
vi.mocked(mockService.snapshot).mockReturnValue({
|
||||
available: false,
|
||||
reason: 'quota',
|
||||
});
|
||||
|
||||
it('should use the flash model if flash is requested', async () => {
|
||||
const mockConfig = {
|
||||
isInFallbackMode: () => true,
|
||||
getModel: () => DEFAULT_GEMINI_FLASH_MODEL,
|
||||
getPreviewFeatures: () => false,
|
||||
} as Config;
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockClient,
|
||||
);
|
||||
|
||||
expect(decision).not.toBeNull();
|
||||
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(decision?.metadata.source).toBe('fallback');
|
||||
// Mock selectModelForAvailability to find a fallback (Flash)
|
||||
vi.mocked(selectModelForAvailability).mockReturnValue({
|
||||
selectedModel: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
skipped: [{ model: DEFAULT_GEMINI_MODEL, reason: 'quota' }],
|
||||
});
|
||||
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
|
||||
expect(decision).not.toBeNull();
|
||||
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(decision?.metadata.source).toBe('fallback');
|
||||
expect(decision?.metadata.reasoning).toContain(
|
||||
`Model ${DEFAULT_GEMINI_MODEL} is unavailable`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly handle "auto" alias by resolving it before checking availability', async () => {
|
||||
// Mock snapshot to return available for the RESOLVED model
|
||||
vi.mocked(mockService.snapshot).mockReturnValue({ available: true });
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
|
||||
expect(decision).toBeNull();
|
||||
// Important: check that it queried snapshot with the RESOLVED model, not 'auto'
|
||||
expect(mockService.snapshot).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { selectModelForAvailability } from '../../availability/policyHelpers.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { getEffectiveModel } from '../../config/models.js';
|
||||
import { resolveModel } from '../../config/models.js';
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
import type {
|
||||
RoutingContext,
|
||||
@@ -21,24 +22,38 @@ export class FallbackStrategy implements RoutingStrategy {
|
||||
config: Config,
|
||||
_baseLlmClient: BaseLlmClient,
|
||||
): Promise<RoutingDecision | null> {
|
||||
const isInFallbackMode: boolean = config.isInFallbackMode();
|
||||
|
||||
if (!isInFallbackMode) {
|
||||
if (!config.isModelAvailabilityServiceEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveModel = getEffectiveModel(
|
||||
isInFallbackMode,
|
||||
config.getModel(),
|
||||
const requestedModel = config.getModel();
|
||||
const resolvedModel = resolveModel(
|
||||
requestedModel,
|
||||
config.getPreviewFeatures(),
|
||||
);
|
||||
return {
|
||||
model: effectiveModel,
|
||||
metadata: {
|
||||
source: this.name,
|
||||
latencyMs: 0,
|
||||
reasoning: `In fallback mode. Using: ${effectiveModel}`,
|
||||
},
|
||||
};
|
||||
const service = config.getModelAvailabilityService();
|
||||
const snapshot = service.snapshot(resolvedModel);
|
||||
|
||||
if (snapshot.available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = selectModelForAvailability(config, requestedModel);
|
||||
|
||||
if (
|
||||
selection?.selectedModel &&
|
||||
selection.selectedModel !== requestedModel
|
||||
) {
|
||||
return {
|
||||
model: selection.selectedModel,
|
||||
metadata: {
|
||||
source: this.name,
|
||||
latencyMs: 0,
|
||||
reasoning: `Model ${requestedModel} is unavailable (${snapshot.reason}). Using fallback: ${selection.selectedModel}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import type { Config } from '../../config/config.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
resolveModel,
|
||||
getEffectiveModel,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
} from '../../config/models.js';
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
import type {
|
||||
@@ -30,11 +31,15 @@ export class OverrideStrategy implements RoutingStrategy {
|
||||
const overrideModel = config.getModel();
|
||||
|
||||
// If the model is 'auto' we should pass to the next strategy.
|
||||
if (overrideModel === DEFAULT_GEMINI_MODEL_AUTO) return null;
|
||||
if (
|
||||
overrideModel === DEFAULT_GEMINI_MODEL_AUTO ||
|
||||
overrideModel === PREVIEW_GEMINI_MODEL_AUTO
|
||||
)
|
||||
return null;
|
||||
|
||||
// Return the overridden model name.
|
||||
return {
|
||||
model: resolveModel(overrideModel, config.getPreviewFeatures()),
|
||||
model: getEffectiveModel(overrideModel, config.getPreviewFeatures()),
|
||||
metadata: {
|
||||
source: this.name,
|
||||
latencyMs: 0,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { firePreCompressHook } from '../core/sessionHookTriggers.js';
|
||||
import { PreCompressTrigger } from '../hooks/types.js';
|
||||
@@ -88,6 +89,8 @@ export function modelStringToModelConfigAlias(model: string): string {
|
||||
switch (model) {
|
||||
case PREVIEW_GEMINI_MODEL:
|
||||
return 'chat-compression-3-pro';
|
||||
case PREVIEW_GEMINI_FLASH_MODEL:
|
||||
return 'chat-compression-3-flash';
|
||||
case DEFAULT_GEMINI_MODEL:
|
||||
return 'chat-compression-2.5-pro';
|
||||
case DEFAULT_GEMINI_FLASH_MODEL:
|
||||
|
||||
@@ -49,6 +49,18 @@
|
||||
"topK": 64
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-preview": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 1,
|
||||
"topP": 0.95,
|
||||
"thinkingConfig": {
|
||||
"includeThoughts": true,
|
||||
"thinkingLevel": "HIGH"
|
||||
},
|
||||
"topK": 64
|
||||
}
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
"model": "gemini-2.5-pro",
|
||||
"generateContentConfig": {
|
||||
@@ -203,6 +215,10 @@
|
||||
"model": "gemini-3-pro-preview",
|
||||
"generateContentConfig": {}
|
||||
},
|
||||
"chat-compression-3-flash": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {}
|
||||
},
|
||||
"chat-compression-2.5-pro": {
|
||||
"model": "gemini-2.5-pro",
|
||||
"generateContentConfig": {}
|
||||
|
||||
@@ -49,6 +49,18 @@
|
||||
"topK": 64
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-preview": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 1,
|
||||
"topP": 0.95,
|
||||
"thinkingConfig": {
|
||||
"includeThoughts": true,
|
||||
"thinkingLevel": "HIGH"
|
||||
},
|
||||
"topK": 64
|
||||
}
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
"model": "gemini-2.5-pro",
|
||||
"generateContentConfig": {
|
||||
@@ -203,6 +215,10 @@
|
||||
"model": "gemini-3-pro-preview",
|
||||
"generateContentConfig": {}
|
||||
},
|
||||
"chat-compression-3-flash": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {}
|
||||
},
|
||||
"chat-compression-2.5-pro": {
|
||||
"model": "gemini-2.5-pro",
|
||||
"generateContentConfig": {}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { StartupProfiler } from './startupProfiler.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
// Mock the metrics module
|
||||
vi.mock('./metrics.js', () => ({
|
||||
@@ -255,6 +256,19 @@ describe('StartupProfiler', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use debug logging instead of standard logging', () => {
|
||||
const logSpy = vi.spyOn(debugLogger, 'log');
|
||||
const debugSpy = vi.spyOn(debugLogger, 'debug');
|
||||
|
||||
const handle = profiler.start('test_phase');
|
||||
handle?.end();
|
||||
|
||||
profiler.flush(mockConfig);
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(debugSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration scenarios', () => {
|
||||
|
||||
@@ -145,7 +145,7 @@ export class StartupProfiler {
|
||||
* Flushes buffered metrics to the telemetry system.
|
||||
*/
|
||||
flush(config: Config): void {
|
||||
debugLogger.log(
|
||||
debugLogger.debug(
|
||||
'[STARTUP] StartupProfiler.flush() called with',
|
||||
this.phases.size,
|
||||
'phases',
|
||||
@@ -181,7 +181,7 @@ export class StartupProfiler {
|
||||
...phase.details,
|
||||
};
|
||||
|
||||
debugLogger.log(
|
||||
debugLogger.debug(
|
||||
'[STARTUP] Recording metric for phase:',
|
||||
phase.name,
|
||||
'duration:',
|
||||
@@ -192,7 +192,7 @@ export class StartupProfiler {
|
||||
details,
|
||||
});
|
||||
} else {
|
||||
debugLogger.log(
|
||||
debugLogger.debug(
|
||||
'[STARTUP] Skipping phase without measure:',
|
||||
phase.name,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EditTool } from './edit.js';
|
||||
import { SmartEditTool } from './smart-edit.js';
|
||||
import { WriteFileTool } from './write-file.js';
|
||||
import { WebFetchTool } from './web-fetch.js';
|
||||
import { ToolConfirmationOutcome } from './tools.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { MessageBusType } from '../confirmation-bus/types.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
// Mock telemetry loggers to avoid failures
|
||||
vi.mock('../telemetry/loggers.js', () => ({
|
||||
logSmartEditStrategy: vi.fn(),
|
||||
logSmartEditCorrectionEvent: vi.fn(),
|
||||
logFileOperation: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Tool Confirmation Policy Updates', () => {
|
||||
let mockConfig: any;
|
||||
let mockMessageBus: MessageBus;
|
||||
const rootDir = path.join(
|
||||
os.tmpdir(),
|
||||
`gemini-cli-policy-test-${Date.now()}`,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(rootDir)) {
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
}
|
||||
|
||||
mockMessageBus = {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as MessageBus;
|
||||
|
||||
mockConfig = {
|
||||
getTargetDir: () => rootDir,
|
||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
setApprovalMode: vi.fn(),
|
||||
getFileSystemService: () => ({
|
||||
readTextFile: vi.fn().mockImplementation((p) => {
|
||||
if (fs.existsSync(p)) {
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
}
|
||||
return 'existing content';
|
||||
}),
|
||||
writeTextFile: vi.fn().mockImplementation((p, c) => {
|
||||
fs.writeFileSync(p, c);
|
||||
}),
|
||||
}),
|
||||
getFileService: () => ({}),
|
||||
getFileFilteringOptions: () => ({}),
|
||||
getGeminiClient: () => ({}),
|
||||
getBaseLlmClient: () => ({}),
|
||||
getIdeMode: () => false,
|
||||
getWorkspaceContext: () => ({
|
||||
isPathWithinWorkspace: () => true,
|
||||
getDirectories: () => [rootDir],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(rootDir)) {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: 'EditTool',
|
||||
create: (config: Config, bus: MessageBus) => new EditTool(config, bus),
|
||||
params: {
|
||||
file_path: 'test.txt',
|
||||
old_string: 'existing',
|
||||
new_string: 'new',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'SmartEditTool',
|
||||
create: (config: Config, bus: MessageBus) =>
|
||||
new SmartEditTool(config, bus),
|
||||
params: {
|
||||
file_path: 'test.txt',
|
||||
instruction: 'change content',
|
||||
old_string: 'existing',
|
||||
new_string: 'new',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'WriteFileTool',
|
||||
create: (config: Config, bus: MessageBus) =>
|
||||
new WriteFileTool(config, bus),
|
||||
params: {
|
||||
file_path: path.join(rootDir, 'test.txt'),
|
||||
content: 'new content',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'WebFetchTool',
|
||||
create: (config: Config, bus: MessageBus) =>
|
||||
new WebFetchTool(config, bus),
|
||||
params: {
|
||||
prompt: 'fetch https://example.com',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe.each(tools)('$name policy updates', ({ create, params }) => {
|
||||
it.each([
|
||||
{
|
||||
outcome: ToolConfirmationOutcome.ProceedAlways,
|
||||
shouldPublish: false,
|
||||
expectedApprovalMode: ApprovalMode.AUTO_EDIT,
|
||||
},
|
||||
{
|
||||
outcome: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
shouldPublish: true,
|
||||
persist: true,
|
||||
},
|
||||
])(
|
||||
'should handle $outcome correctly',
|
||||
async ({ outcome, shouldPublish, persist, expectedApprovalMode }) => {
|
||||
const tool = create(mockConfig, mockMessageBus);
|
||||
|
||||
// For file-based tools, ensure the file exists if needed
|
||||
if (params.file_path) {
|
||||
const fullPath = path.isAbsolute(params.file_path)
|
||||
? params.file_path
|
||||
: path.join(rootDir, params.file_path);
|
||||
fs.writeFileSync(fullPath, 'existing content');
|
||||
}
|
||||
|
||||
const invocation = tool.build(params as any);
|
||||
|
||||
// Mock getMessageBusDecision to trigger ASK_USER flow
|
||||
vi.spyOn(invocation as any, 'getMessageBusDecision').mockResolvedValue(
|
||||
'ASK_USER',
|
||||
);
|
||||
|
||||
const confirmation = await invocation.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(confirmation).not.toBe(false);
|
||||
|
||||
if (confirmation) {
|
||||
await confirmation.onConfirm(outcome);
|
||||
|
||||
if (shouldPublish) {
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
persist,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// Should not publish UPDATE_POLICY message for ProceedAlways
|
||||
const publishCalls = (mockMessageBus.publish as any).mock.calls;
|
||||
const hasUpdatePolicy = publishCalls.some(
|
||||
(call: any) => call[0].type === MessageBusType.UPDATE_POLICY,
|
||||
);
|
||||
expect(hasUpdatePolicy).toBe(false);
|
||||
}
|
||||
|
||||
if (expectedApprovalMode !== undefined) {
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
|
||||
expectedApprovalMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -311,9 +311,12 @@ class EditToolInvocation
|
||||
newContent: editData.newContent,
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
// No need to publish a policy update as the default policy for
|
||||
// AUTO_EDIT already reflects always approving edit.
|
||||
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
|
||||
} else {
|
||||
await this.publishPolicyUpdate(outcome);
|
||||
}
|
||||
await this.publishPolicyUpdate(outcome);
|
||||
|
||||
if (ideConfirmation) {
|
||||
const result = await ideConfirmation;
|
||||
|
||||
@@ -681,9 +681,12 @@ class EditToolInvocation
|
||||
newContent: editData.newContent,
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
// No need to publish a policy update as the default policy for
|
||||
// AUTO_EDIT already reflects always approving smart-edit.
|
||||
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
|
||||
} else {
|
||||
await this.publishPolicyUpdate(outcome);
|
||||
}
|
||||
await this.publishPolicyUpdate(outcome);
|
||||
|
||||
if (ideConfirmation) {
|
||||
const result = await ideConfirmation;
|
||||
|
||||
@@ -242,9 +242,12 @@ ${textContent}
|
||||
urls,
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
// No need to publish a policy update as the default policy for
|
||||
// AUTO_EDIT already reflects always approving web-fetch.
|
||||
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
|
||||
} else {
|
||||
await this.publishPolicyUpdate(outcome);
|
||||
}
|
||||
await this.publishPolicyUpdate(outcome);
|
||||
},
|
||||
};
|
||||
return confirmationDetails;
|
||||
|
||||
@@ -222,9 +222,12 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
newContent: correctedContent,
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
// No need to publish a policy update as the default policy for
|
||||
// AUTO_EDIT already reflects always approving write-file.
|
||||
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
|
||||
} else {
|
||||
await this.publishPolicyUpdate(outcome);
|
||||
}
|
||||
await this.publishPolicyUpdate(outcome);
|
||||
|
||||
if (ideConfirmation) {
|
||||
const result = await ideConfirmation;
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('Retry Utility Fallback Integration', () => {
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow('Daily limit');
|
||||
expect(fallbackCallback).not.toHaveBeenCalled();
|
||||
expect(fallbackCallback).toHaveBeenCalledTimes(1);
|
||||
expect(mockApiCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -325,7 +325,7 @@ describe('classifyGoogleError', () => {
|
||||
expect(result).toBeInstanceOf(TerminalQuotaError);
|
||||
});
|
||||
|
||||
it('should return original error for 429 without specific details', () => {
|
||||
it('should return RetryableQuotaError for any 429', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 429,
|
||||
message: 'Too many requests',
|
||||
@@ -340,7 +340,10 @@ describe('classifyGoogleError', () => {
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const originalError = new Error();
|
||||
const result = classifyGoogleError(originalError);
|
||||
expect(result).toBe(originalError);
|
||||
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||
if (result instanceof RetryableQuotaError) {
|
||||
expect(result.retryDelayMs).toBe(5000);
|
||||
}
|
||||
});
|
||||
|
||||
it('should classify nested JSON string 404 error as ModelNotFoundError', () => {
|
||||
@@ -389,4 +392,61 @@ describe('classifyGoogleError', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should return RetryableQuotaError with 5s fallback for generic 429 without specific message', () => {
|
||||
const generic429 = {
|
||||
status: 429,
|
||||
message: 'Resource exhausted. No specific retry info.',
|
||||
};
|
||||
|
||||
const result = classifyGoogleError(generic429);
|
||||
|
||||
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||
if (result instanceof RetryableQuotaError) {
|
||||
expect(result.retryDelayMs).toBe(5000);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return RetryableQuotaError with 5s fallback for 429 with empty details and no regex match', () => {
|
||||
const errorWithEmptyDetails = {
|
||||
error: {
|
||||
code: 429,
|
||||
message: 'A generic 429 error with no retry message.',
|
||||
details: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = classifyGoogleError(errorWithEmptyDetails);
|
||||
|
||||
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||
if (result instanceof RetryableQuotaError) {
|
||||
expect(result.retryDelayMs).toBe(5000);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return RetryableQuotaError with 5s fallback for 429 with some detail', () => {
|
||||
const errorWithEmptyDetails = {
|
||||
error: {
|
||||
code: 429,
|
||||
message: 'A generic 429 error with no retry message.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'QUOTA_EXCEEDED',
|
||||
domain: 'googleapis.com',
|
||||
metadata: {
|
||||
quota_limit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = classifyGoogleError(errorWithEmptyDetails);
|
||||
|
||||
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||
if (result instanceof RetryableQuotaError) {
|
||||
expect(result.retryDelayMs).toBe(5000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
import { parseGoogleApiError } from './googleErrors.js';
|
||||
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
|
||||
|
||||
const DEFAULT_RETRYABLE_DELAY_SECOND = 5;
|
||||
|
||||
/**
|
||||
* A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit).
|
||||
*/
|
||||
@@ -112,6 +114,18 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
retryDelaySeconds,
|
||||
);
|
||||
}
|
||||
} else if (status === 429) {
|
||||
// Fallback: If it is a 429 but doesn't have a specific "retry in" message,
|
||||
// assume it is a temporary rate limit and retry after 5 sec (same as DEFAULT_RETRY_OPTIONS).
|
||||
return new RetryableQuotaError(
|
||||
errorMessage,
|
||||
googleApiError ?? {
|
||||
code: 429,
|
||||
message: errorMessage,
|
||||
details: [],
|
||||
},
|
||||
DEFAULT_RETRYABLE_DELAY_SECOND,
|
||||
);
|
||||
}
|
||||
|
||||
return error; // Not a 429 error we can handle with structured details or a parsable retry message.
|
||||
@@ -232,5 +246,21 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If we reached this point and the status is still 429, we return retryable.
|
||||
if (status === 429) {
|
||||
const errorMessage =
|
||||
googleApiError?.message ||
|
||||
(error instanceof Error ? error.message : String(error));
|
||||
return new RetryableQuotaError(
|
||||
errorMessage,
|
||||
googleApiError ?? {
|
||||
code: 429,
|
||||
message: errorMessage,
|
||||
details: [],
|
||||
},
|
||||
DEFAULT_RETRYABLE_DELAY_SECOND,
|
||||
);
|
||||
}
|
||||
return error; // Fallback to original error if no specific classification fits.
|
||||
}
|
||||
|
||||
@@ -464,7 +464,7 @@ describe('retryWithBackoff', () => {
|
||||
});
|
||||
|
||||
it.each([[AuthType.USE_GEMINI], [AuthType.USE_VERTEX_AI], [undefined]])(
|
||||
'should not trigger fallback for non-Google auth users (authType: %s) on TerminalQuotaError',
|
||||
'should invoke onPersistent429 callback (delegating decision) for non-Google auth users (authType: %s) on TerminalQuotaError',
|
||||
async (authType) => {
|
||||
const fallbackCallback = vi.fn();
|
||||
const mockFn = vi.fn().mockImplementation(async () => {
|
||||
@@ -478,7 +478,7 @@ describe('retryWithBackoff', () => {
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow('Daily limit reached');
|
||||
expect(fallbackCallback).not.toHaveBeenCalled();
|
||||
expect(fallbackCallback).toHaveBeenCalled();
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
@@ -629,20 +629,10 @@ describe('retryWithBackoff', () => {
|
||||
).rejects.toThrow(TerminalQuotaError);
|
||||
|
||||
// Verify failures
|
||||
expect(mockService.markTerminal).toHaveBeenCalledWith('model-1', 'quota');
|
||||
expect(mockService.markTerminal).toHaveBeenCalledWith('model-2', 'quota');
|
||||
expect(mockService.markTerminal).not.toHaveBeenCalled();
|
||||
expect(mockService.markTerminal).not.toHaveBeenCalled();
|
||||
|
||||
// Verify sequences
|
||||
expect(mockService.markTerminal).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'model-1',
|
||||
'quota',
|
||||
);
|
||||
expect(mockService.markTerminal).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'model-2',
|
||||
'quota',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks sticky_retry after retries are exhausted for transient failures', async () => {
|
||||
@@ -671,8 +661,8 @@ describe('retryWithBackoff', () => {
|
||||
expect(result).toBe(transientError);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
expect(mockService.markRetryOncePerTurn).toHaveBeenCalledWith('model-1');
|
||||
expect(mockService.markRetryOncePerTurn).toHaveBeenCalledTimes(1);
|
||||
expect(mockService.markRetryOncePerTurn).not.toHaveBeenCalled();
|
||||
expect(mockService.markRetryOncePerTurn).not.toHaveBeenCalled();
|
||||
expect(mockService.markTerminal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -710,29 +700,7 @@ describe('retryWithBackoff', () => {
|
||||
maxAttempts: 1,
|
||||
getAvailabilityContext: getContext,
|
||||
}).catch(() => {});
|
||||
expect(mockService.markTerminal).toHaveBeenCalledWith('model-1', 'quota');
|
||||
|
||||
// Run for notFoundError
|
||||
await retryWithBackoff(fn, {
|
||||
maxAttempts: 1,
|
||||
getAvailabilityContext: getContext,
|
||||
}).catch(() => {});
|
||||
expect(mockService.markTerminal).toHaveBeenCalledWith(
|
||||
'model-1',
|
||||
'capacity',
|
||||
);
|
||||
|
||||
// Run for genericError
|
||||
await retryWithBackoff(fn, {
|
||||
maxAttempts: 1,
|
||||
getAvailabilityContext: getContext,
|
||||
}).catch(() => {});
|
||||
expect(mockService.markTerminal).toHaveBeenCalledWith(
|
||||
'model-1',
|
||||
'capacity',
|
||||
);
|
||||
|
||||
expect(mockService.markTerminal).toHaveBeenCalledTimes(3);
|
||||
expect(mockService.markTerminal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import type { GenerateContentResponse } from '@google/genai';
|
||||
import { ApiError } from '@google/genai';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import {
|
||||
TerminalQuotaError,
|
||||
RetryableQuotaError,
|
||||
@@ -16,8 +15,6 @@ import { delay, createAbortError } from './delay.js';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
|
||||
import type { RetryAvailabilityContext } from '../availability/modelPolicy.js';
|
||||
import { classifyFailureKind } from '../availability/errorClassification.js';
|
||||
import { applyAvailabilityTransition } from '../availability/policyHelpers.js';
|
||||
|
||||
export type { RetryAvailabilityContext };
|
||||
|
||||
@@ -192,12 +189,6 @@ export async function retryWithBackoff<T>(
|
||||
}
|
||||
|
||||
const classifiedError = classifyGoogleError(error);
|
||||
const failureKind = classifyFailureKind(classifiedError);
|
||||
const appliedImmediate =
|
||||
failureKind === 'terminal' || failureKind === 'not_found';
|
||||
if (appliedImmediate) {
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
}
|
||||
|
||||
const errorCode = getErrorStatus(error);
|
||||
|
||||
@@ -205,7 +196,7 @@ export async function retryWithBackoff<T>(
|
||||
classifiedError instanceof TerminalQuotaError ||
|
||||
classifiedError instanceof ModelNotFoundError
|
||||
) {
|
||||
if (onPersistent429 && authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
if (onPersistent429) {
|
||||
try {
|
||||
const fallbackModel = await onPersistent429(
|
||||
authType,
|
||||
@@ -229,7 +220,7 @@ export async function retryWithBackoff<T>(
|
||||
|
||||
if (classifiedError instanceof RetryableQuotaError || is500) {
|
||||
if (attempt >= maxAttempts) {
|
||||
if (onPersistent429 && authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
if (onPersistent429) {
|
||||
try {
|
||||
const fallbackModel = await onPersistent429(
|
||||
authType,
|
||||
@@ -244,9 +235,6 @@ export async function retryWithBackoff<T>(
|
||||
console.warn('Model fallback failed:', fallbackError);
|
||||
}
|
||||
}
|
||||
if (!appliedImmediate) {
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
}
|
||||
throw classifiedError instanceof RetryableQuotaError
|
||||
? classifiedError
|
||||
: error;
|
||||
@@ -276,9 +264,6 @@ export async function retryWithBackoff<T>(
|
||||
attempt >= maxAttempts ||
|
||||
!shouldRetryOnError(error as Error, retryFetchErrors)
|
||||
) {
|
||||
if (!appliedImmediate) {
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.22.2",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user