Compare commits

...

35 Commits

Author SHA1 Message Date
gemini-cli-robot 72e2072e7c chore(release): v0.21.2 2025-12-17 23:53:24 +00:00
gemini-cli-robot 97eab144bc fix(patch): cherry-pick a6d1245 to release/v0.21.1-pr-15214 to patch version v0.21.1 and create version 0.21.2 (#15227)
Co-authored-by: Sehoon Shon <sshon@google.com>
2025-12-17 23:26:19 +00:00
gemini-cli-robot f69619e2ec chore(release): v0.21.1 2025-12-17 15:44:48 +00:00
Jenna Inouye 4bdf85f301 Docs: Update Gemini 3 Documentation (#91)
* Update docs.

* Update model docs.
2025-12-16 14:27:44 -08:00
Tommaso Sciortino b27945f904 fix test 2025-12-16 13:50:15 -08:00
Adib234 86a777865f change banner text for vertex and api key users (#90) 2025-12-16 13:45:41 -08:00
Adam Weidman 4292c8784f feat: update to match mocks (#89) 2025-12-16 13:44:53 -08:00
Sehoon Shon ae5068b8cb Check if a user has access to preview model (#87) 2025-12-16 13:43:49 -08:00
Tommaso Sciortino d591140f62 Fix prompt and chat code (#88) 2025-12-16 13:41:59 -08:00
joshualitt d4f1da3934 feat(core): Add real model string (#86) 2025-12-16 13:40:35 -08:00
joshualitt 0188599642 feat(core): Disable todos for 3 family. (#85)
* feat(core): Disable todos for 3 family.

* tidy
2025-12-16 13:37:55 -08:00
Sehoon Shon 4cee7e83c4 Do not fallback for manual models (#84)
* Update display name for alias model

* fix tests
2025-12-16 13:37:08 -08:00
Sehoon Shon 56c3daf2f5 Use Preview Flash model if the main model is a preview model for code… (#83)
* Use Preview Flash model if the main model is a preview model for codebase investigator

* fix test

* Generate settings doc
2025-12-16 13:32:15 -08:00
Adam Weidman af94beea11 FEAT: Add availabilityService (#81)
* auto and fallback work

* test fixes

* fixes

* Show model dialog even if there's no more fallback option

* fix tests

* fix failing test

* disable showInDialog for model in settings

* revert package-lock.json

* remove dup line

---------

Co-authored-by: Sehoon Shon <sshon@google.com>
2025-12-16 13:26:58 -08:00
Adam Weidman 4b3d858f31 feat: reset availabilityService on /auth 2025-12-16 13:25:49 -08:00
Sehoon Shon 17bf02b901 Update models menu dialog to support manual model selection and group… (#80)
* Update models menu dialog to support manual model selection and group by model family

* fix tests

* update codebase investigator model setting

* fix test

* fix test

* update generated golden file

* regenerate scheme and doc for settings

* use Preview Auto if previewFeatures is set to true
2025-12-16 13:23:57 -08:00
Adam Weidman c3f6e7132b feat: try more parsing 2025-12-16 13:10:09 -08:00
Your Name a8e3928dd2 feat(core): Update checkpoint. 2025-12-16 13:07:30 -08:00
Abhi faf69f2c99 Address feedback 2025-12-16 13:06:29 -08:00
Abhi 6d13e0b3a2 feat: ensure codebase investigator uses preview model when main agent does 2025-12-16 13:06:26 -08:00
Your Name 9b571d42b1 feat(core): Add model config for flash 3 2025-12-16 10:58:37 -08:00
gemini-cli-robot 79fa7dcb6d chore(release): v0.21.0 2025-12-16 18:32:05 +00:00
gemini-cli-robot 1f1bbc462f chore(release): v0.21.0-preview.6 2025-12-16 17:29:29 +00:00
gemini-cli-robot 32f536b76b fix(patch): cherry-pick 2995af6 to release/v0.21.0-preview.5-pr-15131 to patch version v0.21.0-preview.5 and create version 0.21.0-preview.6 (#15153)
Co-authored-by: Sehoon Shon <sshon@google.com>
2025-12-16 17:12:38 +00:00
gemini-cli-robot 5977ce8d62 chore(release): v0.21.0-preview.5 2025-12-12 21:26:52 +00:00
gemini-cli-robot f47b44e70d fix(patch): cherry-pick edbe548 to release/v0.21.0-preview.4-pr-15007 to patch version v0.21.0-preview.4 and create version 0.21.0-preview.5 (#15015)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-12-12 15:57:08 -05:00
gemini-cli-robot dcf2317adf chore(release): v0.21.0-preview.4 2025-12-12 18:37:01 +00:00
gemini-cli-robot 61c7c95374 fix(patch): cherry-pick 12cbe32 to release/v0.21.0-preview.3-pr-15000 to patch version v0.21.0-preview.3 and create version 0.21.0-preview.4 (#15003)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-12-12 10:15:52 -08:00
gemini-cli-robot ccee6a71df chore(release): v0.21.0-preview.3 2025-12-12 06:57:31 +00:00
gemini-cli-robot 73add0fda7 fix(patch): cherry-pick 54de675 to release/v0.21.0-preview.2-pr-14961 [CONFLICTS] (#14968)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-12-12 06:36:28 +00:00
gemini-cli-robot cc5c6bd90a chore(release): v0.21.0-preview.2 2025-12-10 19:27:51 +00:00
gemini-cli-robot d9e79331ef fix(patch): cherry-pick ee6556c to release/v0.21.0-preview.1-pr-14691 to patch version v0.21.0-preview.1 and create version 0.21.0-preview.2 (#14908)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2025-12-10 13:58:07 -05:00
gemini-cli-robot 68b7714d9b chore(release): v0.21.0-preview.1 2025-12-09 21:06:14 +00:00
gemini-cli-robot 04dece3e3c fix(patch): cherry-pick 3f5f030 to release/v0.21.0-preview.0-pr-14843 to patch version v0.21.0-preview.0 and create version 0.21.0-preview.1 (#14851)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2025-12-09 12:48:37 -08:00
gemini-cli-robot 2a3513bcdb chore(release): v0.21.0-preview.0 2025-12-09 20:11:24 +00:00
89 changed files with 2720 additions and 2588 deletions
+30 -30
View File
@@ -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:
+12 -1
View File
@@ -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"
@@ -816,7 +827,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:** `"gemini-2.5-pro"`
- **Default:** `"auto"`
- **Requires restart:** Yes
#### `hooks`
+29 -48
View File
@@ -1,56 +1,37 @@
# Gemini 3 Pro on Gemini CLI
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
Were 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 youre automatically granted access or accepted from the
waitlist, youll 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. Weve
onboarded everyone who signed up to the previously available waitlist.
- If you were not on our waitlist, were 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. Youll 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 youve been
granted access to Gemini 3 Pro, youll receive an acceptance email to your
submitted email address.
After youve 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 emailor if you are automatically granted
accessyou 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?
+1 -1
View File
@@ -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"
},
{
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
"version": "0.21.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
"version": "0.21.2",
"workspaces": [
"packages/*"
],
@@ -17692,7 +17692,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
"version": "0.21.2",
"dependencies": {
"@a2a-js/sdk": "^0.3.2",
"@google-cloud/storage": "^7.16.0",
@@ -18003,7 +18003,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
"version": "0.21.2",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.30.0",
@@ -18104,7 +18104,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
"version": "0.21.2",
"dependencies": {
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
@@ -18248,7 +18248,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
"version": "0.21.2",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -18259,7 +18259,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
"version": "0.21.2",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
"version": "0.21.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.20251209.ec9a8c7a7"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.2"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
"version": "0.21.2",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+4 -1
View File
@@ -22,6 +22,7 @@ import {
DEFAULT_GEMINI_MODEL,
type ExtensionLoader,
startupProfiler,
PREVIEW_GEMINI_MODEL,
} from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
@@ -38,7 +39,9 @@ export async function loadConfig(
const configParams: ConfigParameters = {
sessionId: taskId,
model: DEFAULT_GEMINI_MODEL,
model: settings.general?.previewFeatures
? PREVIEW_GEMINI_MODEL
: DEFAULT_GEMINI_MODEL,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
targetDir: workspaceDir, // Or a specific directory the agent operates on
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
"version": "0.21.2",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-nightly.20251209.ec9a8c7a7"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.2"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
+1 -1
View File
@@ -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 () => {
+4 -1
View File
@@ -31,6 +31,7 @@ import {
debugLogger,
loadServerHierarchicalMemory,
WEB_FETCH_TOOL_NAME,
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'] ||
+4 -4
View File
@@ -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': {
@@ -1929,7 +1929,7 @@ describe('Settings Loading and Merging', () => {
usageStatisticsEnabled: false,
},
model: {
name: 'gemini-pro',
name: 'gemini-2.5-pro',
},
context: {
fileName: 'CONTEXT.md',
@@ -1968,7 +1968,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,
+2 -2
View File
@@ -18,8 +18,8 @@ import type {
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_GEMINI_MODEL,
DEFAULT_MODEL_CONFIGS,
GEMINI_MODEL_ALIAS_AUTO,
} from '@google/gemini-cli-core';
import type { CustomTheme } from '../ui/themes/theme.js';
import type { SessionRetentionSettings } from './settings.js';
@@ -1384,7 +1384,7 @@ const SETTINGS_SCHEMA = {
label: 'Model',
category: 'Experimental',
requiresRestart: true,
default: DEFAULT_GEMINI_MODEL,
default: GEMINI_MODEL_ALIAS_AUTO,
description:
'The model to use for the Codebase Investigator agent.',
showInDialog: false,
+14
View File
@@ -498,6 +498,20 @@ export async function main() {
// Handle --list-sessions flag
if (config.getListSessions()) {
// Attempt auth for summary generation (gracefully skips if not configured)
const authType = settings.merged.security?.auth?.selectedType;
if (authType) {
try {
await config.refreshAuth(authType);
} catch (e) {
// Auth failed - continue without summary generation capability
debugLogger.debug(
'Auth failed for --list-sessions, summaries may not be generated:',
e,
);
}
}
await listSessions(config);
await runExitCleanup();
process.exit(ExitCodes.SUCCESS);
+1
View File
@@ -91,6 +91,7 @@ const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getEnableInteractiveShell: () => true,
getPreviewFeatures: () => false,
};
const configProxy = new Proxy(mockConfig, {
+184 -57
View File
@@ -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();
});
});
});
+11 -21
View File
@@ -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,
@@ -63,7 +61,7 @@ import {
SessionEndReason,
fireSessionStartHook,
fireSessionEndHook,
generateAndSaveSummary,
generateSummary,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
@@ -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());
@@ -312,9 +305,13 @@ export const AppContainer = (props: AppContainerProps) => {
: SessionStartSource.Startup;
await fireSessionStartHook(hookMessageBus, sessionStartSource);
}
// Fire-and-forget: generate summary for previous session in background
generateSummary(config).catch((e) => {
debugLogger.warn('Background summary generation failed:', e);
});
})();
registerCleanup(async () => {
await generateAndSaveSummary(config);
// Turn off mouse scroll.
disableMouseEvents();
const ideClient = await IdeClient.getInstance();
@@ -336,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();
@@ -1427,7 +1417,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(
+14 -5
View File
@@ -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',
+2 -6
View File
@@ -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 -2
View File
@@ -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.');
});
});
});
+125 -41
View File
@@ -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}>
@@ -116,7 +116,7 @@ describe('<ModelStatsDisplay />', () => {
});
const output = lastFrame();
expect(output).not.toContain('Cached');
expect(output).not.toContain('Cache Reads');
expect(output).not.toContain('Thoughts');
expect(output).not.toContain('Tool');
expect(output).toMatchSnapshot();
@@ -168,7 +168,7 @@ describe('<ModelStatsDisplay />', () => {
});
const output = lastFrame();
expect(output).toContain('Cached');
expect(output).toContain('Cache Reads');
expect(output).toContain('Thoughts');
expect(output).toContain('Tool');
expect(output).toMatchSnapshot();
@@ -160,24 +160,28 @@ export const ModelStatsDisplay: React.FC = () => {
<StatRow
title="Total"
values={getModelValues((m) => (
<Text color={theme.status.warning}>
<Text color={theme.text.secondary}>
{m.tokens.total.toLocaleString()}
</Text>
))}
/>
<StatRow
title="Prompt"
title="Input"
isSubtle
values={getModelValues((m) => m.tokens.prompt.toLocaleString())}
values={getModelValues((m) => (
<Text color={theme.text.primary}>
{Math.max(0, m.tokens.prompt - m.tokens.cached).toLocaleString()}
</Text>
))}
/>
{hasCached && (
<StatRow
title="Cached"
title="Cache Reads"
isSubtle
values={getModelValues((m) => {
const cacheHitRate = calculateCacheHitRate(m);
return (
<Text color={theme.status.success}>
<Text color={theme.text.secondary}>
{m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%)
</Text>
);
@@ -188,20 +192,32 @@ export const ModelStatsDisplay: React.FC = () => {
<StatRow
title="Thoughts"
isSubtle
values={getModelValues((m) => m.tokens.thoughts.toLocaleString())}
values={getModelValues((m) => (
<Text color={theme.text.primary}>
{m.tokens.thoughts.toLocaleString()}
</Text>
))}
/>
)}
{hasTool && (
<StatRow
title="Tool"
isSubtle
values={getModelValues((m) => m.tokens.tool.toLocaleString())}
values={getModelValues((m) => (
<Text color={theme.text.primary}>
{m.tokens.tool.toLocaleString()}
</Text>
))}
/>
)}
<StatRow
title="Output"
isSubtle
values={getModelValues((m) => m.tokens.candidates.toLocaleString())}
values={getModelValues((m) => (
<Text color={theme.text.primary}>
{m.tokens.candidates.toLocaleString()}
</Text>
))}
/>
</Box>
);
@@ -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>
);
}
@@ -76,8 +76,6 @@ describe('<StatsDisplay />', () => {
expect(output).toContain('Performance');
expect(output).toContain('Interaction Summary');
expect(output).not.toContain('Efficiency & Optimizations');
expect(output).not.toContain('Model'); // The table header
expect(output).toMatchSnapshot();
});
@@ -114,8 +112,8 @@ describe('<StatsDisplay />', () => {
expect(output).toContain('gemini-2.5-pro');
expect(output).toContain('gemini-2.5-flash');
expect(output).toContain('1,000');
expect(output).toContain('25,000');
expect(output).toContain('15,000');
expect(output).toContain('10,000');
expect(output).toMatchSnapshot();
});
@@ -168,7 +166,6 @@ describe('<StatsDisplay />', () => {
expect(output).toContain('Performance');
expect(output).toContain('Interaction Summary');
expect(output).toContain('User Agreement');
expect(output).toContain('Savings Highlight');
expect(output).toContain('gemini-2.5-pro');
expect(output).toMatchSnapshot();
});
@@ -233,7 +230,6 @@ describe('<StatsDisplay />', () => {
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).not.toContain('Efficiency & Optimizations');
expect(output).toMatchSnapshot();
});
});
@@ -443,7 +439,7 @@ describe('<StatsDisplay />', () => {
);
const output = lastFrame();
expect(output).toContain('Usage limit remaining');
expect(output).toContain('Usage left');
expect(output).toContain('75.0%');
expect(output).toContain('(Resets in 1h 30m)');
expect(output).toMatchSnapshot();
+229 -84
View File
@@ -17,9 +17,24 @@ import {
TOOL_SUCCESS_RATE_MEDIUM,
USER_AGREEMENT_RATE_HIGH,
USER_AGREEMENT_RATE_MEDIUM,
CACHE_EFFICIENCY_HIGH,
CACHE_EFFICIENCY_MEDIUM,
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
import type { RetrieveUserQuotaResponse } from '@google/gemini-cli-core';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
} from '@google/gemini-cli-core';
const VALID_GEMINI_MODELS = new Set([
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
]);
// A more flexible and powerful StatRow component
interface StatRowProps {
@@ -33,8 +48,7 @@ const StatRow: React.FC<StatRowProps> = ({ title, children }) => (
<Box width={28}>
<Text color={theme.text.link}>{title}</Text>
</Box>
{/* FIX: Wrap children in a Box that can grow to fill remaining space */}
<Box flexGrow={1}>{children}</Box>
{children}
</Box>
);
@@ -50,8 +64,7 @@ const SubStatRow: React.FC<SubStatRowProps> = ({ title, children }) => (
<Box width={26}>
<Text color={theme.text.secondary}>» {title}</Text>
</Box>
{/* FIX: Apply the same flexGrow fix here */}
<Box flexGrow={1}>{children}</Box>
{children}
</Box>
);
@@ -70,6 +83,57 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
</Box>
);
// Logic for building the unified list of table rows
const buildModelRows = (
models: Record<string, ModelMetrics>,
quotas?: RetrieveUserQuotaResponse,
) => {
const getBaseModelName = (name: string) => name.replace('-001', '');
const usedModelNames = new Set(Object.keys(models).map(getBaseModelName));
// 1. Models with active usage
const activeRows = Object.entries(models).map(([name, metrics]) => {
const modelName = getBaseModelName(name);
const cachedTokens = metrics.tokens.cached;
const totalInputTokens = metrics.tokens.prompt;
const uncachedTokens = Math.max(0, totalInputTokens - cachedTokens);
return {
key: name,
modelName,
requests: metrics.api.totalRequests,
cachedTokens: cachedTokens.toLocaleString(),
uncachedTokens: uncachedTokens.toLocaleString(),
totalInputTokens: totalInputTokens.toLocaleString(),
outputTokens: metrics.tokens.candidates.toLocaleString(),
bucket: quotas?.buckets?.find((b) => b.modelId === modelName),
isActive: true,
};
});
// 2. Models with quota only
const quotaRows =
quotas?.buckets
?.filter(
(b) =>
b.modelId &&
VALID_GEMINI_MODELS.has(b.modelId) &&
!usedModelNames.has(b.modelId),
)
.map((bucket) => ({
key: bucket.modelId!,
modelName: bucket.modelId!,
requests: '-',
cachedTokens: '-',
uncachedTokens: '-',
totalInputTokens: '-',
outputTokens: '-',
bucket,
isActive: false,
})) || [];
return [...activeRows, ...quotaRows];
};
const formatResetTime = (resetTime: string): string => {
const diff = new Date(resetTime).getTime() - Date.now();
if (diff <= 0) return '';
@@ -96,44 +160,91 @@ const formatResetTime = (resetTime: string): string => {
const ModelUsageTable: React.FC<{
models: Record<string, ModelMetrics>;
totalCachedTokens: number;
cacheEfficiency: number;
quotas?: RetrieveUserQuotaResponse;
}> = ({ models, totalCachedTokens, cacheEfficiency, quotas }) => {
cacheEfficiency: number;
totalCachedTokens: number;
}> = ({ models, quotas, cacheEfficiency, totalCachedTokens }) => {
const rows = buildModelRows(models, quotas);
if (rows.length === 0) {
return null;
}
const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket);
const nameWidth = 25;
const requestsWidth = 8;
const inputTokensWidth = 15;
const requestsWidth = 7;
const uncachedWidth = 15;
const cachedWidth = 14;
const outputTokensWidth = 15;
const usageLimitWidth = quotas ? 30 : 0;
const usageLimitWidth = showQuotaColumn ? 28 : 0;
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
green: CACHE_EFFICIENCY_HIGH,
yellow: CACHE_EFFICIENCY_MEDIUM,
});
return (
<Box flexDirection="column" marginTop={1}>
{/* Header */}
<Box>
<Box width={nameWidth}>
<Text bold color={theme.text.primary}>
<Box alignItems="flex-end">
<Box width={nameWidth} flexGrow={1}>
<Text bold color={theme.text.primary} wrap="truncate-end">
Model Usage
</Text>
</Box>
<Box width={requestsWidth} justifyContent="flex-end">
<Box
width={requestsWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Reqs
</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text bold color={theme.text.primary}>
Input Tokens
</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text bold color={theme.text.primary}>
Output Tokens
</Text>
</Box>
{quotas && (
<Box width={usageLimitWidth} justifyContent="flex-end">
{!showQuotaColumn && (
<>
<Box
width={uncachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Input Tokens
</Text>
</Box>
<Box
width={cachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Cache Reads
</Text>
</Box>
<Box
width={outputTokensWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Output Tokens
</Text>
</Box>
</>
)}
{showQuotaColumn && (
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
>
<Text bold color={theme.text.primary}>
Usage limit remaining
Usage left
</Text>
</Box>
)}
@@ -146,63 +257,99 @@ const ModelUsageTable: React.FC<{
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
width={
nameWidth +
requestsWidth +
inputTokensWidth +
outputTokensWidth +
usageLimitWidth
}
width="100%"
></Box>
{/* Rows */}
{Object.entries(models).map(([name, modelMetrics]) => {
const modelName = name.replace('-001', '');
const bucket = quotas?.buckets?.find((b) => b.modelId === modelName);
return (
<Box key={name}>
<Box width={nameWidth}>
<Text color={theme.text.primary}>{modelName}</Text>
</Box>
<Box width={requestsWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.api.totalRequests}
</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text color={theme.status.warning}>
{modelMetrics.tokens.prompt.toLocaleString()}
</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text color={theme.status.warning}>
{modelMetrics.tokens.candidates.toLocaleString()}
</Text>
</Box>
<Box width={usageLimitWidth} justifyContent="flex-end">
{bucket &&
bucket.remainingFraction != null &&
bucket.resetTime && (
<Text color={theme.text.secondary}>
{(bucket.remainingFraction * 100).toFixed(1)}%{' '}
{formatResetTime(bucket.resetTime)}
</Text>
)}
</Box>
{rows.map((row) => (
<Box key={row.key}>
<Box width={nameWidth} flexGrow={1}>
<Text color={theme.text.primary} wrap="truncate-end">
{row.modelName}
</Text>
</Box>
);
})}
{cacheEfficiency > 0 && (
<Box
width={requestsWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text
color={row.isActive ? theme.text.primary : theme.text.secondary}
>
{row.requests}
</Text>
</Box>
{!showQuotaColumn && (
<>
<Box
width={uncachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
>
{row.uncachedTokens}
</Text>
</Box>
<Box
width={cachedWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text color={theme.text.secondary}>{row.cachedTokens}</Text>
</Box>
<Box
width={outputTokensWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
<Text
color={
row.isActive ? theme.text.primary : theme.text.secondary
}
>
{row.outputTokens}
</Text>
</Box>
</>
)}
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
>
{row.bucket &&
row.bucket.remainingFraction != null &&
row.bucket.resetTime && (
<Text color={theme.text.secondary} wrap="truncate-end">
{(row.bucket.remainingFraction * 100).toFixed(1)}%{' '}
{formatResetTime(row.bucket.resetTime)}
</Text>
)}
</Box>
</Box>
))}
{cacheEfficiency > 0 && !showQuotaColumn && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>
<Text color={theme.status.success}>Savings Highlight:</Text>{' '}
{totalCachedTokens.toLocaleString()} ({cacheEfficiency.toFixed(1)}
%) of input tokens were served from the cache, reducing costs.
{totalCachedTokens.toLocaleString()} (
<Text color={cacheEfficiencyColor}>
{cacheEfficiency.toFixed(1)}%
</Text>
) of input tokens were served from the cache, reducing costs.
</Text>
</Box>
)}
{models && (
{showQuotaColumn && (
<>
<Box marginTop={1} marginBottom={2}>
<Text color={theme.text.primary}>
@@ -266,6 +413,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
flexDirection="column"
paddingY={1}
paddingX={2}
overflow="hidden"
>
{renderTitle()}
<Box height={1} />
@@ -335,15 +483,12 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
</Text>
</SubStatRow>
</Section>
{Object.keys(models).length > 0 && (
<ModelUsageTable
models={models}
totalCachedTokens={computed.totalCachedTokens}
cacheEfficiency={computed.cacheEfficiency}
quotas={quotas}
/>
)}
<ModelUsageTable
models={models}
quotas={quotas}
cacheEfficiency={computed.cacheEfficiency}
totalCachedTokens={computed.totalCachedTokens}
/>
</Box>
);
};
@@ -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)"`;
@@ -14,8 +14,8 @@ exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
│ │
│ Tokens │
│ Total 30 │
│ ↳ Prompt 10
│ ↳ Cached 5 (50.0%) │
│ ↳ Input 5
│ ↳ Cache Reads 5 (50.0%) │
│ ↳ Thoughts 2 │
│ ↳ Tool 1 │
│ ↳ Output 20 │
@@ -37,8 +37,8 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one
│ │
│ Tokens │
│ Total 30 15 │
│ ↳ Prompt 10 5 │
│ ↳ Cached 5 (50.0%) 0 (0.0%) │
│ ↳ Input 5 5 │
│ ↳ Cache Reads 5 (50.0%) 0 (0.0%) │
│ ↳ Thoughts 2 0 │
│ ↳ Tool 0 3 │
│ ↳ Output 20 10 │
@@ -60,8 +60,8 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc
│ │
│ Tokens │
│ Total 300 600 │
│ ↳ Prompt 100 200 │
│ ↳ Cached 50 (50.0%) 100 (50.0%) │
│ ↳ Input 50 100 │
│ ↳ Cache Reads 50 (50.0%) 100 (50.0%) │
│ ↳ Thoughts 10 20 │
│ ↳ Tool 5 10 │
│ ↳ Output 200 400 │
@@ -83,8 +83,8 @@ exports[`<ModelStatsDisplay /> > should handle large values without wrapping or
│ │
│ Tokens │
│ Total 999,999,999 │
│ ↳ Prompt 987,654,321
│ ↳ Cached 123,456,789 (12.5%) │
│ ↳ Input 864,197,532 │
│ ↳ Cache Reads 123,456,789 (12.5%) │
│ ↳ Thoughts 111,111,111 │
│ ↳ Tool 222,222,222 │
│ ↳ Output 123,456,789 │
@@ -106,7 +106,7 @@ exports[`<ModelStatsDisplay /> > should not display conditional rows if no model
│ │
│ Tokens │
│ Total 30 │
│ ↳ Prompt 10 │
│ ↳ Input 10 │
│ ↳ Output 20 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -18,17 +18,11 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens
│ ───────────────────────────────────────────────────────────────
│ gemini-2.5-pro 10 1,000 2,000
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 10 500 500 2,000
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -118,15 +118,9 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens
│ ───────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 100 100
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 100 0 100
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -168,11 +162,9 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens Usage limit remaining
│ ─────────────────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 100 100 75.0% (Resets in 1h 30m)
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ Model Usage Reqs Usage left
│ ─────────────────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 75.0% (Resets in 1h 30m) │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
@@ -240,19 +232,13 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens
│ ───────────────────────────────────────────────────────────────
│ gemini-2.5-pro 3 1,000 2,000
│ gemini-2.5-flash 5 25,000 15,000
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 3 500 500 2,000
│ gemini-2.5-flash 5 15,000 10,000 15,000
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -274,18 +260,12 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
│ » Tool Time: 123ms (55.2%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens
│ ───────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 100 100
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 50 50 100
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -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 {
+1
View File
@@ -21,6 +21,7 @@ vi.mock('@google/gemini-cli-core', async () => {
return {
...actual,
ChatRecordingService: vi.fn(),
generateSummary: vi.fn().mockResolvedValue(undefined),
};
});
+8 -1
View File
@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { ChatRecordingService, type Config } from '@google/gemini-cli-core';
import {
ChatRecordingService,
generateSummary,
type Config,
} from '@google/gemini-cli-core';
import {
formatRelativeTime,
SessionSelector,
@@ -12,6 +16,9 @@ import {
} from './sessionUtils.js';
export async function listSessions(config: Config): Promise<void> {
// Generate summary for most recent session if needed
await generateSummary(config);
const sessionSelector = new SessionSelector(config);
const sessions = await sessionSelector.listSessions();
@@ -256,7 +256,6 @@ export class Session {
try {
const model = getEffectiveModel(
this.config.isInFallbackMode(),
this.config.getModel(),
this.config.getPreviewFeatures(),
);
-3
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
"version": "0.21.2",
"description": "Gemini CLI Core",
"repository": {
"type": "git",
+70
View File
@@ -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 {
@@ -72,6 +79,69 @@ describe('AgentRegistry', () => {
`[AgentRegistry] Initialized with ${agentCount} agents.`,
);
});
it('should use preview flash model for codebase investigator if main model is preview pro', async () => {
const previewConfig = makeFakeConfig({
model: PREVIEW_GEMINI_MODEL,
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 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,
);
});
});
describe('registration logic', () => {
+25 -3
View File
@@ -9,7 +9,14 @@ import type { AgentDefinition } from './types.js';
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
import { type z } from 'zod';
import { debugLogger } from '../utils/debugLogger.js';
import {
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.
@@ -36,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.`,
@@ -48,13 +59,24 @@ export class AgentRegistry {
// Only register the agent if it's enabled in the settings.
if (investigatorSettings?.enabled) {
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 = {
...CodebaseInvestigatorAgent,
modelConfig: {
...CodebaseInvestigatorAgent.modelConfig,
model:
investigatorSettings.model ??
CodebaseInvestigatorAgent.modelConfig.model,
model,
thinkingBudget:
investigatorSettings.thinkingBudget ??
CodebaseInvestigatorAgent.modelConfig.thinkingBudget,
@@ -123,6 +123,10 @@ export class ModelAvailabilityService {
}
}
reset() {
this.health.clear();
}
private setState(model: ModelId, nextState: HealthState) {
this.health.set(model, nextState);
}
@@ -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']);
});
+43 -20
View File
@@ -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,
};
}
+187 -4
View File
@@ -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')>();
@@ -173,7 +179,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',
@@ -351,6 +357,21 @@ describe('Server Config (config.ts)', () => {
expect(config.isInFallbackMode()).toBe(false);
});
it('should reset model availability status', async () => {
const config = new Config(baseParams);
const service = config.getModelAvailabilityService();
const spy = vi.spyOn(service, 'reset');
vi.mocked(createContentGeneratorConfig).mockImplementation(
async (_: Config, authType: AuthType | undefined) =>
({ authType }) as unknown as ContentGeneratorConfig,
);
await config.refreshAuth(AuthType.USE_GEMINI);
expect(spy).toHaveBeenCalled();
});
it('should strip thoughts when switching from GenAI to Vertex', async () => {
const config = new Config(baseParams);
@@ -750,6 +771,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);
@@ -888,6 +943,11 @@ describe('Server Config (config.ts)', () => {
expect(SubagentToolWrapperMock).not.toHaveBeenCalled();
});
it('should not set default codebase investigator model in config (defaults in registry)', () => {
const config = new Config(baseParams);
expect(config.getCodebaseInvestigatorSettings()?.model).toBeUndefined();
});
describe('with minified tool class names', () => {
beforeEach(() => {
Object.defineProperty(
@@ -1483,6 +1543,9 @@ describe('Config getHooks', () => {
describe('setModel', () => {
it('should allow setting a pro (any) model and disable fallback mode', () => {
const config = new Config(baseParams);
const service = config.getModelAvailabilityService();
const spy = vi.spyOn(service, 'reset');
config.setFallbackMode(true);
expect(config.isInFallbackMode()).toBe(true);
@@ -1492,10 +1555,14 @@ describe('Config getHooks', () => {
expect(config.getModel()).toBe(proModel);
expect(config.isInFallbackMode()).toBe(false);
expect(mockCoreEvents.emitModelChanged).toHaveBeenCalledWith(proModel);
expect(spy).toHaveBeenCalled();
});
it('should allow setting auto model from non-auto model and disable fallback mode', () => {
const config = new Config(baseParams);
const service = config.getModelAvailabilityService();
const spy = vi.spyOn(service, 'reset');
config.setFallbackMode(true);
expect(config.isInFallbackMode()).toBe(true);
@@ -1504,6 +1571,7 @@ describe('Config getHooks', () => {
expect(config.getModel()).toBe('auto');
expect(config.isInFallbackMode()).toBe(false);
expect(mockCoreEvents.emitModelChanged).toHaveBeenCalledWith('auto');
expect(spy).toHaveBeenCalled();
});
it('should allow setting auto model from auto model if it is in the fallback mode', () => {
@@ -1515,6 +1583,9 @@ describe('Config getHooks', () => {
model: 'auto',
usageStatisticsEnabled: false,
});
const service = config.getModelAvailabilityService();
const spy = vi.spyOn(service, 'reset');
config.setFallbackMode(true);
expect(config.isInFallbackMode()).toBe(true);
@@ -1523,6 +1594,7 @@ describe('Config getHooks', () => {
expect(config.getModel()).toBe('auto');
expect(config.isInFallbackMode()).toBe(false);
expect(mockCoreEvents.emitModelChanged).toHaveBeenCalledWith('auto');
expect(spy).toHaveBeenCalled();
});
});
});
@@ -1643,18 +1715,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', () => {
@@ -1671,3 +1741,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);
});
});
});
+79 -9
View File
@@ -48,8 +48,11 @@ import { tokenLimit } from '../core/tokenLimits.js';
import {
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_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';
@@ -81,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';
@@ -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;
@@ -508,8 +513,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 +555,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
@@ -573,7 +580,7 @@ export class Config {
thinkingBudget:
params.codebaseInvestigatorSettings?.thinkingBudget ??
DEFAULT_THINKING_MODE,
model: params.codebaseInvestigatorSettings?.model ?? DEFAULT_GEMINI_MODEL,
model: params.codebaseInvestigatorSettings?.model,
};
this.continueOnFailedApiCall = params.continueOnFailedApiCall ?? true;
this.enableShellOutputEfficiency =
@@ -695,6 +702,9 @@ export class Config {
}
async refreshAuth(authMethod: AuthType) {
// Reset availability service when switching auth
this.modelAvailabilityService.reset();
// Vertex and Genai have incompatible encryption and sending history with
// thoughtSignature from Genai to Vertex will fail, we need to strip them
if (
@@ -705,6 +715,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,
@@ -720,16 +733,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) {
@@ -745,8 +760,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> {
@@ -820,6 +848,7 @@ export class Config {
coreEvents.emitModelChanged(newModel);
}
this.setFallbackMode(false);
this.modelAvailabilityService.reset();
}
getActiveModel(): string {
@@ -829,7 +858,6 @@ export class Config {
setActiveModel(model: string): void {
if (this._activeModel !== model) {
this._activeModel = model;
coreEvents.emitModelChanged(model);
}
}
@@ -940,7 +968,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 {
@@ -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',
+86 -155
View File
@@ -7,6 +7,8 @@
import { describe, it, expect } from 'vitest';
import {
getEffectiveModel,
resolveClassifierModel,
isGemini2Model,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
@@ -14,213 +16,142 @@ import {
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL_AUTO,
} from './models.js';
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);
});
});
+89 -24
View File
@@ -5,13 +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 DEFAULT_GEMINI_MODEL_AUTO = 'auto';
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 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';
@@ -31,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;
@@ -53,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
);
}
/**
@@ -19,11 +19,10 @@ exports[`Core System Prompt (prompts.ts) > should append userMemory with separat
## 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.
@@ -121,11 +120,10 @@ exports[`Core System Prompt (prompts.ts) > should handle git instructions when i
## 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.
@@ -218,11 +216,10 @@ exports[`Core System Prompt (prompts.ts) > should handle git instructions when i
## 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.
@@ -330,11 +327,10 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru
## 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.
@@ -427,11 +423,10 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru
## 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.
@@ -524,11 +519,10 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru
## 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.
@@ -621,11 +615,10 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when us
## 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.
@@ -718,11 +711,10 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when us
## 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.
@@ -815,11 +807,10 @@ exports[`Core System Prompt (prompts.ts) > should return the interactive avoidan
## 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.
@@ -913,11 +904,10 @@ exports[`Core System Prompt (prompts.ts) > should use chatty system prompt for p
## 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.
+9
View File
@@ -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) {
+10 -3
View File
@@ -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,
+4 -10
View File
@@ -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(
+54 -278
View File
@@ -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, '', [], []);
+25 -36
View File
@@ -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;
}
@@ -394,7 +390,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);
@@ -441,11 +437,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({
@@ -459,24 +468,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;
@@ -504,10 +495,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();
@@ -595,8 +585,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'),
+17 -8
View File
@@ -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,21 +66,29 @@ 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),
} 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 '],
@@ -160,6 +168,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),
} as unknown as Config;
+7 -9
View File
@@ -24,7 +24,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;
@@ -105,13 +105,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
? `
@@ -158,7 +157,7 @@ export function getCoreSystemPrompt(
## 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.`,
@@ -185,8 +184,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.
@@ -413,7 +411,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>
+172 -480
View File
@@ -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();
});
});
});
+67 -176
View File
@@ -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.
}
}
+31
View File
@@ -59,6 +59,7 @@ describe('IdeClient', () => {
delete process.env['GEMINI_CLI_IDE_SERVER_PORT'];
delete process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'];
delete process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'];
delete process.env['GEMINI_CLI_IDE_AUTH_TOKEN'];
// Mock dependencies
vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir');
@@ -923,5 +924,35 @@ describe('IdeClient', () => {
IDEConnectionStatus.Connected,
);
});
it('should connect with an auth token from environment variable if config file is missing', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(
new Error('File not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090';
process.env['GEMINI_CLI_IDE_AUTH_TOKEN'] = 'env-auth-token';
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://127.0.0.1:9090/mcp'),
expect.objectContaining({
requestInit: {
headers: {
Authorization: 'Bearer env-auth-token',
},
},
}),
);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
});
});
});
+4 -3
View File
@@ -151,9 +151,10 @@ export class IdeClient {
this.setState(IDEConnectionStatus.Connecting);
this.connectionConfig = await this.getConnectionConfigFromFile();
if (this.connectionConfig?.authToken) {
this.authToken = this.connectionConfig.authToken;
}
this.authToken =
this.connectionConfig?.authToken ??
process.env['GEMINI_CLI_IDE_AUTH_TOKEN'];
const workspacePath =
this.connectionConfig?.workspacePath ??
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
@@ -54,3 +54,8 @@ priority = 50
toolName = "google_web_search"
decision = "allow"
priority = 50
[[rule]]
toolName = "SubagentInvocation"
decision = "allow"
priority = 50
@@ -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:
@@ -5,11 +5,15 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { generateAndSaveSummary } from './sessionSummaryUtils.js';
import { generateSummary, getPreviousSession } from './sessionSummaryUtils.js';
import type { Config } from '../config/config.js';
import type { ChatRecordingService } from './chatRecordingService.js';
import type { ContentGenerator } from '../core/contentGenerator.js';
import type { GeminiClient } from '../core/client.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
// Mock fs/promises
vi.mock('node:fs/promises');
const mockReaddir = fs.readdir as unknown as ReturnType<typeof vi.fn>;
// Mock the SessionSummaryService module
vi.mock('./sessionSummaryService.js', () => ({
@@ -23,10 +27,24 @@ vi.mock('../core/baseLlmClient.js', () => ({
BaseLlmClient: vi.fn(),
}));
// Helper to create a session with N user messages
function createSessionWithUserMessages(
count: number,
options: { summary?: string; sessionId?: string } = {},
) {
return JSON.stringify({
sessionId: options.sessionId ?? 'session-id',
summary: options.summary,
messages: Array.from({ length: count }, (_, i) => ({
id: String(i + 1),
type: 'user',
content: [{ text: `Message ${i + 1}` }],
})),
});
}
describe('sessionSummaryUtils', () => {
let mockConfig: Config;
let mockChatRecordingService: ChatRecordingService;
let mockGeminiClient: GeminiClient;
let mockContentGenerator: ContentGenerator;
let mockGenerateSummary: ReturnType<typeof vi.fn>;
@@ -36,23 +54,12 @@ describe('sessionSummaryUtils', () => {
// Setup mock content generator
mockContentGenerator = {} as ContentGenerator;
// Setup mock chat recording service
mockChatRecordingService = {
getConversation: vi.fn(),
saveSummary: vi.fn(),
} as unknown as ChatRecordingService;
// Setup mock gemini client
mockGeminiClient = {
getChatRecordingService: vi
.fn()
.mockReturnValue(mockChatRecordingService),
} as unknown as GeminiClient;
// Setup mock config
mockConfig = {
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
} as unknown as Config;
// Setup mock generateSummary function
@@ -73,320 +80,138 @@ describe('sessionSummaryUtils', () => {
vi.restoreAllMocks();
});
describe('Integration Tests', () => {
it('should generate and save summary successfully', async () => {
const mockConversation = {
sessionId: 'test-session',
projectHash: 'test-hash',
startTime: '2025-12-03T00:00:00Z',
lastUpdated: '2025-12-03T00:10:00Z',
messages: [
{
id: '1',
timestamp: '2025-12-03T00:00:00Z',
type: 'user' as const,
content: [{ text: 'How do I add dark mode?' }],
},
{
id: '2',
timestamp: '2025-12-03T00:01:00Z',
type: 'gemini' as const,
content: [{ text: 'To add dark mode...' }],
},
],
};
describe('getPreviousSession', () => {
it('should return null if chats directory does not exist', async () => {
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
(
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
).mockReturnValue(mockConversation);
const result = await getPreviousSession(mockConfig);
await generateAndSaveSummary(mockConfig);
expect(result).toBeNull();
});
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
expect(mockGenerateSummary).toHaveBeenCalledWith({
messages: mockConversation.messages,
});
expect(mockChatRecordingService.saveSummary).toHaveBeenCalledTimes(1);
expect(mockChatRecordingService.saveSummary).toHaveBeenCalledWith(
'Add dark mode to the app',
it('should return null if no session files exist', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
mockReaddir.mockResolvedValue([]);
const result = await getPreviousSession(mockConfig);
expect(result).toBeNull();
});
it('should return null if most recent session already has summary', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
vi.mocked(fs.readFile).mockResolvedValue(
createSessionWithUserMessages(5, { summary: 'Existing summary' }),
);
const result = await getPreviousSession(mockConfig);
expect(result).toBeNull();
});
it('should return null if most recent session has 1 or fewer user messages', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
vi.mocked(fs.readFile).mockResolvedValue(
createSessionWithUserMessages(1),
);
const result = await getPreviousSession(mockConfig);
expect(result).toBeNull();
});
it('should return path if most recent session has more than 1 user message and no summary', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
vi.mocked(fs.readFile).mockResolvedValue(
createSessionWithUserMessages(2),
);
const result = await getPreviousSession(mockConfig);
expect(result).toBe(
path.join(
'/tmp/project',
'chats',
'session-2024-01-01T10-00-abc12345.json',
),
);
});
it('should skip if no chat recording service is available', async () => {
(
mockGeminiClient.getChatRecordingService as ReturnType<typeof vi.fn>
).mockReturnValue(undefined);
it('should select most recently created session by filename', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
mockReaddir.mockResolvedValue([
'session-2024-01-01T10-00-older000.json',
'session-2024-01-02T10-00-newer000.json',
]);
vi.mocked(fs.readFile).mockResolvedValue(
createSessionWithUserMessages(2),
);
await generateAndSaveSummary(mockConfig);
const result = await getPreviousSession(mockConfig);
expect(mockGeminiClient.getChatRecordingService).toHaveBeenCalledTimes(1);
expect(mockGenerateSummary).not.toHaveBeenCalled();
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
expect(result).toBe(
path.join(
'/tmp/project',
'chats',
'session-2024-01-02T10-00-newer000.json',
),
);
});
it('should skip if no conversation exists', async () => {
(
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
).mockReturnValue(null);
it('should return null if most recent session file is corrupted', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
vi.mocked(fs.readFile).mockResolvedValue('invalid json');
await generateAndSaveSummary(mockConfig);
const result = await getPreviousSession(mockConfig);
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
expect(mockGenerateSummary).not.toHaveBeenCalled();
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
});
it('should skip if summary already exists', async () => {
const mockConversation = {
sessionId: 'test-session',
projectHash: 'test-hash',
startTime: '2025-12-03T00:00:00Z',
lastUpdated: '2025-12-03T00:10:00Z',
summary: 'Existing summary',
messages: [
{
id: '1',
timestamp: '2025-12-03T00:00:00Z',
type: 'user' as const,
content: [{ text: 'Hello' }],
},
],
};
(
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
).mockReturnValue(mockConversation);
await generateAndSaveSummary(mockConfig);
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
expect(mockGenerateSummary).not.toHaveBeenCalled();
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
});
it('should skip if no messages present', async () => {
const mockConversation = {
sessionId: 'test-session',
projectHash: 'test-hash',
startTime: '2025-12-03T00:00:00Z',
lastUpdated: '2025-12-03T00:10:00Z',
messages: [],
};
(
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
).mockReturnValue(mockConversation);
await generateAndSaveSummary(mockConfig);
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
expect(mockGenerateSummary).not.toHaveBeenCalled();
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
});
it('should handle generateSummary failure gracefully', async () => {
const mockConversation = {
sessionId: 'test-session',
projectHash: 'test-hash',
startTime: '2025-12-03T00:00:00Z',
lastUpdated: '2025-12-03T00:10:00Z',
messages: [
{
id: '1',
timestamp: '2025-12-03T00:00:00Z',
type: 'user' as const,
content: [{ text: 'Hello' }],
},
],
};
(
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
).mockReturnValue(mockConversation);
mockGenerateSummary.mockResolvedValue(null);
await generateAndSaveSummary(mockConfig);
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
});
it('should catch and log errors without throwing', async () => {
const mockConversation = {
sessionId: 'test-session',
projectHash: 'test-hash',
startTime: '2025-12-03T00:00:00Z',
lastUpdated: '2025-12-03T00:10:00Z',
messages: [
{
id: '1',
timestamp: '2025-12-03T00:00:00Z',
type: 'user' as const,
content: [{ text: 'Hello' }],
},
],
};
(
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
).mockReturnValue(mockConversation);
mockGenerateSummary.mockRejectedValue(new Error('API Error'));
// Should not throw
await expect(generateAndSaveSummary(mockConfig)).resolves.not.toThrow();
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('Mock Verification Tests', () => {
it('should call getConversation() once', async () => {
const mockConversation = {
sessionId: 'test-session',
projectHash: 'test-hash',
startTime: '2025-12-03T00:00:00Z',
lastUpdated: '2025-12-03T00:10:00Z',
messages: [
{
id: '1',
timestamp: '2025-12-03T00:00:00Z',
type: 'user' as const,
content: [{ text: 'Hello' }],
},
],
};
describe('generateSummary', () => {
it('should not throw if getPreviousSession returns null', async () => {
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
(
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
).mockReturnValue(mockConversation);
await generateAndSaveSummary(mockConfig);
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
expect(mockChatRecordingService.getConversation).toHaveBeenCalledWith();
await expect(generateSummary(mockConfig)).resolves.not.toThrow();
});
it('should call generateSummary() with correct messages', async () => {
const mockMessages = [
{
id: '1',
timestamp: '2025-12-03T00:00:00Z',
type: 'user' as const,
content: [{ text: 'How do I add dark mode?' }],
},
{
id: '2',
timestamp: '2025-12-03T00:01:00Z',
type: 'gemini' as const,
content: [{ text: 'To add dark mode...' }],
},
];
it('should generate and save summary for session needing one', async () => {
const sessionPath = path.join(
'/tmp/project',
'chats',
'session-2024-01-01T10-00-abc12345.json',
);
const mockConversation = {
sessionId: 'test-session',
projectHash: 'test-hash',
startTime: '2025-12-03T00:00:00Z',
lastUpdated: '2025-12-03T00:10:00Z',
messages: mockMessages,
};
vi.mocked(fs.access).mockResolvedValue(undefined);
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
vi.mocked(fs.readFile).mockResolvedValue(
createSessionWithUserMessages(2),
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
(
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
).mockReturnValue(mockConversation);
await generateAndSaveSummary(mockConfig);
await generateSummary(mockConfig);
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
expect(mockGenerateSummary).toHaveBeenCalledWith({
messages: mockMessages,
});
});
it('should call saveSummary() with generated summary', async () => {
const mockConversation = {
sessionId: 'test-session',
projectHash: 'test-hash',
startTime: '2025-12-03T00:00:00Z',
lastUpdated: '2025-12-03T00:10:00Z',
messages: [
{
id: '1',
timestamp: '2025-12-03T00:00:00Z',
type: 'user' as const,
content: [{ text: 'Hello' }],
},
],
};
(
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
).mockReturnValue(mockConversation);
mockGenerateSummary.mockResolvedValue('Test summary');
await generateAndSaveSummary(mockConfig);
expect(mockChatRecordingService.saveSummary).toHaveBeenCalledTimes(1);
expect(mockChatRecordingService.saveSummary).toHaveBeenCalledWith(
'Test summary',
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(fs.writeFile).toHaveBeenCalledWith(
sessionPath,
expect.stringContaining('Add dark mode to the app'),
);
});
it('should not call saveSummary() if generation fails', async () => {
const mockConversation = {
sessionId: 'test-session',
projectHash: 'test-hash',
startTime: '2025-12-03T00:00:00Z',
lastUpdated: '2025-12-03T00:10:00Z',
messages: [
{
id: '1',
timestamp: '2025-12-03T00:00:00Z',
type: 'user' as const,
content: [{ text: 'Hello' }],
},
],
};
it('should handle errors gracefully without throwing', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
vi.mocked(fs.readFile).mockResolvedValue(
createSessionWithUserMessages(2),
);
mockGenerateSummary.mockRejectedValue(new Error('API Error'));
(
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
).mockReturnValue(mockConversation);
mockGenerateSummary.mockResolvedValue(null);
await generateAndSaveSummary(mockConfig);
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
});
it('should not call saveSummary() if generateSummary throws', async () => {
const mockConversation = {
sessionId: 'test-session',
projectHash: 'test-hash',
startTime: '2025-12-03T00:00:00Z',
lastUpdated: '2025-12-03T00:10:00Z',
messages: [
{
id: '1',
timestamp: '2025-12-03T00:00:00Z',
type: 'user' as const,
content: [{ text: 'Hello' }],
},
],
};
(
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
).mockReturnValue(mockConversation);
mockGenerateSummary.mockRejectedValue(new Error('Generation failed'));
await generateAndSaveSummary(mockConfig);
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
await expect(generateSummary(mockConfig)).resolves.not.toThrow();
});
});
});
+152 -42
View File
@@ -8,62 +8,172 @@ import type { Config } from '../config/config.js';
import { SessionSummaryService } from './sessionSummaryService.js';
import { BaseLlmClient } from '../core/baseLlmClient.js';
import { debugLogger } from '../utils/debugLogger.js';
import {
SESSION_FILE_PREFIX,
type ConversationRecord,
} from './chatRecordingService.js';
import fs from 'node:fs/promises';
import path from 'node:path';
const MIN_MESSAGES_FOR_SUMMARY = 1;
/**
* Generates and saves a summary for the current session.
* This is called during session cleanup and is non-blocking - errors are logged but don't prevent exit.
* Generates and saves a summary for a session file.
*/
export async function generateAndSaveSummary(config: Config): Promise<void> {
async function generateAndSaveSummary(
config: Config,
sessionPath: string,
): Promise<void> {
// Read session file
const content = await fs.readFile(sessionPath, 'utf-8');
const conversation: ConversationRecord = JSON.parse(content);
// Skip if summary already exists
if (conversation.summary) {
debugLogger.debug(
`[SessionSummary] Summary already exists for ${sessionPath}, skipping`,
);
return;
}
// Skip if no messages
if (conversation.messages.length === 0) {
debugLogger.debug(
`[SessionSummary] No messages to summarize in ${sessionPath}`,
);
return;
}
// Create summary service
const contentGenerator = config.getContentGenerator();
if (!contentGenerator) {
debugLogger.debug(
'[SessionSummary] Content generator not available, skipping summary generation',
);
return;
}
const baseLlmClient = new BaseLlmClient(contentGenerator, config);
const summaryService = new SessionSummaryService(baseLlmClient);
// Generate summary
const summary = await summaryService.generateSummary({
messages: conversation.messages,
});
if (!summary) {
debugLogger.warn(
`[SessionSummary] Failed to generate summary for ${sessionPath}`,
);
return;
}
// Re-read the file before writing to handle race conditions
const freshContent = await fs.readFile(sessionPath, 'utf-8');
const freshConversation: ConversationRecord = JSON.parse(freshContent);
// Check if summary was added by another process
if (freshConversation.summary) {
debugLogger.debug(
`[SessionSummary] Summary was added by another process for ${sessionPath}`,
);
return;
}
// Add summary and write back
freshConversation.summary = summary;
freshConversation.lastUpdated = new Date().toISOString();
await fs.writeFile(sessionPath, JSON.stringify(freshConversation, null, 2));
debugLogger.debug(
`[SessionSummary] Saved summary for ${sessionPath}: "${summary}"`,
);
}
/**
* Finds the most recently created session that needs a summary.
* Returns the path if it needs a summary, null otherwise.
*/
export async function getPreviousSession(
config: Config,
): Promise<string | null> {
try {
// Get the chat recording service from config
const chatRecordingService = config
.getGeminiClient()
?.getChatRecordingService();
if (!chatRecordingService) {
debugLogger.debug('[SessionSummary] No chat recording service available');
return;
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
// Check if chats directory exists
try {
await fs.access(chatsDir);
} catch {
debugLogger.debug('[SessionSummary] No chats directory found');
return null;
}
// Get the current conversation
const conversation = chatRecordingService.getConversation();
if (!conversation) {
debugLogger.debug('[SessionSummary] No conversation to summarize');
return;
// List session files
const allFiles = await fs.readdir(chatsDir);
const sessionFiles = allFiles.filter(
(f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'),
);
if (sessionFiles.length === 0) {
debugLogger.debug('[SessionSummary] No session files found');
return null;
}
// Skip if summary already exists (e.g., resumed session)
if (conversation.summary) {
debugLogger.debug('[SessionSummary] Summary already exists, skipping');
return;
// Sort by filename descending (most recently created first)
// Filename format: session-YYYY-MM-DDTHH-MM-XXXXXXXX.json
sessionFiles.sort((a, b) => b.localeCompare(a));
// Check the most recently created session
const mostRecentFile = sessionFiles[0];
const filePath = path.join(chatsDir, mostRecentFile);
try {
const content = await fs.readFile(filePath, 'utf-8');
const conversation: ConversationRecord = JSON.parse(content);
if (conversation.summary) {
debugLogger.debug(
'[SessionSummary] Most recent session already has summary',
);
return null;
}
// Only generate summaries for sessions with more than 1 user message
const userMessageCount = conversation.messages.filter(
(m) => m.type === 'user',
).length;
if (userMessageCount <= MIN_MESSAGES_FOR_SUMMARY) {
debugLogger.debug(
`[SessionSummary] Most recent session has ${userMessageCount} user message(s), skipping (need more than ${MIN_MESSAGES_FOR_SUMMARY})`,
);
return null;
}
return filePath;
} catch {
debugLogger.debug('[SessionSummary] Could not read most recent session');
return null;
}
} catch (error) {
debugLogger.debug(
`[SessionSummary] Error finding previous session: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
}
// Skip if no messages
if (conversation.messages.length === 0) {
debugLogger.debug('[SessionSummary] No messages to summarize');
return;
}
// Create summary service
const contentGenerator = config.getContentGenerator();
const baseLlmClient = new BaseLlmClient(contentGenerator, config);
const summaryService = new SessionSummaryService(baseLlmClient);
// Generate summary
const summary = await summaryService.generateSummary({
messages: conversation.messages,
});
// Save summary if generated successfully
if (summary) {
chatRecordingService.saveSummary(summary);
debugLogger.debug(`[SessionSummary] Saved summary: "${summary}"`);
} else {
debugLogger.warn('[SessionSummary] Failed to generate summary');
/**
* Generates summary for the previous session if it lacks one.
* This is designed to be called fire-and-forget on startup.
*/
export async function generateSummary(config: Config): Promise<void> {
try {
const sessionPath = await getPreviousSession(config);
if (sessionPath) {
await generateAndSaveSummary(config, sessionPath);
}
} catch (error) {
// Log but don't throw - we want graceful degradation
debugLogger.warn(
`[SessionSummary] Error in generateAndSaveSummary: ${error instanceof Error ? error.message : String(error)}`,
`[SessionSummary] Error generating summary: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
@@ -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": {}
@@ -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);
});
+11 -2
View File
@@ -94,7 +94,12 @@ describe('parseGoogleApiError', () => {
},
},
};
expect(parseGoogleApiError(mockError)).toBeNull();
expect(parseGoogleApiError(mockError)).toEqual({
code: 400,
message: 'Bad Request',
details: [],
});
});
it('should return null if there are no valid details', () => {
@@ -115,7 +120,11 @@ describe('parseGoogleApiError', () => {
},
},
};
expect(parseGoogleApiError(mockError)).toBeNull();
expect(parseGoogleApiError(mockError)).toEqual({
code: 400,
message: 'Bad Request',
details: [],
});
});
it('should parse a doubly nested error in the message', () => {
+32 -20
View File
@@ -190,32 +190,32 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
const message = currentError.message;
const errorDetails = currentError.details;
if (Array.isArray(errorDetails) && code && message) {
if (code && message) {
const details: GoogleApiErrorDetail[] = [];
for (const detail of errorDetails) {
if (detail && typeof detail === 'object') {
const detailObj = detail as Record<string, unknown>;
const typeKey = Object.keys(detailObj).find(
(key) => key.trim() === '@type',
);
if (typeKey) {
if (typeKey !== '@type') {
detailObj['@type'] = detailObj[typeKey];
delete detailObj[typeKey];
if (Array.isArray(errorDetails)) {
for (const detail of errorDetails) {
if (detail && typeof detail === 'object') {
const detailObj = detail as Record<string, unknown>;
const typeKey = Object.keys(detailObj).find(
(key) => key.trim() === '@type',
);
if (typeKey) {
if (typeKey !== '@type') {
detailObj['@type'] = detailObj[typeKey];
delete detailObj[typeKey];
}
// We can just cast it; the consumer will have to switch on @type
details.push(detailObj as unknown as GoogleApiErrorDetail);
}
// We can just cast it; the consumer will have to switch on @type
details.push(detailObj as unknown as GoogleApiErrorDetail);
}
}
}
if (details.length > 0) {
return {
code,
message,
details,
};
}
return {
code,
message,
details,
};
}
return null;
@@ -288,6 +288,18 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
data = JSON.parse(data);
} catch (_) {
// Not a JSON string, can't parse.
// Try one more fallback: look for the first '{' and last '}'
if (typeof data === 'string') {
const firstBrace = data.indexOf('{');
const lastBrace = data.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
try {
data = JSON.parse(data.substring(firstBrace, lastBrace + 1));
} catch (__) {
// Still failed
}
}
}
}
}
@@ -12,6 +12,7 @@ import {
} from './googleQuotaErrors.js';
import * as errorParser from './googleErrors.js';
import type { GoogleApiError } from './googleErrors.js';
import { ModelNotFoundError } from './httpErrors.js';
describe('classifyGoogleError', () => {
afterEach(() => {
@@ -341,4 +342,51 @@ describe('classifyGoogleError', () => {
const result = classifyGoogleError(originalError);
expect(result).toBe(originalError);
});
it('should classify nested JSON string 404 error as ModelNotFoundError', () => {
// Mimic the double-wrapped JSON structure seen in the user report
const innerError = {
error: {
code: 404,
message:
'models/NOT_FOUND is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.',
status: 'NOT_FOUND',
},
};
const errorString = JSON.stringify(innerError);
const outerErrorString = JSON.stringify({
error: {
message: errorString,
},
});
const error = new Error(`[API Error: ${outerErrorString}]`);
const classified = classifyGoogleError(error);
expect(classified).toBeInstanceOf(ModelNotFoundError);
expect((classified as ModelNotFoundError).code).toBe(404);
});
it('should fallback to string parsing for retry delays when details array is empty', () => {
const errorWithEmptyDetails = {
error: {
code: 429,
message: 'Resource exhausted. Please retry in 5s',
details: [],
},
};
const result = classifyGoogleError(errorWithEmptyDetails);
expect(result).toBeInstanceOf(RetryableQuotaError);
if (result instanceof RetryableQuotaError) {
expect(result.retryDelayMs).toBe(5000);
// The cause should be the parsed GoogleApiError
expect(result.cause).toEqual({
code: 429,
message: 'Resource exhausted. Please retry in 5s',
details: [],
});
}
});
});
+9 -3
View File
@@ -89,9 +89,15 @@ export function classifyGoogleError(error: unknown): unknown {
return new ModelNotFoundError(message, status);
}
if (!googleApiError || googleApiError.code !== 429) {
if (
!googleApiError ||
googleApiError.code !== 429 ||
googleApiError.details.length === 0
) {
// Fallback: try to parse the error message for a retry delay
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage =
googleApiError?.message ||
(error instanceof Error ? error.message : String(error));
const match = errorMessage.match(/Please retry in ([0-9.]+(?:ms|s))/);
if (match?.[1]) {
const retryDelaySeconds = parseDurationInSeconds(match[1]);
@@ -108,7 +114,7 @@ export function classifyGoogleError(error: unknown): unknown {
}
}
return error; // Not a 429 error we can handle.
return error; // Not a 429 error we can handle with structured details or a parsable retry message.
}
const quotaFailure = googleApiError.details.find(
+7 -39
View File
@@ -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();
});
});
});
+2 -17
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
"version": "0.21.2",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -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.20251209.ec9a8c7a7",
"version": "0.21.2",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
File diff suppressed because one or more lines are too long