mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d94256551b | |||
| d0cdeda005 | |||
| 72e2072e7c | |||
| 97eab144bc | |||
| f69619e2ec | |||
| 4bdf85f301 | |||
| b27945f904 | |||
| 86a777865f | |||
| 4292c8784f | |||
| ae5068b8cb | |||
| d591140f62 | |||
| d4f1da3934 | |||
| 0188599642 | |||
| 4cee7e83c4 | |||
| 56c3daf2f5 | |||
| af94beea11 | |||
| 4b3d858f31 | |||
| 17bf02b901 | |||
| c3f6e7132b | |||
| a8e3928dd2 | |||
| faf69f2c99 | |||
| 6d13e0b3a2 | |||
| 9b571d42b1 | |||
| 79fa7dcb6d | |||
| 1f1bbc462f | |||
| 32f536b76b | |||
| 5977ce8d62 | |||
| f47b44e70d | |||
| dcf2317adf | |||
| 61c7c95374 | |||
| ccee6a71df | |||
| 73add0fda7 | |||
| cc5c6bd90a | |||
| d9e79331ef | |||
| 68b7714d9b | |||
| 04dece3e3c | |||
| 2a3513bcdb |
+30
-30
@@ -1,12 +1,13 @@
|
||||
# Gemini CLI model selection (`/model` command)
|
||||
|
||||
Select your Gemini CLI model. The `/model` command opens a dialog where you can
|
||||
configure the model used by Gemini CLI, giving you more control over your
|
||||
results.
|
||||
Select your Gemini CLI model. The `/model` command lets you configure the model
|
||||
used by Gemini CLI, giving you more control over your results. Use **Pro**
|
||||
models for complex tasks and reasoning, **Flash** models for high speed results,
|
||||
or the (recommended) **Auto** setting to choose the best model for your tasks.
|
||||
|
||||
**Note:** The `/model` command (and the `--model` flag) does not override the
|
||||
model used by sub-agents. Consequently, even when using the `/model` flag you
|
||||
may see other models used in your model usage reports.
|
||||
> **Note:** The `/model` command (and the `--model` flag) does not override the
|
||||
> model used by sub-agents. Consequently, even when using the `/model` flag you
|
||||
> may see other models used in your model usage reports.
|
||||
|
||||
## How to use the `/model` command
|
||||
|
||||
@@ -16,26 +17,25 @@ Use the following command in Gemini CLI:
|
||||
/model
|
||||
```
|
||||
|
||||
Running this command will open a dialog with your model options:
|
||||
Running this command will open a dialog with your options:
|
||||
|
||||
| Option | Description | Models |
|
||||
| ------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Auto (recommended) | Let the system choose the best model for your task. | gemini-3-pro-preview (if enabled), gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite |
|
||||
| Pro | For complex tasks that require deep reasoning and creativity. | gemini-3-pro-preview (if enabled), gemini-2.5-pro |
|
||||
| Flash | For tasks that need a balance of speed and reasoning. | gemini-2.5-flash |
|
||||
| Flash-Lite | For simple tasks that need to be done quickly. | gemini-2.5-flash-lite |
|
||||
| Option | Description | Models |
|
||||
| ----------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview (if enabled), gemini-3-flash-preview (if enabled) |
|
||||
| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash |
|
||||
| Manual | Select a specific model. | Any available model. |
|
||||
|
||||
### Gemini 3 Pro and preview features
|
||||
We recommend selecting one of the above **Auto** options. However, you can
|
||||
select **Manual** to select a specific model from those available.
|
||||
|
||||
Note: Gemini 3 is not currently available on all account types. To learn more
|
||||
about Gemini 3 access, refer to
|
||||
[Gemini 3 Pro on Gemini CLI](../get-started/gemini-3.md).
|
||||
### Gemini 3 and preview features
|
||||
|
||||
To enable Gemini 3 Pro (if available), enable
|
||||
[**Preview features** by using the `settings` command](../cli/settings.md). Once
|
||||
enabled, Gemini CLI will attempt to use Gemini 3 Pro when you select **Auto** or
|
||||
**Pro**. Both **Auto** and **Pro** will try to use Gemini 3 Pro before falling
|
||||
back to Gemini 2.5 Pro.
|
||||
> **Note:** Gemini 3 is not currently available on all account types. To learn
|
||||
> more about Gemini 3 access, refer to
|
||||
> [Gemini 3 on Gemini CLI](../get-started/gemini-3.md).
|
||||
|
||||
To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable
|
||||
[**Preview Features** by using the `settings` command](../cli/settings.md).
|
||||
|
||||
You can also use the `--model` flag to specify a particular Gemini model on
|
||||
startup. For more details, refer to the
|
||||
@@ -46,16 +46,16 @@ Gemini CLI.
|
||||
|
||||
## Best practices for model selection
|
||||
|
||||
- **Default to Auto (recommended).** For most users, the _Auto (recommended)_
|
||||
model provides a balance between speed and performance, automatically
|
||||
selecting the correct model based on the complexity of the task. Example:
|
||||
Developing a web application could include a mix of complex tasks (building
|
||||
architecture and scaffolding the project) and simple tasks (generating CSS).
|
||||
- **Default to Auto.** For most users, the _Auto_ option model provides a
|
||||
balance between speed and performance, automatically selecting the correct
|
||||
model based on the complexity of the task. Example: Developing a web
|
||||
application could include a mix of complex tasks (building architecture and
|
||||
scaffolding the project) and simple tasks (generating CSS).
|
||||
|
||||
- **Switch to Pro if you aren't getting the results you want.** If you think you
|
||||
need your model to be a little "smarter," use Pro. Pro will provide you with
|
||||
the highest levels of reasoning and creativity. Example: A complex or
|
||||
multi-stage debugging task.
|
||||
need your model to be a little "smarter," you can manually select Pro. Pro
|
||||
will provide you with the highest levels of reasoning and creativity. Example:
|
||||
A complex or multi-stage debugging task.
|
||||
|
||||
- **Switch to Flash or Flash-Lite if you need faster results.** If you need a
|
||||
simple response quickly, Flash or Flash-Lite is the best option. Example:
|
||||
|
||||
@@ -367,6 +367,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-preview": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
"extends": "chat-base-2.5",
|
||||
"modelConfig": {
|
||||
@@ -496,6 +502,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
},
|
||||
"chat-compression-3-flash": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"chat-compression-2.5-pro": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-pro"
|
||||
@@ -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`
|
||||
|
||||
@@ -1,56 +1,37 @@
|
||||
# Gemini 3 Pro on Gemini CLI
|
||||
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
|
||||
|
||||
We’re excited to bring Gemini 3 Pro to Gemini CLI. Gemini 3 Pro is **currently
|
||||
available** on Gemini CLI to all of the following subscribers:
|
||||
Gemini 3 Pro and Gemini 3 Flash are now available on Gemini CLI! Currently, most
|
||||
paid customers of Gemini CLI will have access to both Gemini 3 Pro and Gemini 3
|
||||
Flash, including the following subscribers:
|
||||
|
||||
- Google AI Ultra (except Google AI Ultra for Business).
|
||||
- Google AI Pro.
|
||||
- Gemini Code Assist Standard (requires
|
||||
- Google AI Pro and Google AI Ultra (excluding business customers).
|
||||
- Gemini Code Assist Standard and Enterprise (requires
|
||||
[administrative enablement](#administrator-instructions)).
|
||||
- Gemini Code Assist Enterprise (requires
|
||||
[administrative enablement](#administrator-instructions)).
|
||||
- Paid Gemini API key holders.
|
||||
- Paid Vertex API key holders.
|
||||
- Paid Gemini API and Vertex API key holders.
|
||||
|
||||
For **everyone else**, we're gradually expanding access
|
||||
[through a waitlist](https://goo.gle/geminicli-waitlist-signup). If you don't
|
||||
have one of the listed subscriptions, sign up for the waitlist to access Gemini
|
||||
3 Pro once approved.
|
||||
For free tier users:
|
||||
|
||||
**Note:** Whether you’re automatically granted access or accepted from the
|
||||
waitlist, you’ll still need to enable Gemini 3 Pro
|
||||
[using the `/settings` command](../cli/settings.md).
|
||||
- If you signed up for the waitlist, please check your email for details. We’ve
|
||||
onboarded everyone who signed up to the previously available waitlist.
|
||||
- If you were not on our waitlist, we’re rolling out additional access gradually
|
||||
to ensure the experience remains fast and reliable. Stay tuned for more
|
||||
details.
|
||||
|
||||
## How to join the waitlist
|
||||
## How to get started with Gemini 3 on Gemini CLI
|
||||
|
||||
Users not automatically granted access will need to join the waitlist. Follow
|
||||
these instructions to sign up:
|
||||
Get started by upgrading Gemini CLI to the latest version (0.21.1):
|
||||
|
||||
- Install Gemini CLI.
|
||||
- Authenticate using the **Login with Google** option. You’ll see a banner that
|
||||
says “Gemini 3 is now available.” If you do not see this banner, update your
|
||||
installation of Gemini CLI to the most recent version.
|
||||
- Fill out this Google form:
|
||||
[Access Gemini 3 in Gemini CLI](https://goo.gle/geminicli-waitlist-signup).
|
||||
Provide the email address of the account you used to authenticate with Gemini
|
||||
CLI.
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@latest
|
||||
```
|
||||
|
||||
Users will be onboarded in batches, subject to availability. When you’ve been
|
||||
granted access to Gemini 3 Pro, you’ll receive an acceptance email to your
|
||||
submitted email address.
|
||||
After you’ve confirmed your version is 0.21.1 or later:
|
||||
|
||||
**Note:** Please wait until you have been approved to use Gemini 3 Pro to enable
|
||||
**Preview Features**. If enabled early, the CLI will fallback to Gemini 2.5 Pro.
|
||||
1. Use the `/settings` command in Gemini CLI.
|
||||
2. Toggle **Preview Features** to `true`.
|
||||
3. Run `/model` and select **Auto (Gemini 3)**.
|
||||
|
||||
## How to use Gemini 3 Pro with Gemini CLI
|
||||
|
||||
Once you receive your acceptance email–or if you are automatically granted
|
||||
access–you still need to enable Gemini 3 Pro within Gemini CLI.
|
||||
|
||||
To enable Gemini 3 Pro, use the `/settings` command in Gemini CLI and set
|
||||
**Preview Features** to `true`.
|
||||
|
||||
For more information, see [Gemini CLI Settings](../cli/settings.md).
|
||||
For more information, see [Gemini CLI model selection](../cli/model.md).
|
||||
|
||||
### Usage limits and fallback
|
||||
|
||||
@@ -68,10 +49,10 @@ There may be times when the Gemini 3 Pro model is overloaded. When that happens,
|
||||
Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro
|
||||
or fallback to Gemini 2.5 Pro.
|
||||
|
||||
**Note:** The **Keep trying** option uses exponential backoff, in which Gemini
|
||||
CLI waits longer between each retry, when the system is busy. If the retry
|
||||
doesn't happen immediately, please wait a few minutes for the request to
|
||||
process.
|
||||
> **Note:** The **Keep trying** option uses exponential backoff, in which Gemini
|
||||
> CLI waits longer between each retry, when the system is busy. If the retry
|
||||
> doesn't happen immediately, please wait a few minutes for the request to
|
||||
> process.
|
||||
|
||||
### Model selection and routing types
|
||||
|
||||
@@ -92,7 +73,7 @@ manage your usage limits:
|
||||
To learn more about selecting a model and routing, refer to
|
||||
[Gemini CLI Model Selection](../cli/model.md).
|
||||
|
||||
## How to enable Gemini 3 Pro with Gemini CLI on Gemini Code Assist
|
||||
## How to enable Gemini 3 with Gemini CLI on Gemini Code Assist
|
||||
|
||||
If you're using Gemini Code Assist Standard or Gemini Code Assist Enterprise,
|
||||
enabling Gemini 3 Pro on Gemini CLI requires configuring your release channels.
|
||||
@@ -123,7 +104,7 @@ then:
|
||||
- Use the `/settings` command.
|
||||
- Set **Preview Features** to `true`.
|
||||
|
||||
Restart Gemini CLI and you should have access to Gemini 3 Pro.
|
||||
Restart Gemini CLI and you should have access to Gemini 3.
|
||||
|
||||
## Need help?
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
"slug": "docs/get-started"
|
||||
},
|
||||
{
|
||||
"label": "Gemini 3 Pro on Gemini CLI",
|
||||
"label": "Gemini 3 on Gemini CLI",
|
||||
"slug": "docs/get-started/gemini-3"
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.3",
|
||||
"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.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.3",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.3",
|
||||
"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.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
|
||||
@@ -1283,7 +1283,7 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('auto');
|
||||
expect(config.getModel()).toBe('auto-gemini-2.5');
|
||||
});
|
||||
|
||||
it('always prefers model from argv', async () => {
|
||||
|
||||
@@ -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'] ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -91,6 +91,7 @@ const mockConfig = {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getEnableInteractiveShell: () => true,
|
||||
getPreviewFeatures: () => false,
|
||||
};
|
||||
|
||||
const configProxy = new Proxy(mockConfig, {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { render } from '../test-utils/render.js';
|
||||
import { waitFor } from '../test-utils/async.js';
|
||||
import { cleanup } from 'ink-testing-library';
|
||||
import { act, useContext } from 'react';
|
||||
import { act, useContext, type ReactElement } from 'react';
|
||||
import { AppContainer } from './AppContainer.js';
|
||||
import { SettingsContext } from './contexts/SettingsContext.js';
|
||||
import {
|
||||
@@ -71,6 +71,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
})),
|
||||
enableMouseEvents: vi.fn(),
|
||||
disableMouseEvents: vi.fn(),
|
||||
FileDiscoveryService: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
})),
|
||||
startupProfiler: {
|
||||
flush: vi.fn(),
|
||||
start: vi.fn(),
|
||||
end: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
@@ -344,7 +352,7 @@ describe('AppContainer State Management', () => {
|
||||
// Add other properties if AppContainer uses them
|
||||
});
|
||||
mockedUseLogger.mockReturnValue({
|
||||
getPreviousUserMessages: vi.fn().mockReturnValue(new Promise(() => {})),
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
mockedUseInputHistoryStore.mockReturnValue({
|
||||
inputHistory: [],
|
||||
@@ -361,6 +369,8 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock config's getTargetDir to return consistent workspace directory
|
||||
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
|
||||
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
|
||||
vi.spyOn(mockConfig, 'getDebugMode').mockReturnValue(false);
|
||||
|
||||
mockExtensionManager = vi.mockObject({
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
@@ -403,17 +413,25 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('renders without crashing with minimal props', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('renders with startup warnings', async () => {
|
||||
const startupWarnings = ['Warning 1', 'Warning 2'];
|
||||
|
||||
const { unmount } = renderAppContainer({ startupWarnings });
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ startupWarnings });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -424,11 +442,15 @@ describe('AppContainer State Management', () => {
|
||||
themeError: 'Failed to load theme',
|
||||
};
|
||||
|
||||
const { unmount } = renderAppContainer({
|
||||
initResult: initResultWithError,
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({
|
||||
initResult: initResultWithError,
|
||||
});
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('handles debug mode state', () => {
|
||||
@@ -443,29 +465,45 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
describe('Context Providers', () => {
|
||||
it('provides AppContext with correct values', async () => {
|
||||
const { unmount } = renderAppContainer({ version: '2.0.0' });
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ version: '2.0.0' });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Should render and unmount cleanly
|
||||
expect(() => unmount()).not.toThrow();
|
||||
expect(() => unmount!()).not.toThrow();
|
||||
});
|
||||
|
||||
it('provides UIStateContext with state management', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('provides UIActionsContext with action handlers', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('provides ConfigContext with config object', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -480,9 +518,13 @@ describe('AppContainer State Management', () => {
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const { unmount } = renderAppContainer({ settings: settingsAllHidden });
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ settings: settingsAllHidden });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('handles settings with memory usage enabled', async () => {
|
||||
@@ -495,9 +537,13 @@ describe('AppContainer State Management', () => {
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const { unmount } = renderAppContainer({ settings: settingsWithMemory });
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ settings: settingsWithMemory });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -505,9 +551,13 @@ describe('AppContainer State Management', () => {
|
||||
it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])(
|
||||
'handles version format: %s',
|
||||
async (version) => {
|
||||
const { unmount } = renderAppContainer({ version });
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ version });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -529,9 +579,13 @@ describe('AppContainer State Management', () => {
|
||||
merged: {},
|
||||
} as LoadedSettings;
|
||||
|
||||
const { unmount } = renderAppContainer({ settings: undefinedSettings });
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ settings: undefinedSettings });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -860,12 +914,16 @@ describe('AppContainer State Management', () => {
|
||||
describe('Quota and Fallback Integration', () => {
|
||||
it('passes a null proQuotaRequest to UIStateContext by default', async () => {
|
||||
// The default mock from beforeEach already sets proQuotaRequest to null
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Assert that the context value is as expected
|
||||
expect(capturedUIState.proQuotaRequest).toBeNull();
|
||||
});
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => {
|
||||
@@ -881,12 +939,16 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Assert: The mock request is correctly passed through the context
|
||||
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
|
||||
});
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('passes the handleProQuotaChoice function to UIActionsContext', async () => {
|
||||
@@ -898,7 +960,11 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Assert: The action in the context is the mock handler we provided
|
||||
expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler);
|
||||
@@ -909,7 +975,7 @@ describe('AppContainer State Management', () => {
|
||||
capturedUIActions.handleProQuotaChoice('retry_later');
|
||||
});
|
||||
expect(mockHandler).toHaveBeenCalledWith('retry_later');
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1327,13 +1393,17 @@ describe('AppContainer State Management', () => {
|
||||
activePtyId: 'some-id',
|
||||
});
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
|
||||
const lastCall =
|
||||
resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1];
|
||||
// Check the height argument specifically
|
||||
expect(lastCall[2]).toBe(1);
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1672,11 +1742,15 @@ describe('AppContainer State Management', () => {
|
||||
closeModelDialog: vi.fn(),
|
||||
});
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
expect(capturedUIState.isModelDialogOpen).toBe(true);
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('should provide model dialog actions in the UIActionsContext', async () => {
|
||||
@@ -1688,7 +1762,11 @@ describe('AppContainer State Management', () => {
|
||||
closeModelDialog: mockCloseModelDialog,
|
||||
});
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Verify that the actions are correctly passed through context
|
||||
@@ -1696,13 +1774,17 @@ describe('AppContainer State Management', () => {
|
||||
capturedUIActions.closeModelDialog();
|
||||
});
|
||||
expect(mockCloseModelDialog).toHaveBeenCalled();
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoreEvents Integration', () => {
|
||||
it('subscribes to UserFeedback and drains backlog on mount', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
expect(mockCoreEvents.on).toHaveBeenCalledWith(
|
||||
@@ -1710,14 +1792,18 @@ describe('AppContainer State Management', () => {
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('unsubscribes from UserFeedback on unmount', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
unmount();
|
||||
unmount!();
|
||||
|
||||
expect(mockCoreEvents.off).toHaveBeenCalledWith(
|
||||
CoreEvent.UserFeedback,
|
||||
@@ -1726,7 +1812,11 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('adds history item when UserFeedback event is received', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Get the registered handler
|
||||
@@ -1751,14 +1841,18 @@ describe('AppContainer State Management', () => {
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('updates currentModel when ModelChanged event is received', async () => {
|
||||
// Arrange: Mock initial model
|
||||
vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model');
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState?.currentModel).toBe('initial-model');
|
||||
});
|
||||
@@ -1770,13 +1864,15 @@ describe('AppContainer State Management', () => {
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
// Act: Simulate ModelChanged event
|
||||
// Update config mock to return new model since the handler reads from config
|
||||
vi.spyOn(mockConfig, 'getModel').mockReturnValue('new-model');
|
||||
act(() => {
|
||||
handler({ model: 'new-model' });
|
||||
});
|
||||
|
||||
// Assert: Verify model is updated
|
||||
expect(capturedUIState.currentModel).toBe('new-model');
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1799,10 +1895,14 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// The main assertion is that the render does not throw.
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(resizePtySpy).toHaveBeenCalled());
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
describe('Banner Text', () => {
|
||||
@@ -1812,10 +1912,14 @@ describe('AppContainer State Management', () => {
|
||||
authType: AuthType.USE_GEMINI,
|
||||
apiKey: 'fake-key',
|
||||
});
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.bannerData.defaultText).toBeDefined();
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1838,7 +1942,11 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
const { onCancelSubmit } = extractUseGeminiStreamArgs(
|
||||
@@ -1851,7 +1959,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
expect(mockSetText).toHaveBeenCalledWith('');
|
||||
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => {
|
||||
@@ -1862,7 +1970,11 @@ describe('AppContainer State Management', () => {
|
||||
initializeFromLogger: vi.fn(),
|
||||
});
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(capturedUIState.userMessages).toContain('previous message'),
|
||||
);
|
||||
@@ -1877,7 +1989,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
expect(mockSetText).toHaveBeenCalledWith('previous message');
|
||||
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('input history is independent from conversation history (survives /clear)', async () => {
|
||||
@@ -1890,7 +2002,13 @@ describe('AppContainer State Management', () => {
|
||||
initializeFromLogger: vi.fn(),
|
||||
});
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let rerender: (tree: ReactElement) => void;
|
||||
let unmount;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
rerender = result.rerender;
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
// Verify userMessages is populated from inputHistory
|
||||
await waitFor(() =>
|
||||
@@ -1908,12 +2026,17 @@ describe('AppContainer State Management', () => {
|
||||
loadHistory: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
// Rerender to apply the new mock.
|
||||
rerender(getAppContainer());
|
||||
});
|
||||
|
||||
// Verify that userMessages still contains the input history
|
||||
// (it should not be affected by clearing conversation history)
|
||||
expect(capturedUIState.userMessages).toContain('first prompt');
|
||||
expect(capturedUIState.userMessages).toContain('second prompt');
|
||||
|
||||
unmount();
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1928,7 +2051,11 @@ describe('AppContainer State Management', () => {
|
||||
// Clear previous calls
|
||||
mocks.mockStdout.write.mockClear();
|
||||
|
||||
const { unmount } = renderAppContainer();
|
||||
let compUnmount: () => void = () => {};
|
||||
await act(async () => {
|
||||
const { unmount } = renderAppContainer();
|
||||
compUnmount = unmount;
|
||||
});
|
||||
|
||||
// Allow async effects to run
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
@@ -1944,7 +2071,7 @@ describe('AppContainer State Management', () => {
|
||||
);
|
||||
|
||||
expect(clearTerminalCalls).toHaveLength(0);
|
||||
unmount();
|
||||
compUnmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
type IdeContext,
|
||||
type UserTierId,
|
||||
type UserFeedbackPayload,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
IdeClient,
|
||||
ideContextStore,
|
||||
getErrorMessage,
|
||||
@@ -50,7 +49,6 @@ import {
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
refreshServerHierarchicalMemory,
|
||||
type ModelChangedPayload,
|
||||
type MemoryChangedPayload,
|
||||
writeToStdout,
|
||||
disableMouseEvents,
|
||||
@@ -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(
|
||||
|
||||
@@ -4,15 +4,24 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
CommandKind,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
|
||||
export const modelCommand: SlashCommand = {
|
||||
name: 'model',
|
||||
description: 'Opens a dialog to configure the model',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async () => ({
|
||||
type: 'dialog',
|
||||
dialog: 'model',
|
||||
}),
|
||||
action: async (context: CommandContext) => {
|
||||
if (context.services.config) {
|
||||
await context.services.config.refreshUserQuota();
|
||||
}
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'model',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
describe('statsCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
@@ -45,6 +46,26 @@ describe('statsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch and display quota if config is available', async () => {
|
||||
if (!statsCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const mockQuota = { buckets: [] };
|
||||
const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota);
|
||||
mockContext.services.config = {
|
||||
refreshUserQuota: mockRefreshUserQuota,
|
||||
} as unknown as Config;
|
||||
|
||||
await statsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockRefreshUserQuota).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
quotas: mockQuota,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display model stats when using the "model" subcommand', () => {
|
||||
const modelSubCommand = statsCommand.subCommands?.find(
|
||||
(sc) => sc.name === 'model',
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CodeAssistServer, getCodeAssistServer } from '@google/gemini-cli-core';
|
||||
import type { HistoryItemStats } from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
@@ -35,11 +34,8 @@ async function defaultSessionView(context: CommandContext) {
|
||||
};
|
||||
|
||||
if (context.services.config) {
|
||||
const server = getCodeAssistServer(context.services.config);
|
||||
if (server instanceof CodeAssistServer && server.projectId) {
|
||||
const quota = await server.retrieveUserQuota({
|
||||
project: server.projectId,
|
||||
});
|
||||
const quota = await context.services.config.refreshUserQuota();
|
||||
if (quota) {
|
||||
statsItem.quotas = quota;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { shortenPath, tildeifyPath } from '@google/gemini-cli-core';
|
||||
import {
|
||||
shortenPath,
|
||||
tildeifyPath,
|
||||
getDisplayString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
||||
import process from 'node:process';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
@@ -145,7 +149,8 @@ export const Footer: React.FC = () => {
|
||||
<Box alignItems="center" justifyContent="flex-end">
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.accent}>
|
||||
{model}
|
||||
{getDisplayString(model, config.getPreviewFeatures())}
|
||||
<Text color={theme.text.secondary}> /model</Text>
|
||||
{!hideContextPercentage && (
|
||||
<>
|
||||
{' '}
|
||||
|
||||
@@ -4,239 +4,240 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { cleanup } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
const mockedUseKeypress = vi.mocked(useKeypress);
|
||||
|
||||
vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({
|
||||
DescriptiveRadioButtonSelect: vi.fn(() => null),
|
||||
}));
|
||||
const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect);
|
||||
|
||||
const renderComponent = (
|
||||
props: Partial<React.ComponentProps<typeof ModelDialog>> = {},
|
||||
contextValue: Partial<Config> | undefined = undefined,
|
||||
) => {
|
||||
const defaultProps = {
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
const combinedProps = { ...defaultProps, ...props };
|
||||
|
||||
const mockConfig = contextValue
|
||||
? ({
|
||||
// --- Functions used by ModelDialog ---
|
||||
getModel: vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO),
|
||||
setModel: vi.fn(),
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
|
||||
// --- Functions used by ClearcutLogger ---
|
||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||
getSessionId: vi.fn(() => 'mock-session-id'),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
|
||||
getUseSmartEdit: vi.fn(() => false),
|
||||
getProxy: vi.fn(() => undefined),
|
||||
isInteractive: vi.fn(() => false),
|
||||
getExperiments: () => {},
|
||||
|
||||
// --- Spread test-specific overrides ---
|
||||
...contextValue,
|
||||
} as Config)
|
||||
: undefined;
|
||||
|
||||
const renderResult = render(
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
<ModelDialog {...combinedProps} />
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
// Mock dependencies
|
||||
const mockGetDisplayString = vi.fn();
|
||||
const mockLogModelSlashCommand = vi.fn();
|
||||
const mockModelSlashCommandEvent = vi.fn();
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
return {
|
||||
...renderResult,
|
||||
props: combinedProps,
|
||||
mockConfig,
|
||||
...actual,
|
||||
getDisplayString: (val: string) => mockGetDisplayString(val),
|
||||
logModelSlashCommand: (config: Config, event: ModelSlashCommandEvent) =>
|
||||
mockLogModelSlashCommand(config, event),
|
||||
ModelSlashCommandEvent: class {
|
||||
constructor(model: string) {
|
||||
mockModelSlashCommandEvent(model);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
describe('<ModelDialog />', () => {
|
||||
const mockSetModel = vi.fn();
|
||||
const mockGetModel = vi.fn();
|
||||
const mockGetPreviewFeatures = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
const mockGetHasAccessToPreviewModel = vi.fn();
|
||||
|
||||
interface MockConfig extends Partial<Config> {
|
||||
setModel: (model: string) => void;
|
||||
getModel: () => string;
|
||||
getPreviewFeatures: () => boolean;
|
||||
getHasAccessToPreviewModel: () => boolean;
|
||||
}
|
||||
|
||||
const mockConfig: MockConfig = {
|
||||
setModel: mockSetModel,
|
||||
getModel: mockGetModel,
|
||||
getPreviewFeatures: mockGetPreviewFeatures,
|
||||
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
mockGetPreviewFeatures.mockReturnValue(false);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
|
||||
// Default implementation for getDisplayString
|
||||
mockGetDisplayString.mockImplementation((val: string) => {
|
||||
if (val === 'auto-gemini-2.5') return 'Auto (Gemini 2.5)';
|
||||
if (val === 'auto-gemini-3') return 'Auto (Preview)';
|
||||
return val;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
const renderComponent = (contextValue = mockConfig as Config) =>
|
||||
render(
|
||||
<KeypressProvider>
|
||||
<ConfigContext.Provider value={contextValue}>
|
||||
<ModelDialog onClose={mockOnClose} />
|
||||
</ConfigContext.Provider>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
it('renders the title and help text', () => {
|
||||
const { lastFrame, unmount } = renderComponent();
|
||||
const waitForUpdate = () =>
|
||||
new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
it('renders the initial "main" view correctly', () => {
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Select Model');
|
||||
expect(lastFrame()).toContain('(Press Esc to close)');
|
||||
expect(lastFrame()).toContain(
|
||||
'To use a specific Gemini model on startup, use the --model flag.',
|
||||
);
|
||||
unmount();
|
||||
expect(lastFrame()).toContain('Auto');
|
||||
expect(lastFrame()).toContain('Manual');
|
||||
});
|
||||
|
||||
it('passes all model options to DescriptiveRadioButtonSelect', () => {
|
||||
const { unmount } = renderComponent();
|
||||
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
||||
|
||||
const props = mockedSelect.mock.calls[0][0];
|
||||
expect(props.items).toHaveLength(4);
|
||||
expect(props.items[0].value).toBe(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
expect(props.items[1].value).toBe(GEMINI_MODEL_ALIAS_PRO);
|
||||
expect(props.items[2].value).toBe(GEMINI_MODEL_ALIAS_FLASH);
|
||||
expect(props.items[3].value).toBe(GEMINI_MODEL_ALIAS_FLASH_LITE);
|
||||
expect(props.showNumbers).toBe(true);
|
||||
unmount();
|
||||
it('renders "main" view with preview options when preview features are enabled', () => {
|
||||
mockGetPreviewFeatures.mockReturnValue(true);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true); // Must have access
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Auto (Preview)');
|
||||
});
|
||||
|
||||
it('initializes with the model from ConfigContext', () => {
|
||||
const mockGetModel = vi.fn(() => GEMINI_MODEL_ALIAS_FLASH);
|
||||
const { unmount } = renderComponent({}, { getModel: mockGetModel });
|
||||
it('switches to "manual" view when "Manual" is selected', async () => {
|
||||
const { lastFrame, stdin } = renderComponent();
|
||||
|
||||
expect(mockGetModel).toHaveBeenCalled();
|
||||
expect(mockedSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialIndex: 2,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
// Select "Manual" (index 1)
|
||||
// Press down arrow to move to "Manual"
|
||||
stdin.write('\u001B[B'); // Arrow Down
|
||||
await waitForUpdate();
|
||||
|
||||
// Press enter to select
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
// Should now show manual options
|
||||
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
|
||||
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('initializes with "auto" model if context is not provided', () => {
|
||||
const { unmount } = renderComponent({}, undefined);
|
||||
it('renders "manual" view with preview options when preview features are enabled', async () => {
|
||||
mockGetPreviewFeatures.mockReturnValue(true);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true); // Must have access
|
||||
mockGetModel.mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
|
||||
const { lastFrame, stdin } = renderComponent();
|
||||
|
||||
expect(mockedSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialIndex: 0,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
// Select "Manual" (index 2 because Preview Auto is first, then Auto (Gemini 2.5))
|
||||
// Press down enough times to ensure we reach the bottom (Manual)
|
||||
stdin.write('\u001B[B'); // Arrow Down
|
||||
await waitForUpdate();
|
||||
stdin.write('\u001B[B'); // Arrow Down
|
||||
await waitForUpdate();
|
||||
|
||||
// Press enter to select Manual
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
expect(lastFrame()).toContain(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('initializes with "auto" model if getModel returns undefined', () => {
|
||||
const mockGetModel = vi.fn(() => undefined);
|
||||
// @ts-expect-error This test validates component robustness when getModel
|
||||
// returns an unexpected undefined value.
|
||||
const { unmount } = renderComponent({}, { getModel: mockGetModel });
|
||||
it('sets model and closes when a model is selected in "main" view', async () => {
|
||||
const { stdin } = renderComponent();
|
||||
|
||||
expect(mockGetModel).toHaveBeenCalled();
|
||||
// Select "Auto" (index 0)
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
// When getModel returns undefined, preferredModel falls back to DEFAULT_GEMINI_MODEL_AUTO
|
||||
// which has index 0, so initialIndex should be 0
|
||||
expect(mockedSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialIndex: 0,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
expect(mockSetModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
|
||||
const { props, mockConfig, unmount } = renderComponent({}, {}); // Pass empty object for contextValue
|
||||
it('sets model and closes when a model is selected in "manual" view', async () => {
|
||||
const { stdin } = renderComponent();
|
||||
|
||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||
expect(childOnSelect).toBeDefined();
|
||||
// Navigate to Manual (index 1) and select
|
||||
stdin.write('\u001B[B');
|
||||
await waitForUpdate();
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
childOnSelect(GEMINI_MODEL_ALIAS_PRO);
|
||||
// Now in manual view. Default selection is first item (DEFAULT_GEMINI_MODEL)
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
// Assert against the default mock provided by renderComponent
|
||||
expect(mockConfig?.setModel).toHaveBeenCalledWith(GEMINI_MODEL_ALIAS_PRO);
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
expect(mockSetModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not pass onHighlight to DescriptiveRadioButtonSelect', () => {
|
||||
const { unmount } = renderComponent();
|
||||
it('closes dialog on escape in "main" view', async () => {
|
||||
const { stdin } = renderComponent();
|
||||
|
||||
const childOnHighlight = mockedSelect.mock.calls[0][0].onHighlight;
|
||||
expect(childOnHighlight).toBeUndefined();
|
||||
unmount();
|
||||
stdin.write('\u001B'); // Escape
|
||||
await waitForUpdate();
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose prop when "escape" key is pressed', () => {
|
||||
const { props, unmount } = renderComponent();
|
||||
it('goes back to "main" view on escape in "manual" view', async () => {
|
||||
const { lastFrame, stdin } = renderComponent();
|
||||
|
||||
expect(mockedUseKeypress).toHaveBeenCalled();
|
||||
// Go to manual view
|
||||
stdin.write('\u001B[B');
|
||||
await waitForUpdate();
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
const keyPressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
const options = mockedUseKeypress.mock.calls[0][1];
|
||||
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
|
||||
|
||||
expect(options).toEqual({ isActive: true });
|
||||
// Press Escape
|
||||
stdin.write('\u001B');
|
||||
await waitForUpdate();
|
||||
|
||||
keyPressHandler({
|
||||
name: 'escape',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: false,
|
||||
sequence: '',
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
// Should be back to main view (Manual option visible)
|
||||
expect(lastFrame()).toContain('Manual');
|
||||
});
|
||||
|
||||
describe('Preview Logic', () => {
|
||||
it('should NOT show preview options if user has no access', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
mockGetPreviewFeatures.mockReturnValue(true); // Even if enabled
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).not.toContain('Auto (Preview)');
|
||||
});
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
keyPressHandler({
|
||||
name: 'a',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence: '',
|
||||
it('should NOT show preview options if user has access but preview features are disabled', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetPreviewFeatures.mockReturnValue(false);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).not.toContain('Auto (Preview)');
|
||||
});
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('updates initialIndex when config context changes', () => {
|
||||
const mockGetModel = vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO);
|
||||
const oldMockConfig = {
|
||||
getModel: mockGetModel,
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const { rerender, unmount } = render(
|
||||
<ConfigContext.Provider value={oldMockConfig}>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
it('should show preview options if user has access AND preview features are enabled', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetPreviewFeatures.mockReturnValue(true);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Auto (Preview)');
|
||||
});
|
||||
|
||||
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
|
||||
it('should show "Gemini 3 is now available" header if user has access but preview features disabled', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetPreviewFeatures.mockReturnValue(false);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Gemini 3 is now available.');
|
||||
expect(lastFrame()).toContain('Enable "Preview features" in /settings');
|
||||
});
|
||||
|
||||
mockGetModel.mockReturnValue(GEMINI_MODEL_ALIAS_FLASH_LITE);
|
||||
const newMockConfig = {
|
||||
getModel: mockGetModel,
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
it('should show "Gemini 3 is coming soon" header if user has no access', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
mockGetPreviewFeatures.mockReturnValue(false);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Gemini 3 is coming soon.');
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ConfigContext.Provider value={newMockConfig}>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
|
||||
// Should be called at least twice: initial render + re-render after context change
|
||||
expect(mockedSelect).toHaveBeenCalledTimes(2);
|
||||
expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(3);
|
||||
unmount();
|
||||
it('should NOT show header/subheader if preview options are shown', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetPreviewFeatures.mockReturnValue(true);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).not.toContain('Gemini 3 is now available.');
|
||||
expect(lastFrame()).not.toContain('Gemini 3 is coming soon.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
getDisplayString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
@@ -31,61 +31,131 @@ interface ModelDialogProps {
|
||||
|
||||
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const config = useContext(ConfigContext);
|
||||
const [view, setView] = useState<'main' | 'manual'>('main');
|
||||
|
||||
// Determine the Preferred Model (read once when the dialog opens).
|
||||
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
|
||||
|
||||
const shouldShowPreviewModels =
|
||||
config?.getPreviewFeatures() && config.getHasAccessToPreviewModel();
|
||||
|
||||
const manualModelSelected = useMemo(() => {
|
||||
const manualModels = [
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
];
|
||||
if (manualModels.includes(preferredModel)) {
|
||||
return preferredModel;
|
||||
}
|
||||
return '';
|
||||
}, [preferredModel]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onClose();
|
||||
if (view === 'manual') {
|
||||
setView('main');
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() => [
|
||||
const mainOptions = useMemo(() => {
|
||||
const list = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
title: 'Auto',
|
||||
description: 'Let the system choose the best model for your task.',
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
|
||||
description:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
|
||||
key: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
},
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_PRO,
|
||||
title: config?.getPreviewFeatures()
|
||||
? `Pro (${PREVIEW_GEMINI_MODEL}, ${DEFAULT_GEMINI_MODEL})`
|
||||
: `Pro (${DEFAULT_GEMINI_MODEL})`,
|
||||
value: 'Manual',
|
||||
title: manualModelSelected
|
||||
? `Manual (${manualModelSelected})`
|
||||
: 'Manual',
|
||||
description: 'Manually select a model',
|
||||
key: 'Manual',
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
list.unshift({
|
||||
value: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
|
||||
description:
|
||||
'For complex tasks that require deep reasoning and creativity',
|
||||
key: GEMINI_MODEL_ALIAS_PRO,
|
||||
'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
|
||||
key: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [shouldShowPreviewModels, manualModelSelected]);
|
||||
|
||||
const manualOptions = useMemo(() => {
|
||||
const list = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: DEFAULT_GEMINI_MODEL,
|
||||
key: DEFAULT_GEMINI_MODEL,
|
||||
},
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_FLASH,
|
||||
title: `Flash (${DEFAULT_GEMINI_FLASH_MODEL})`,
|
||||
description: 'For tasks that need a balance of speed and reasoning',
|
||||
key: GEMINI_MODEL_ALIAS_FLASH,
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
key: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
title: `Flash-Lite (${DEFAULT_GEMINI_FLASH_LITE_MODEL})`,
|
||||
description: 'For simple tasks that need to be done quickly',
|
||||
key: GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
},
|
||||
],
|
||||
[config],
|
||||
);
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
list.unshift(
|
||||
{
|
||||
value: PREVIEW_GEMINI_MODEL,
|
||||
title: PREVIEW_GEMINI_MODEL,
|
||||
key: PREVIEW_GEMINI_MODEL,
|
||||
},
|
||||
{
|
||||
value: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
title: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
key: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [shouldShowPreviewModels]);
|
||||
|
||||
const options = view === 'main' ? mainOptions : manualOptions;
|
||||
|
||||
// Calculate the initial index based on the preferred model.
|
||||
const initialIndex = useMemo(
|
||||
() => options.findIndex((option) => option.value === preferredModel),
|
||||
[preferredModel, options],
|
||||
);
|
||||
const initialIndex = useMemo(() => {
|
||||
const idx = options.findIndex((option) => option.value === preferredModel);
|
||||
if (idx !== -1) {
|
||||
return idx;
|
||||
}
|
||||
if (view === 'main') {
|
||||
const manualIdx = options.findIndex((o) => o.value === 'Manual');
|
||||
return manualIdx !== -1 ? manualIdx : 0;
|
||||
}
|
||||
return 0;
|
||||
}, [preferredModel, options, view]);
|
||||
|
||||
// Handle selection internally (Autonomous Dialog).
|
||||
const handleSelect = useCallback(
|
||||
(model: string) => {
|
||||
if (model === 'Manual') {
|
||||
setView('manual');
|
||||
return;
|
||||
}
|
||||
|
||||
if (config) {
|
||||
config.setModel(model);
|
||||
const event = new ModelSlashCommandEvent(model);
|
||||
@@ -96,13 +166,23 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
[config, onClose],
|
||||
);
|
||||
|
||||
const header = config?.getPreviewFeatures()
|
||||
? 'Gemini 3 is now enabled.'
|
||||
: 'Gemini 3 is now available.';
|
||||
let header;
|
||||
let subheader;
|
||||
|
||||
const subheader = config?.getPreviewFeatures()
|
||||
? `To disable Gemini 3, disable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features\n\nWhen you select Auto or Pro, Gemini CLI will attempt to use ${PREVIEW_GEMINI_MODEL} first, before falling back to ${DEFAULT_GEMINI_MODEL}.`
|
||||
: `To use Gemini 3, enable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features`;
|
||||
// Do not show any header or subheader since it's already showing preview model
|
||||
// options
|
||||
if (shouldShowPreviewModels) {
|
||||
header = undefined;
|
||||
subheader = undefined;
|
||||
// When a user has the access but has not enabled the preview features.
|
||||
} else if (config?.getHasAccessToPreviewModel()) {
|
||||
header = 'Gemini 3 is now available.';
|
||||
subheader =
|
||||
'Enable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features';
|
||||
} else {
|
||||
header = 'Gemini 3 is coming soon.';
|
||||
subheader = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -114,11 +194,15 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
>
|
||||
<Text bold>Select Model</Text>
|
||||
|
||||
<Box marginTop={1} marginBottom={1} flexDirection="column">
|
||||
<ThemedGradient>
|
||||
<Text>{header}</Text>
|
||||
</ThemedGradient>
|
||||
<Text>{subheader}</Text>
|
||||
<Box flexDirection="column">
|
||||
{header && (
|
||||
<Box marginTop={1}>
|
||||
<ThemedGradient>
|
||||
<Text>{header}</Text>
|
||||
</ThemedGradient>
|
||||
</Box>
|
||||
)}
|
||||
{subheader && <Text>{subheader}</Text>}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
return {
|
||||
...actual,
|
||||
ChatRecordingService: vi.fn(),
|
||||
generateSummary: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -12,9 +12,6 @@ export {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
} from './src/config/models.js';
|
||||
export {
|
||||
serializeTerminalToObject,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.3",
|
||||
"description": "Gemini CLI Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,17 @@ import type {
|
||||
ModelPolicyChain,
|
||||
RetryAvailabilityContext,
|
||||
} from './modelPolicy.js';
|
||||
import { createDefaultPolicy, getModelPolicyChain } from './policyCatalog.js';
|
||||
import { DEFAULT_GEMINI_MODEL, getEffectiveModel } from '../config/models.js';
|
||||
import {
|
||||
createDefaultPolicy,
|
||||
createSingleModelChain,
|
||||
getModelPolicyChain,
|
||||
} from './policyCatalog.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
resolveModel,
|
||||
} from '../config/models.js';
|
||||
import type { ModelSelectionResult } from './modelAvailabilityService.js';
|
||||
|
||||
/**
|
||||
@@ -24,27 +33,34 @@ import type { ModelSelectionResult } from './modelAvailabilityService.js';
|
||||
export function resolvePolicyChain(
|
||||
config: Config,
|
||||
preferredModel?: string,
|
||||
wrapsAround: boolean = false,
|
||||
): ModelPolicyChain {
|
||||
const chain = getModelPolicyChain({
|
||||
previewEnabled: !!config.getPreviewFeatures(),
|
||||
userTier: config.getUserTier(),
|
||||
});
|
||||
// TODO: This will be replaced when we get rid of Fallback Modes.
|
||||
// Switch to getActiveModel()
|
||||
const activeModel =
|
||||
preferredModel ??
|
||||
getEffectiveModel(
|
||||
config.isInFallbackMode(),
|
||||
config.getModel(),
|
||||
config.getPreviewFeatures(),
|
||||
);
|
||||
// Availability uses the active/requested model directly. Legacy fallback logic
|
||||
// (getEffectiveModel) only applies when availability is disabled.
|
||||
const modelFromConfig =
|
||||
preferredModel ?? config.getActiveModel?.() ?? config.getModel();
|
||||
|
||||
if (activeModel === 'auto') {
|
||||
return [...chain];
|
||||
let chain;
|
||||
|
||||
if (
|
||||
config.getModel() === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
config.getModel() === DEFAULT_GEMINI_MODEL_AUTO
|
||||
) {
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled: config.getModel() === PREVIEW_GEMINI_MODEL_AUTO,
|
||||
userTier: config.getUserTier(),
|
||||
});
|
||||
} else {
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
}
|
||||
|
||||
if (chain.some((policy) => policy.model === activeModel)) {
|
||||
return [...chain];
|
||||
const activeModel = resolveModel(modelFromConfig);
|
||||
|
||||
const activeIndex = chain.findIndex((policy) => policy.model === activeModel);
|
||||
if (activeIndex !== -1) {
|
||||
return wrapsAround
|
||||
? [...chain.slice(activeIndex), ...chain.slice(0, activeIndex)]
|
||||
: [...chain.slice(activeIndex)];
|
||||
}
|
||||
|
||||
// If the user specified a model not in the default chain, we assume they want
|
||||
@@ -55,10 +71,14 @@ export function resolvePolicyChain(
|
||||
/**
|
||||
* Produces the failed policy (if it exists in the chain) and the list of
|
||||
* fallback candidates that follow it.
|
||||
* @param chain - The ordered list of available model policies.
|
||||
* @param failedModel - The identifier of the model that failed.
|
||||
* @param wrapsAround - If true, treats the chain as a circular buffer.
|
||||
*/
|
||||
export function buildFallbackPolicyContext(
|
||||
chain: ModelPolicyChain,
|
||||
failedModel: string,
|
||||
wrapsAround: boolean = false,
|
||||
): {
|
||||
failedPolicy?: ModelPolicy;
|
||||
candidates: ModelPolicy[];
|
||||
@@ -69,9 +89,12 @@ export function buildFallbackPolicyContext(
|
||||
}
|
||||
// Return [candidates_after, candidates_before] to prioritize downgrades
|
||||
// (continuing the chain) before wrapping around to upgrades.
|
||||
const candidates = wrapsAround
|
||||
? [...chain.slice(index + 1), ...chain.slice(0, index)]
|
||||
: [...chain.slice(index + 1)];
|
||||
return {
|
||||
failedPolicy: chain[index],
|
||||
candidates: [...chain.slice(index + 1), ...chain.slice(0, index)],
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ import { logRipgrepFallback } from '../telemetry/loggers.js';
|
||||
import { RipgrepFallbackEvent } from '../telemetry/types.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
} from './models.js';
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
} from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import type { AuthType } from './contentGenerator.js';
|
||||
import { handleFallback } from '../fallback/handler.js';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
import { reportError } from '../utils/errorReporting.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
@@ -86,6 +88,7 @@ export class BaseLlmClient {
|
||||
constructor(
|
||||
private readonly contentGenerator: ContentGenerator,
|
||||
private readonly config: Config,
|
||||
private readonly authType?: AuthType,
|
||||
) {}
|
||||
|
||||
async generateJson(
|
||||
@@ -286,6 +289,12 @@ export class BaseLlmClient {
|
||||
maxAttempts:
|
||||
availabilityMaxAttempts ?? maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
||||
getAvailabilityContext,
|
||||
onPersistent429: this.config.isInteractive()
|
||||
? (authType, error) =>
|
||||
handleFallback(this.config, requestParams.model, authType, error)
|
||||
: undefined,
|
||||
authType:
|
||||
this.authType ?? this.config.getContentGeneratorConfig()?.authType,
|
||||
});
|
||||
} catch (error) {
|
||||
if (abortSignal?.aborted) {
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
type ChatCompressionInfo,
|
||||
} from './turn.js';
|
||||
import { getCoreSystemPrompt } from './prompts.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
} from '../config/models.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { setSimulate429 } from '../utils/testUtils.js';
|
||||
import { tokenLimit } from './tokenLimits.js';
|
||||
@@ -2044,7 +2047,9 @@ ${JSON.stringify(
|
||||
skipped: [],
|
||||
},
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'Hi' }],
|
||||
new AbortController().signal,
|
||||
@@ -2074,7 +2079,9 @@ ${JSON.stringify(
|
||||
skipped: [],
|
||||
},
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'Hi' }],
|
||||
new AbortController().signal,
|
||||
|
||||
@@ -31,10 +31,7 @@ import type {
|
||||
ResumedSessionData,
|
||||
} from '../services/chatRecordingService.js';
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
getEffectiveModel,
|
||||
} from '../config/models.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import { LoopDetectionService } from '../services/loopDetectionService.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
@@ -395,12 +392,9 @@ export class GeminiClient {
|
||||
return this.currentSequenceModel;
|
||||
}
|
||||
|
||||
const configModel = this.config.getModel();
|
||||
return getEffectiveModel(
|
||||
this.config.isInFallbackMode(),
|
||||
configModel,
|
||||
this.config.getPreviewFeatures(),
|
||||
);
|
||||
// Availability logic: The configured model is the source of truth,
|
||||
// including any permanent fallbacks (config.setModel) or manual overrides.
|
||||
return this.config.getActiveModel();
|
||||
}
|
||||
|
||||
async *sendMessageStream(
|
||||
|
||||
@@ -19,13 +19,13 @@ import type { Config } from '../config/config.js';
|
||||
import { setSimulate429 } from '../utils/testUtils.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_THINKING_MODE,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { AuthType } from './contentGenerator.js';
|
||||
import { TerminalQuotaError } from '../utils/googleQuotaErrors.js';
|
||||
import { retryWithBackoff, type RetryOptions } from '../utils/retry.js';
|
||||
import { type RetryOptions } from '../utils/retry.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
import { HookSystem } from '../hooks/hookSystem.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
@@ -127,18 +127,25 @@ describe('GeminiChat', () => {
|
||||
}
|
||||
return result;
|
||||
});
|
||||
let currentModel = 'gemini-pro';
|
||||
let currentActiveModel = 'gemini-pro';
|
||||
|
||||
mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getTelemetryLogPromptsEnabled: () => true,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getPreviewFeatures: () => false,
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'oauth-personal', // Ensure this is set for fallback tests
|
||||
model: 'test-model',
|
||||
getContentGeneratorConfig: vi.fn().mockImplementation(() => ({
|
||||
authType: 'oauth-personal',
|
||||
model: currentModel,
|
||||
})),
|
||||
getModel: vi.fn().mockImplementation(() => currentModel),
|
||||
setModel: vi.fn().mockImplementation((m: string) => {
|
||||
currentModel = m;
|
||||
// When model is explicitly set, active model usually resets or updates to it
|
||||
currentActiveModel = m;
|
||||
}),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
setModel: vi.fn(),
|
||||
isInFallbackMode: vi.fn().mockReturnValue(false),
|
||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||
setQuotaErrorOccurred: vi.fn(),
|
||||
@@ -155,7 +162,8 @@ describe('GeminiChat', () => {
|
||||
getUserTier: vi.fn().mockReturnValue(undefined),
|
||||
modelConfigService: {
|
||||
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => {
|
||||
const thinkingConfig = modelConfigKey.model.startsWith('gemini-3')
|
||||
const model = modelConfigKey.model ?? mockConfig.getModel();
|
||||
const thinkingConfig = model.startsWith('gemini-3')
|
||||
? {
|
||||
thinkingLevel: ThinkingLevel.HIGH,
|
||||
}
|
||||
@@ -163,7 +171,7 @@ describe('GeminiChat', () => {
|
||||
thinkingBudget: DEFAULT_THINKING_MODE,
|
||||
};
|
||||
return {
|
||||
model: modelConfigKey.model,
|
||||
model,
|
||||
generateContentConfig: {
|
||||
temperature: 0,
|
||||
thinkingConfig,
|
||||
@@ -178,8 +186,10 @@ describe('GeminiChat', () => {
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
getEnableHooks: vi.fn().mockReturnValue(false),
|
||||
isModelAvailabilityServiceEnabled: vi.fn().mockReturnValue(false),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
setActiveModel: vi.fn(),
|
||||
getActiveModel: vi.fn().mockImplementation(() => currentActiveModel),
|
||||
setActiveModel: vi
|
||||
.fn()
|
||||
.mockImplementation((m: string) => (currentActiveModel = m)),
|
||||
getModelAvailabilityService: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -570,6 +580,39 @@ describe('GeminiChat', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use maxAttempts=1 for retryWithBackoff when in Preview Model Fallback Mode (Flash)', async () => {
|
||||
vi.mocked(mockConfig.isPreviewModelFallbackMode).mockReturnValue(true);
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: PREVIEW_GEMINI_FLASH_MODEL },
|
||||
'test',
|
||||
'prompt-id-fast-retry-flash',
|
||||
new AbortController().signal,
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockRetryWithBackoff).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
maxAttempts: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT use maxAttempts=1 for other models even in Preview Model Fallback Mode', async () => {
|
||||
vi.mocked(mockConfig.isPreviewModelFallbackMode).mockReturnValue(true);
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
@@ -603,62 +646,6 @@ describe('GeminiChat', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass DEFAULT_GEMINI_MODEL to handleFallback when Preview Model is bypassed (downgraded)', async () => {
|
||||
// ARRANGE
|
||||
vi.mocked(mockConfig.isPreviewModelBypassMode).mockReturnValue(true);
|
||||
// Mock retryWithBackoff to simulate catching the error and calling onPersistent429
|
||||
vi.mocked(retryWithBackoff).mockImplementation(
|
||||
async (apiCall, options) => {
|
||||
const onPersistent429 = options?.onPersistent429;
|
||||
try {
|
||||
await apiCall();
|
||||
} catch (error) {
|
||||
if (onPersistent429) {
|
||||
await onPersistent429(AuthType.LOGIN_WITH_GOOGLE, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// We need the API call to fail so retryWithBackoff calls the callback.
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(
|
||||
new TerminalQuotaError('Simulated Quota Error', {
|
||||
code: 429,
|
||||
message: 'Simulated Quota Error',
|
||||
details: [],
|
||||
}),
|
||||
);
|
||||
|
||||
// ACT
|
||||
const consumeStream = async () => {
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: PREVIEW_GEMINI_MODEL },
|
||||
'test',
|
||||
'prompt-id-bypass',
|
||||
new AbortController().signal,
|
||||
);
|
||||
// Consume the stream to trigger execution
|
||||
for await (const _ of stream) {
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
|
||||
await expect(consumeStream()).rejects.toThrow('Simulated Quota Error');
|
||||
|
||||
expect(retryWithBackoff).toHaveBeenCalled();
|
||||
|
||||
// ASSERT
|
||||
// handleFallback is called via onPersistent429Callback
|
||||
// We verify it was called with DEFAULT_GEMINI_MODEL
|
||||
expect(mockHandleFallback).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
DEFAULT_GEMINI_MODEL, // This is the key assertion
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when a tool call is followed by an empty stream response', async () => {
|
||||
// 1. Setup: A history where the model has just made a function call.
|
||||
const initialHistory: Content[] = [
|
||||
@@ -1848,45 +1835,6 @@ describe('GeminiChat', () => {
|
||||
expect(turn4.parts[0].text).toBe('second response');
|
||||
});
|
||||
|
||||
describe('Model Resolution', () => {
|
||||
const mockResponse = {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'response' }], role: 'model' },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
|
||||
it('should use the FLASH model when in fallback mode (sendMessageStream)', async () => {
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-pro');
|
||||
vi.mocked(mockConfig.isInFallbackMode).mockReturnValue(true);
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
|
||||
async () =>
|
||||
(async function* () {
|
||||
yield mockResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: 'test-model' },
|
||||
'test message',
|
||||
'prompt-id-res3',
|
||||
new AbortController().signal,
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
}),
|
||||
'prompt-id-res3',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback Integration (Retries)', () => {
|
||||
const error429 = new ApiError({
|
||||
message: 'API Error 429: Quota exceeded',
|
||||
@@ -1983,92 +1931,6 @@ describe('GeminiChat', () => {
|
||||
expect(modelTurn.parts![0]!.text).toBe('Success on retry');
|
||||
});
|
||||
|
||||
it('should switch to DEFAULT_GEMINI_FLASH_MODEL and use thinkingBudget when falling back from a gemini-3 model', async () => {
|
||||
// ARRANGE
|
||||
const authType = AuthType.LOGIN_WITH_GOOGLE;
|
||||
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
|
||||
authType,
|
||||
});
|
||||
|
||||
// Initial state: Not in fallback mode
|
||||
const isInFallbackModeSpy = vi.spyOn(mockConfig, 'isInFallbackMode');
|
||||
isInFallbackModeSpy.mockReturnValue(false);
|
||||
|
||||
// Mock API calls:
|
||||
// 1. Fails with 429 (simulating gemini-3 failure)
|
||||
// 2. Succeeds (simulating fallback success)
|
||||
vi.mocked(mockContentGenerator.generateContentStream)
|
||||
.mockRejectedValueOnce(error429)
|
||||
.mockResolvedValueOnce(
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Fallback success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
// Mock handleFallback to enable fallback mode and signal retry
|
||||
mockHandleFallback.mockImplementation(async () => {
|
||||
isInFallbackModeSpy.mockReturnValue(true); // Next call will see fallback mode = true
|
||||
return true;
|
||||
});
|
||||
|
||||
// ACT
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: 'gemini-3-test-model' }, // Start with a gemini-3 model
|
||||
'test fallback thinking',
|
||||
'prompt-id-fb3',
|
||||
new AbortController().signal,
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
// ASSERT
|
||||
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
|
||||
// First call: gemini-3 model, thinkingLevel set
|
||||
expect(
|
||||
mockContentGenerator.generateContentStream,
|
||||
).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
model: 'gemini-3-test-model',
|
||||
config: expect.objectContaining({
|
||||
thinkingConfig: {
|
||||
thinkingBudget: undefined,
|
||||
thinkingLevel: ThinkingLevel.HIGH,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'prompt-id-fb3',
|
||||
);
|
||||
|
||||
// Second call: DEFAULT_GEMINI_FLASH_MODEL (due to fallback), thinkingBudget set (due to fix)
|
||||
expect(
|
||||
mockContentGenerator.generateContentStream,
|
||||
).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
config: expect.objectContaining({
|
||||
thinkingConfig: {
|
||||
thinkingBudget: DEFAULT_THINKING_MODE,
|
||||
thinkingLevel: undefined,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'prompt-id-fb3',
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop retrying if handleFallback returns false (e.g., auth intent)', async () => {
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-pro');
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(
|
||||
@@ -2200,92 +2062,6 @@ describe('GeminiChat', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Preview Model Fallback Logic', () => {
|
||||
it('should reset previewModelBypassMode to false at the start of sendMessageStream', async () => {
|
||||
const stream = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
|
||||
await chat.sendMessageStream(
|
||||
{ model: 'test-model' },
|
||||
'test',
|
||||
'prompt-id-preview-model-reset',
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should reset previewModelFallbackMode to false upon successful Preview Model usage', async () => {
|
||||
const stream = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
|
||||
const resultStream = await chat.sendMessageStream(
|
||||
{ model: PREVIEW_GEMINI_MODEL },
|
||||
'test',
|
||||
'prompt-id-preview-model-healing',
|
||||
new AbortController().signal,
|
||||
);
|
||||
for await (const _ of resultStream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it('should NOT reset previewModelFallbackMode if Preview Model was bypassed (downgraded)', async () => {
|
||||
const stream = (async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'Success' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})();
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
// Simulate bypass mode being active (downgrade happened)
|
||||
vi.mocked(mockConfig.isPreviewModelBypassMode).mockReturnValue(true);
|
||||
|
||||
const resultStream = await chat.sendMessageStream(
|
||||
{ model: PREVIEW_GEMINI_MODEL },
|
||||
'test',
|
||||
'prompt-id-bypass-no-healing',
|
||||
new AbortController().signal,
|
||||
);
|
||||
for await (const _ of resultStream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
expect(mockConfig.setPreviewModelFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureActiveLoopHasThoughtSignatures', () => {
|
||||
it('should add thoughtSignature to the first functionCall in each model turn of the active loop', () => {
|
||||
const chat = new GeminiChat(mockConfig, '', [], []);
|
||||
|
||||
@@ -22,11 +22,10 @@ import { createUserContent, FinishReason } from '@google/genai';
|
||||
import { retryWithBackoff, isRetryableError } from '../utils/retry.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_THINKING_MODE,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
getEffectiveModel,
|
||||
resolveModel,
|
||||
isGemini2Model,
|
||||
isPreviewModel,
|
||||
} from '../config/models.js';
|
||||
import { hasCycleInSchema } from '../tools/tools.js';
|
||||
import type { StructuredError } from './turn.js';
|
||||
@@ -306,10 +305,7 @@ export class GeminiChat {
|
||||
let maxAttempts = INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
|
||||
// If we are in Preview Model Fallback Mode, we want to fail fast (1 attempt)
|
||||
// when probing the Preview Model.
|
||||
if (
|
||||
this.config.isPreviewModelFallbackMode() &&
|
||||
model === PREVIEW_GEMINI_MODEL
|
||||
) {
|
||||
if (this.config.isPreviewModelFallbackMode() && isPreviewModel(model)) {
|
||||
maxAttempts = 1;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ import { AuthType } from '../core/contentGenerator.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
} from '../config/models.js';
|
||||
import { logFlashFallback } from '../telemetry/index.js';
|
||||
import type { FallbackModelHandler } from './types.js';
|
||||
import { ModelNotFoundError } from '../utils/httpErrors.js';
|
||||
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
@@ -64,7 +65,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
({
|
||||
isInFallbackMode: vi.fn(() => false),
|
||||
setFallbackMode: vi.fn(),
|
||||
isModelAvailabilityServiceEnabled: vi.fn(() => false),
|
||||
isModelAvailabilityServiceEnabled: vi.fn(() => true),
|
||||
isPreviewModelFallbackMode: vi.fn(() => false),
|
||||
setPreviewModelFallbackMode: vi.fn(),
|
||||
isPreviewModelBypassMode: vi.fn(() => false),
|
||||
@@ -78,6 +79,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
skipped: [],
|
||||
}),
|
||||
),
|
||||
getActiveModel: vi.fn(() => MOCK_PRO_MODEL),
|
||||
getModel: vi.fn(() => MOCK_PRO_MODEL),
|
||||
getPreviewFeatures: vi.fn(() => false),
|
||||
getUserTier: vi.fn(() => undefined),
|
||||
@@ -113,430 +115,6 @@ describe('handleFallback', () => {
|
||||
fallbackEventSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return null immediately if authType is not OAuth', async () => {
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_API_KEY,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mockHandler).not.toHaveBeenCalled();
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still consult the handler if the failed model is the fallback model', async () => {
|
||||
mockHandler.mockResolvedValue('stop');
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
FALLBACK_MODEL, // Failed model is Flash
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null if no fallbackHandler is injected in config', async () => {
|
||||
const configWithoutHandler = createMockConfig({
|
||||
fallbackModelHandler: undefined,
|
||||
});
|
||||
const result = await handleFallback(
|
||||
configWithoutHandler,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe('when handler returns "retry_always"', () => {
|
||||
it('should activate fallback mode, log telemetry, and return true', async () => {
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockConfig.setFallbackMode).toHaveBeenCalledWith(true);
|
||||
expect(logFlashFallback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when handler returns "stop"', () => {
|
||||
it('should activate fallback mode, log telemetry, and return false', async () => {
|
||||
mockHandler.mockResolvedValue('stop');
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockConfig.setFallbackMode).toHaveBeenCalledWith(true);
|
||||
expect(logFlashFallback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false without toggling fallback when handler returns "retry_later"', async () => {
|
||||
mockHandler.mockResolvedValue('retry_later');
|
||||
|
||||
const result = await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
expect(logFlashFallback).not.toHaveBeenCalled();
|
||||
expect(fallbackEventSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should launch upgrade flow and avoid fallback mode when handler returns "upgrade"', async () => {
|
||||
mockHandler.mockResolvedValue('upgrade');
|
||||
vi.mocked(openBrowserSecurely).mockResolvedValue(undefined);
|
||||
|
||||
const result = await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(openBrowserSecurely).toHaveBeenCalledWith(
|
||||
'https://goo.gle/set-up-gemini-code-assist',
|
||||
);
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
expect(logFlashFallback).not.toHaveBeenCalled();
|
||||
expect(fallbackEventSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log a warning and continue when upgrade flow fails to open a browser', async () => {
|
||||
mockHandler.mockResolvedValue('upgrade');
|
||||
const debugWarnSpy = vi.spyOn(debugLogger, 'warn');
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
vi.mocked(openBrowserSecurely).mockRejectedValue(new Error('blocked'));
|
||||
|
||||
const result = await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(debugWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to open browser automatically:',
|
||||
'blocked',
|
||||
);
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
expect(fallbackEventSpy).not.toHaveBeenCalled();
|
||||
debugWarnSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('when handler returns an unexpected value', () => {
|
||||
it('should log an error and return null', async () => {
|
||||
mockHandler.mockResolvedValue(null);
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Fallback UI handler failed:',
|
||||
new Error(
|
||||
'Unexpected fallback intent received from fallbackModelHandler: "null"',
|
||||
),
|
||||
);
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass the correct context (failedModel, fallbackModel, error) to the handler', async () => {
|
||||
const mockError = new Error('Quota Exceeded');
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH, mockError);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledWith(
|
||||
MOCK_PRO_MODEL,
|
||||
FALLBACK_MODEL,
|
||||
mockError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call setFallbackMode or log telemetry if already in fallback mode', async () => {
|
||||
// Setup config where fallback mode is already active
|
||||
const activeFallbackConfig = createMockConfig({
|
||||
fallbackModelHandler: mockHandler,
|
||||
isInFallbackMode: vi.fn(() => true), // Already active
|
||||
setFallbackMode: vi.fn(),
|
||||
});
|
||||
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
const result = await handleFallback(
|
||||
activeFallbackConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
// Should still return true to allow the retry (which will use the active fallback mode)
|
||||
expect(result).toBe(true);
|
||||
// Should still consult the handler
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
// But should not mutate state or log telemetry again
|
||||
expect(activeFallbackConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
expect(logFlashFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should catch errors from the handler, log an error, and return null', async () => {
|
||||
const handlerError = new Error('UI interaction failed');
|
||||
mockHandler.mockRejectedValue(handlerError);
|
||||
|
||||
const result = await handleFallback(mockConfig, MOCK_PRO_MODEL, AUTH_OAUTH);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Fallback UI handler failed:',
|
||||
handlerError,
|
||||
);
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Preview Model Fallback Logic', () => {
|
||||
const previewModel = PREVIEW_GEMINI_MODEL;
|
||||
|
||||
it('should only set Preview Model bypass mode on retryable quota failure', async () => {
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const retryableQuotaError = new RetryableQuotaError(
|
||||
'Capacity error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
await handleFallback(
|
||||
mockConfig,
|
||||
previewModel,
|
||||
AUTH_OAUTH,
|
||||
retryableQuotaError,
|
||||
);
|
||||
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should not set Preview Model bypass mode on non-retryable quota failure', async () => {
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const terminalQuotaError = new TerminalQuotaError(
|
||||
'quota error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
await handleFallback(
|
||||
mockConfig,
|
||||
previewModel,
|
||||
AUTH_OAUTH,
|
||||
terminalQuotaError,
|
||||
);
|
||||
|
||||
expect(mockConfig.setPreviewModelBypassMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should silently retry if Preview Model fallback mode is already active and error is retryable error', async () => {
|
||||
vi.spyOn(mockConfig, 'isPreviewModelFallbackMode').mockReturnValue(true);
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const retryableQuotaError = new RetryableQuotaError(
|
||||
'Capacity error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
previewModel,
|
||||
AUTH_OAUTH,
|
||||
retryableQuotaError,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should activate Preview Model fallback mode when handler returns "retry_always" and is RetryableQuotaError', async () => {
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const retryableQuotaError = new RetryableQuotaError(
|
||||
'Capacity error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
previewModel,
|
||||
AUTH_OAUTH,
|
||||
retryableQuotaError,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockConfig.setPreviewModelBypassMode).toHaveBeenCalledWith(true);
|
||||
expect(mockConfig.setPreviewModelFallbackMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should activate regular fallback when handler returns "retry_always" and is TerminalQuotaError', async () => {
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
const mockGoogleApiError = {
|
||||
code: 503,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const terminalError = new TerminalQuotaError(
|
||||
'Quota error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
previewModel,
|
||||
AUTH_OAUTH,
|
||||
terminalError,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockConfig.setPreviewModelFallbackMode).not.toBeCalled();
|
||||
expect(mockConfig.setFallbackMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should NOT set fallback mode if user chooses "retry_once"', async () => {
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const terminalQuotaError = new TerminalQuotaError(
|
||||
'quota error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
mockHandler.mockResolvedValue('retry_once');
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
terminalQuotaError,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockConfig.setPreviewModelBypassMode).not.toHaveBeenCalled();
|
||||
expect(mockConfig.setPreviewModelFallbackMode).not.toHaveBeenCalled();
|
||||
expect(mockConfig.setFallbackMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass DEFAULT_GEMINI_MODEL as fallback when Preview Model fails with Retryable Error', async () => {
|
||||
const mockFallbackHandler = vi.fn().mockResolvedValue('stop');
|
||||
vi.mocked(mockConfig.fallbackModelHandler!).mockImplementation(
|
||||
mockFallbackHandler,
|
||||
);
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const retryableQuotaError = new RetryableQuotaError(
|
||||
'Capacity error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
|
||||
await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
retryableQuotaError,
|
||||
);
|
||||
|
||||
expect(mockConfig.fallbackModelHandler).toHaveBeenCalledWith(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
retryableQuotaError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass DEFAULT_GEMINI_MODEL as fallback when Preview Model fails with other error', async () => {
|
||||
await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
|
||||
expect(mockConfig.fallbackModelHandler).toHaveBeenCalledWith(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass DEFAULT_GEMINI_FLASH_MODEL as fallback when Preview Model fails with other error', async () => {
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const terminalQuotaError = new TerminalQuotaError(
|
||||
'quota error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
terminalQuotaError,
|
||||
);
|
||||
|
||||
expect(mockConfig.fallbackModelHandler).toHaveBeenCalledWith(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
terminalQuotaError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if ModelNotFoundError occurs for a non-preview model', async () => {
|
||||
const modelNotFoundError = new ModelNotFoundError('Not found');
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
DEFAULT_GEMINI_MODEL, // Not preview model
|
||||
AUTH_OAUTH,
|
||||
modelNotFoundError,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mockHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should consult handler if ModelNotFoundError occurs for preview model', async () => {
|
||||
const modelNotFoundError = new ModelNotFoundError('Not found');
|
||||
mockHandler.mockResolvedValue('retry_always');
|
||||
|
||||
const result = await handleFallback(
|
||||
mockConfig,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
AUTH_OAUTH,
|
||||
modelNotFoundError,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('policy-driven flow', () => {
|
||||
let policyConfig: Config;
|
||||
let availability: ModelAvailabilityService;
|
||||
@@ -550,31 +128,47 @@ describe('handleFallback', () => {
|
||||
});
|
||||
policyHandler = vi.fn().mockResolvedValue('retry_once');
|
||||
policyConfig = createMockConfig();
|
||||
vi.spyOn(
|
||||
policyConfig,
|
||||
'isModelAvailabilityServiceEnabled',
|
||||
).mockReturnValue(true);
|
||||
vi.spyOn(policyConfig, 'getModelAvailabilityService').mockReturnValue(
|
||||
|
||||
// Ensure we test the availability path
|
||||
vi.mocked(policyConfig.isModelAvailabilityServiceEnabled).mockReturnValue(
|
||||
true,
|
||||
);
|
||||
vi.mocked(policyConfig.getModelAvailabilityService).mockReturnValue(
|
||||
availability,
|
||||
);
|
||||
vi.spyOn(policyConfig, 'getFallbackModelHandler').mockReturnValue(
|
||||
vi.mocked(policyConfig.getFallbackModelHandler).mockReturnValue(
|
||||
policyHandler,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null immediately if authType is not OAuth', async () => {
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_API_KEY,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(policyHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses availability selection with correct candidates when enabled', async () => {
|
||||
vi.spyOn(policyConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.spyOn(policyConfig, 'getModel').mockReturnValue(DEFAULT_GEMINI_MODEL);
|
||||
// Direct mock manipulation since it's already a vi.fn()
|
||||
vi.mocked(policyConfig.getPreviewFeatures).mockReturnValue(true);
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
await handleFallback(policyConfig, DEFAULT_GEMINI_MODEL, AUTH_OAUTH);
|
||||
|
||||
expect(availability.selectFirstAvailable).toHaveBeenCalledWith([
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to last resort when availability returns null', async () => {
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
availability.selectFirstAvailable = vi
|
||||
.fn()
|
||||
.mockReturnValue({ selectedModel: null, skipped: [] });
|
||||
@@ -634,9 +228,12 @@ describe('handleFallback', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('wraps around to upgrade candidates if the current model was selected mid-chain (e.g. by router)', async () => {
|
||||
it('does not wrap around to upgrade candidates if the current model was selected at the end (e.g. by router)', async () => {
|
||||
// Last-resort failure (Flash) in [Preview, Pro, Flash] checks Preview then Pro (all upstream).
|
||||
vi.spyOn(policyConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.mocked(policyConfig.getPreviewFeatures).mockReturnValue(true);
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
availability.selectFirstAvailable = vi.fn().mockReturnValue({
|
||||
selectedModel: MOCK_PRO_MODEL,
|
||||
@@ -650,43 +247,27 @@ describe('handleFallback', () => {
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(availability.selectFirstAvailable).toHaveBeenCalledWith([
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
MOCK_PRO_MODEL,
|
||||
]);
|
||||
expect(availability.selectFirstAvailable).not.toHaveBeenCalled();
|
||||
expect(policyHandler).toHaveBeenCalledWith(
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
MOCK_PRO_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('logs and returns null when handler resolves to null', async () => {
|
||||
policyHandler.mockResolvedValue(null);
|
||||
const debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error');
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Fallback handler failed:',
|
||||
new Error(
|
||||
'Unexpected fallback intent received from fallbackModelHandler: "null"',
|
||||
),
|
||||
);
|
||||
debugLoggerErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('successfully follows expected availability response for Preview Chain', async () => {
|
||||
availability.selectFirstAvailable = vi
|
||||
.fn()
|
||||
.mockReturnValue({ selectedModel: DEFAULT_GEMINI_MODEL, skipped: [] });
|
||||
availability.selectFirstAvailable = vi.fn().mockReturnValue({
|
||||
selectedModel: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
skipped: [],
|
||||
});
|
||||
policyHandler.mockResolvedValue('retry_once');
|
||||
vi.spyOn(policyConfig, 'getPreviewFeatures').mockReturnValue(true);
|
||||
vi.spyOn(policyConfig, 'getModel').mockReturnValue(PREVIEW_GEMINI_MODEL);
|
||||
vi.mocked(policyConfig.getPreviewFeatures).mockReturnValue(true);
|
||||
vi.mocked(policyConfig.getActiveModel).mockReturnValue(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
@@ -696,21 +277,112 @@ describe('handleFallback', () => {
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(availability.selectFirstAvailable).toHaveBeenCalledWith([
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
]);
|
||||
expect(policyHandler).toHaveBeenCalledWith(
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
undefined,
|
||||
});
|
||||
|
||||
it('should launch upgrade flow and avoid fallback mode when handler returns "upgrade"', async () => {
|
||||
policyHandler.mockResolvedValue('upgrade');
|
||||
vi.mocked(openBrowserSecurely).mockResolvedValue(undefined);
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(openBrowserSecurely).toHaveBeenCalledWith(
|
||||
'https://goo.gle/set-up-gemini-code-assist',
|
||||
);
|
||||
expect(policyConfig.setActiveModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should catch errors from the handler, log an error, and return null', async () => {
|
||||
const handlerError = new Error('UI interaction failed');
|
||||
policyHandler.mockRejectedValue(handlerError);
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(debugLogger.error).toHaveBeenCalledWith(
|
||||
'Fallback handler failed:',
|
||||
handlerError,
|
||||
);
|
||||
});
|
||||
|
||||
it('short-circuits when the failed model is the last-resort policy AND candidates are unavailable', async () => {
|
||||
it('should pass TerminalQuotaError (429) correctly to the handler', async () => {
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const terminalError = new TerminalQuotaError(
|
||||
'Quota error',
|
||||
mockGoogleApiError,
|
||||
5,
|
||||
);
|
||||
policyHandler.mockResolvedValue('retry_always');
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
terminalError,
|
||||
);
|
||||
|
||||
expect(policyHandler).toHaveBeenCalledWith(
|
||||
MOCK_PRO_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
terminalError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass RetryableQuotaError correctly to the handler', async () => {
|
||||
const mockGoogleApiError = {
|
||||
code: 503,
|
||||
message: 'mock error',
|
||||
details: [],
|
||||
};
|
||||
const retryableError = new RetryableQuotaError(
|
||||
'Service unavailable',
|
||||
mockGoogleApiError,
|
||||
1000,
|
||||
);
|
||||
policyHandler.mockResolvedValue('retry_once');
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
retryableError,
|
||||
);
|
||||
|
||||
expect(policyHandler).toHaveBeenCalledWith(
|
||||
MOCK_PRO_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
retryableError,
|
||||
);
|
||||
});
|
||||
|
||||
it('Call the handler with fallback model same as the failed model when the failed model is the last-resort policy', async () => {
|
||||
// Ensure short-circuit when wrapping to an unavailable upstream model.
|
||||
availability.selectFirstAvailable = vi
|
||||
.fn()
|
||||
.mockReturnValue({ selectedModel: null, skipped: [] });
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
@@ -718,14 +390,21 @@ describe('handleFallback', () => {
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
// Service called to check upstream; no UI handler since nothing selected.
|
||||
expect(policyConfig.getModelAvailabilityService).toHaveBeenCalled();
|
||||
expect(policyConfig.getFallbackModelHandler).not.toHaveBeenCalled();
|
||||
policyHandler.mockResolvedValue('retry_once');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(policyHandler).toHaveBeenCalledWith(
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls setActiveModel and logs telemetry when handler returns "retry_always"', async () => {
|
||||
policyHandler.mockResolvedValue('retry_always');
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
@@ -739,7 +418,7 @@ describe('handleFallback', () => {
|
||||
// TODO: add logging expect statement
|
||||
});
|
||||
|
||||
it('calls setActiveModel when handler returns "stop"', async () => {
|
||||
it('does NOT call setActiveModel when handler returns "stop"', async () => {
|
||||
policyHandler.mockResolvedValue('stop');
|
||||
|
||||
const result = await handleFallback(
|
||||
@@ -749,8 +428,21 @@ describe('handleFallback', () => {
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(policyConfig.setActiveModel).toHaveBeenCalledWith(FALLBACK_MODEL);
|
||||
expect(policyConfig.setActiveModel).not.toHaveBeenCalled();
|
||||
// TODO: add logging expect statement
|
||||
});
|
||||
|
||||
it('does NOT call setActiveModel when handler returns "retry_once"', async () => {
|
||||
policyHandler.mockResolvedValue('retry_once');
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
MOCK_PRO_MODEL,
|
||||
AUTH_OAUTH,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(policyConfig.setActiveModel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,24 +6,16 @@
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { logFlashFallback, FlashFallbackEvent } from '../telemetry/index.js';
|
||||
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { ModelNotFoundError } from '../utils/httpErrors.js';
|
||||
import { TerminalQuotaError } from '../utils/googleQuotaErrors.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import type { FallbackIntent, FallbackRecommendation } from './types.js';
|
||||
import { classifyFailureKind } from '../availability/errorClassification.js';
|
||||
import {
|
||||
buildFallbackPolicyContext,
|
||||
resolvePolicyChain,
|
||||
resolvePolicyAction,
|
||||
applyAvailabilityTransition,
|
||||
} from '../availability/policyHelpers.js';
|
||||
|
||||
const UPGRADE_URL_PAGE = 'https://goo.gle/set-up-gemini-code-assist';
|
||||
@@ -34,75 +26,7 @@ export async function handleFallback(
|
||||
authType?: string,
|
||||
error?: unknown,
|
||||
): Promise<string | boolean | null> {
|
||||
if (config.isModelAvailabilityServiceEnabled()) {
|
||||
return handlePolicyDrivenFallback(config, failedModel, authType, error);
|
||||
}
|
||||
return legacyHandleFallback(config, failedModel, authType, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Old fallback logic relying on hard coded strings
|
||||
*/
|
||||
async function legacyHandleFallback(
|
||||
config: Config,
|
||||
failedModel: string,
|
||||
authType?: string,
|
||||
error?: unknown,
|
||||
): Promise<string | boolean | null> {
|
||||
if (authType !== AuthType.LOGIN_WITH_GOOGLE) return null;
|
||||
|
||||
// Guardrail: If it's a ModelNotFoundError but NOT the preview model, do not handle it.
|
||||
if (
|
||||
error instanceof ModelNotFoundError &&
|
||||
failedModel !== PREVIEW_GEMINI_MODEL
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const shouldActivatePreviewFallback =
|
||||
failedModel === PREVIEW_GEMINI_MODEL &&
|
||||
!(error instanceof TerminalQuotaError);
|
||||
// Preview Model Specific Logic
|
||||
if (shouldActivatePreviewFallback) {
|
||||
// Always set bypass mode for the immediate retry, for non-TerminalQuotaErrors.
|
||||
// This ensures the next attempt uses 2.5 Pro.
|
||||
config.setPreviewModelBypassMode(true);
|
||||
|
||||
// If we are already in Preview Model fallback mode (user previously said "Always"),
|
||||
// we silently retry (which will use 2.5 Pro due to bypass mode).
|
||||
if (config.isPreviewModelFallbackMode()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackModel = shouldActivatePreviewFallback
|
||||
? DEFAULT_GEMINI_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_MODEL;
|
||||
|
||||
// Consult UI Handler for Intent
|
||||
const fallbackModelHandler = config.fallbackModelHandler;
|
||||
if (typeof fallbackModelHandler !== 'function') return null;
|
||||
|
||||
try {
|
||||
// Pass the specific failed model to the UI handler.
|
||||
const intent = await fallbackModelHandler(
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
error,
|
||||
);
|
||||
|
||||
// Process Intent and Update State
|
||||
return await processIntent(
|
||||
config,
|
||||
intent,
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
authType,
|
||||
error,
|
||||
);
|
||||
} catch (handlerError) {
|
||||
console.error('Fallback UI handler failed:', handlerError);
|
||||
return null;
|
||||
}
|
||||
return handlePolicyDrivenFallback(config, failedModel, authType, error);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,50 +49,56 @@ async function handlePolicyDrivenFallback(
|
||||
);
|
||||
|
||||
const failureKind = classifyFailureKind(error);
|
||||
|
||||
if (!candidates.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const availability = config.getModelAvailabilityService();
|
||||
const selection = availability.selectFirstAvailable(
|
||||
candidates.map((policy) => policy.model),
|
||||
);
|
||||
|
||||
const lastResortPolicy = candidates.find((policy) => policy.isLastResort);
|
||||
const fallbackModel = selection.selectedModel ?? lastResortPolicy?.model;
|
||||
const selectedPolicy = candidates.find(
|
||||
(policy) => policy.model === fallbackModel,
|
||||
);
|
||||
|
||||
if (!fallbackModel || fallbackModel === failedModel || !selectedPolicy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// failureKind is already declared and calculated above
|
||||
const action = resolvePolicyAction(failureKind, selectedPolicy);
|
||||
|
||||
if (action === 'silent') {
|
||||
return processIntent(
|
||||
config,
|
||||
'retry_always',
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
authType,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// This will be used in the future when FallbackRecommendation is passed through UI
|
||||
const recommendation: FallbackRecommendation = {
|
||||
...selection,
|
||||
selectedModel: fallbackModel,
|
||||
action,
|
||||
failureKind,
|
||||
failedPolicy,
|
||||
selectedPolicy,
|
||||
const getAvailabilityContext = () => {
|
||||
if (!failedPolicy) return undefined;
|
||||
return { service: availability, policy: failedPolicy };
|
||||
};
|
||||
void recommendation;
|
||||
|
||||
let fallbackModel: string;
|
||||
if (!candidates.length) {
|
||||
fallbackModel = failedModel;
|
||||
} else {
|
||||
const selection = availability.selectFirstAvailable(
|
||||
candidates.map((policy) => policy.model),
|
||||
);
|
||||
|
||||
const lastResortPolicy = candidates.find((policy) => policy.isLastResort);
|
||||
const selectedFallbackModel =
|
||||
selection.selectedModel ?? lastResortPolicy?.model;
|
||||
const selectedPolicy = candidates.find(
|
||||
(policy) => policy.model === selectedFallbackModel,
|
||||
);
|
||||
|
||||
if (
|
||||
!selectedFallbackModel ||
|
||||
selectedFallbackModel === failedModel ||
|
||||
!selectedPolicy
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
fallbackModel = selectedFallbackModel;
|
||||
|
||||
// failureKind is already declared and calculated above
|
||||
const action = resolvePolicyAction(failureKind, selectedPolicy);
|
||||
|
||||
if (action === 'silent') {
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
return processIntent(config, 'retry_always', fallbackModel);
|
||||
}
|
||||
|
||||
// This will be used in the future when FallbackRecommendation is passed through UI
|
||||
const recommendation: FallbackRecommendation = {
|
||||
...selection,
|
||||
selectedModel: fallbackModel,
|
||||
action,
|
||||
failureKind,
|
||||
failedPolicy,
|
||||
selectedPolicy,
|
||||
};
|
||||
void recommendation;
|
||||
}
|
||||
|
||||
const handler = config.getFallbackModelHandler();
|
||||
if (typeof handler !== 'function') {
|
||||
@@ -177,14 +107,16 @@ async function handlePolicyDrivenFallback(
|
||||
|
||||
try {
|
||||
const intent = await handler(failedModel, fallbackModel, error);
|
||||
return await processIntent(
|
||||
config,
|
||||
intent,
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
authType,
|
||||
error, // Pass the error so processIntent can handle preview-specific logic
|
||||
);
|
||||
|
||||
// If the user chose to switch/retry, we apply the availability transition
|
||||
// to the failed model (e.g. marking it terminal if it had a quota error).
|
||||
// We DO NOT apply it if the user chose 'stop' or 'retry_later', allowing
|
||||
// them to try again later with the same model state.
|
||||
if (intent === 'retry_always' || intent === 'retry_once') {
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
}
|
||||
|
||||
return await processIntent(config, intent, fallbackModel);
|
||||
} catch (handlerError) {
|
||||
debugLogger.error('Fallback handler failed:', handlerError);
|
||||
return null;
|
||||
@@ -205,47 +137,23 @@ async function handleUpgrade() {
|
||||
async function processIntent(
|
||||
config: Config,
|
||||
intent: FallbackIntent | null,
|
||||
failedModel: string,
|
||||
fallbackModel: string,
|
||||
authType?: string,
|
||||
error?: unknown,
|
||||
): Promise<boolean> {
|
||||
const isAvailabilityEnabled = config.isModelAvailabilityServiceEnabled();
|
||||
|
||||
switch (intent) {
|
||||
case 'retry_always':
|
||||
if (isAvailabilityEnabled) {
|
||||
// TODO(telemetry): Implement generic fallback event logging. Existing
|
||||
// logFlashFallback is specific to a single Model.
|
||||
config.setActiveModel(fallbackModel);
|
||||
} else {
|
||||
// If the error is non-retryable, e.g. TerminalQuota Error, trigger a regular fallback to flash.
|
||||
// For all other errors, activate previewModel fallback.
|
||||
if (
|
||||
failedModel === PREVIEW_GEMINI_MODEL &&
|
||||
!(error instanceof TerminalQuotaError)
|
||||
) {
|
||||
activatePreviewModelFallbackMode(config);
|
||||
} else {
|
||||
activateFallbackMode(config, authType);
|
||||
}
|
||||
}
|
||||
// TODO(telemetry): Implement generic fallback event logging. Existing
|
||||
// logFlashFallback is specific to a single Model.
|
||||
config.setActiveModel(fallbackModel);
|
||||
return true;
|
||||
|
||||
case 'retry_once':
|
||||
if (isAvailabilityEnabled) {
|
||||
config.setActiveModel(fallbackModel);
|
||||
}
|
||||
// For distinct retry (retry_once), we do NOT set the active model permanently.
|
||||
// The FallbackStrategy will handle routing to the available model for this turn
|
||||
// based on the availability service state (which is updated before this).
|
||||
return true;
|
||||
|
||||
case 'stop':
|
||||
if (isAvailabilityEnabled) {
|
||||
// TODO(telemetry): Implement generic fallback event logging. Existing
|
||||
// logFlashFallback is specific to a single Model.
|
||||
config.setActiveModel(fallbackModel);
|
||||
} else {
|
||||
activateFallbackMode(config, authType);
|
||||
}
|
||||
// Do not switch model on stop. User wants to stay on current model (and stop).
|
||||
return false;
|
||||
|
||||
case 'retry_later':
|
||||
@@ -261,20 +169,3 @@ async function processIntent(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function activateFallbackMode(config: Config, authType: string | undefined) {
|
||||
if (!config.isInFallbackMode()) {
|
||||
config.setFallbackMode(true);
|
||||
coreEvents.emitFallbackModeChanged(true);
|
||||
if (authType) {
|
||||
logFlashFallback(config, new FlashFallbackEvent(authType));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function activatePreviewModelFallbackMode(config: Config) {
|
||||
if (!config.isPreviewModelFallbackMode()) {
|
||||
config.setPreviewModelFallbackMode(true);
|
||||
// We might want a specific event for Preview Model fallback, but for now we just set the mode.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -324,7 +325,7 @@ describe('classifyGoogleError', () => {
|
||||
expect(result).toBeInstanceOf(TerminalQuotaError);
|
||||
});
|
||||
|
||||
it('should return original error for 429 without specific details', () => {
|
||||
it('should return RetryableQuotaError for any 429', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 429,
|
||||
message: 'Too many requests',
|
||||
@@ -339,6 +340,113 @@ describe('classifyGoogleError', () => {
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const originalError = new Error();
|
||||
const result = classifyGoogleError(originalError);
|
||||
expect(result).toBe(originalError);
|
||||
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||
if (result instanceof RetryableQuotaError) {
|
||||
expect(result.retryDelayMs).toBe(5000);
|
||||
}
|
||||
});
|
||||
|
||||
it('should classify nested JSON string 404 error as ModelNotFoundError', () => {
|
||||
// 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: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should return RetryableQuotaError with 5s fallback for generic 429 without specific message', () => {
|
||||
const generic429 = {
|
||||
status: 429,
|
||||
message: 'Resource exhausted. No specific retry info.',
|
||||
};
|
||||
|
||||
const result = classifyGoogleError(generic429);
|
||||
|
||||
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||
if (result instanceof RetryableQuotaError) {
|
||||
expect(result.retryDelayMs).toBe(5000);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return RetryableQuotaError with 5s fallback for 429 with empty details and no regex match', () => {
|
||||
const errorWithEmptyDetails = {
|
||||
error: {
|
||||
code: 429,
|
||||
message: 'A generic 429 error with no retry message.',
|
||||
details: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = classifyGoogleError(errorWithEmptyDetails);
|
||||
|
||||
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||
if (result instanceof RetryableQuotaError) {
|
||||
expect(result.retryDelayMs).toBe(5000);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return RetryableQuotaError with 5s fallback for 429 with some detail', () => {
|
||||
const errorWithEmptyDetails = {
|
||||
error: {
|
||||
code: 429,
|
||||
message: 'A generic 429 error with no retry message.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'QUOTA_EXCEEDED',
|
||||
domain: 'googleapis.com',
|
||||
metadata: {
|
||||
quota_limit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = classifyGoogleError(errorWithEmptyDetails);
|
||||
|
||||
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||
if (result instanceof RetryableQuotaError) {
|
||||
expect(result.retryDelayMs).toBe(5000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
import { parseGoogleApiError } from './googleErrors.js';
|
||||
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
|
||||
|
||||
const DEFAULT_RETRYABLE_DELAY_SECOND = 5;
|
||||
|
||||
/**
|
||||
* A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit).
|
||||
*/
|
||||
@@ -89,9 +91,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]);
|
||||
@@ -106,9 +114,21 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
retryDelaySeconds,
|
||||
);
|
||||
}
|
||||
} else if (status === 429) {
|
||||
// Fallback: If it is a 429 but doesn't have a specific "retry in" message,
|
||||
// assume it is a temporary rate limit and retry after 5 sec (same as DEFAULT_RETRY_OPTIONS).
|
||||
return new RetryableQuotaError(
|
||||
errorMessage,
|
||||
googleApiError ?? {
|
||||
code: 429,
|
||||
message: errorMessage,
|
||||
details: [],
|
||||
},
|
||||
DEFAULT_RETRYABLE_DELAY_SECOND,
|
||||
);
|
||||
}
|
||||
|
||||
return error; // Not a 429 error we can handle.
|
||||
return error; // Not a 429 error we can handle with structured details or a parsable retry message.
|
||||
}
|
||||
|
||||
const quotaFailure = googleApiError.details.find(
|
||||
@@ -226,5 +246,21 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If we reached this point and the status is still 429, we return retryable.
|
||||
if (status === 429) {
|
||||
const errorMessage =
|
||||
googleApiError?.message ||
|
||||
(error instanceof Error ? error.message : String(error));
|
||||
return new RetryableQuotaError(
|
||||
errorMessage,
|
||||
googleApiError ?? {
|
||||
code: 429,
|
||||
message: errorMessage,
|
||||
details: [],
|
||||
},
|
||||
DEFAULT_RETRYABLE_DELAY_SECOND,
|
||||
);
|
||||
}
|
||||
return error; // Fallback to original error if no specific classification fits.
|
||||
}
|
||||
|
||||
@@ -464,7 +464,7 @@ describe('retryWithBackoff', () => {
|
||||
});
|
||||
|
||||
it.each([[AuthType.USE_GEMINI], [AuthType.USE_VERTEX_AI], [undefined]])(
|
||||
'should not trigger fallback for non-Google auth users (authType: %s) on TerminalQuotaError',
|
||||
'should invoke onPersistent429 callback (delegating decision) for non-Google auth users (authType: %s) on TerminalQuotaError',
|
||||
async (authType) => {
|
||||
const fallbackCallback = vi.fn();
|
||||
const mockFn = vi.fn().mockImplementation(async () => {
|
||||
@@ -478,7 +478,7 @@ describe('retryWithBackoff', () => {
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow('Daily limit reached');
|
||||
expect(fallbackCallback).not.toHaveBeenCalled();
|
||||
expect(fallbackCallback).toHaveBeenCalled();
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
@@ -629,20 +629,10 @@ describe('retryWithBackoff', () => {
|
||||
).rejects.toThrow(TerminalQuotaError);
|
||||
|
||||
// Verify failures
|
||||
expect(mockService.markTerminal).toHaveBeenCalledWith('model-1', 'quota');
|
||||
expect(mockService.markTerminal).toHaveBeenCalledWith('model-2', 'quota');
|
||||
expect(mockService.markTerminal).not.toHaveBeenCalled();
|
||||
expect(mockService.markTerminal).not.toHaveBeenCalled();
|
||||
|
||||
// Verify sequences
|
||||
expect(mockService.markTerminal).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'model-1',
|
||||
'quota',
|
||||
);
|
||||
expect(mockService.markTerminal).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'model-2',
|
||||
'quota',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks sticky_retry after retries are exhausted for transient failures', async () => {
|
||||
@@ -671,8 +661,8 @@ describe('retryWithBackoff', () => {
|
||||
expect(result).toBe(transientError);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
expect(mockService.markRetryOncePerTurn).toHaveBeenCalledWith('model-1');
|
||||
expect(mockService.markRetryOncePerTurn).toHaveBeenCalledTimes(1);
|
||||
expect(mockService.markRetryOncePerTurn).not.toHaveBeenCalled();
|
||||
expect(mockService.markRetryOncePerTurn).not.toHaveBeenCalled();
|
||||
expect(mockService.markTerminal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -710,29 +700,7 @@ describe('retryWithBackoff', () => {
|
||||
maxAttempts: 1,
|
||||
getAvailabilityContext: getContext,
|
||||
}).catch(() => {});
|
||||
expect(mockService.markTerminal).toHaveBeenCalledWith('model-1', 'quota');
|
||||
|
||||
// Run for notFoundError
|
||||
await retryWithBackoff(fn, {
|
||||
maxAttempts: 1,
|
||||
getAvailabilityContext: getContext,
|
||||
}).catch(() => {});
|
||||
expect(mockService.markTerminal).toHaveBeenCalledWith(
|
||||
'model-1',
|
||||
'capacity',
|
||||
);
|
||||
|
||||
// Run for genericError
|
||||
await retryWithBackoff(fn, {
|
||||
maxAttempts: 1,
|
||||
getAvailabilityContext: getContext,
|
||||
}).catch(() => {});
|
||||
expect(mockService.markTerminal).toHaveBeenCalledWith(
|
||||
'model-1',
|
||||
'capacity',
|
||||
);
|
||||
|
||||
expect(mockService.markTerminal).toHaveBeenCalledTimes(3);
|
||||
expect(mockService.markTerminal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import type { GenerateContentResponse } from '@google/genai';
|
||||
import { ApiError } from '@google/genai';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import {
|
||||
TerminalQuotaError,
|
||||
RetryableQuotaError,
|
||||
@@ -16,8 +15,6 @@ import { delay, createAbortError } from './delay.js';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
|
||||
import type { RetryAvailabilityContext } from '../availability/modelPolicy.js';
|
||||
import { classifyFailureKind } from '../availability/errorClassification.js';
|
||||
import { applyAvailabilityTransition } from '../availability/policyHelpers.js';
|
||||
|
||||
export type { RetryAvailabilityContext };
|
||||
|
||||
@@ -192,12 +189,6 @@ export async function retryWithBackoff<T>(
|
||||
}
|
||||
|
||||
const classifiedError = classifyGoogleError(error);
|
||||
const failureKind = classifyFailureKind(classifiedError);
|
||||
const appliedImmediate =
|
||||
failureKind === 'terminal' || failureKind === 'not_found';
|
||||
if (appliedImmediate) {
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
}
|
||||
|
||||
const errorCode = getErrorStatus(error);
|
||||
|
||||
@@ -205,7 +196,7 @@ export async function retryWithBackoff<T>(
|
||||
classifiedError instanceof TerminalQuotaError ||
|
||||
classifiedError instanceof ModelNotFoundError
|
||||
) {
|
||||
if (onPersistent429 && authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
if (onPersistent429) {
|
||||
try {
|
||||
const fallbackModel = await onPersistent429(
|
||||
authType,
|
||||
@@ -229,7 +220,7 @@ export async function retryWithBackoff<T>(
|
||||
|
||||
if (classifiedError instanceof RetryableQuotaError || is500) {
|
||||
if (attempt >= maxAttempts) {
|
||||
if (onPersistent429 && authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
if (onPersistent429) {
|
||||
try {
|
||||
const fallbackModel = await onPersistent429(
|
||||
authType,
|
||||
@@ -244,9 +235,6 @@ export async function retryWithBackoff<T>(
|
||||
console.warn('Model fallback failed:', fallbackError);
|
||||
}
|
||||
}
|
||||
if (!appliedImmediate) {
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
}
|
||||
throw classifiedError instanceof RetryableQuotaError
|
||||
? classifiedError
|
||||
: error;
|
||||
@@ -276,9 +264,6 @@ export async function retryWithBackoff<T>(
|
||||
attempt >= maxAttempts ||
|
||||
!shouldRetryOnError(error as Error, retryFetchErrors)
|
||||
) {
|
||||
if (!appliedImmediate) {
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.3",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.3",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user