mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e5fc1663 | |||
| 3815de798f | |||
| 50947c57ce | |||
| ac3c643035 | |||
| 29e8f2abf4 | |||
| bf278ef2b0 | |||
| 1f9da6723f | |||
| 3ff5cfaaf6 | |||
| 70b650122f | |||
| 16d3883642 | |||
| 5c23f7f6e0 | |||
| d47d4855db |
@@ -20,8 +20,7 @@ async function run(cmd) {
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (_e) {
|
||||
// eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +382,7 @@ See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
|
||||
## 📄 Legal
|
||||
|
||||
- **License**: [Apache License 2.0](LICENSE)
|
||||
- **Terms of Service**: [Terms & Privacy](./docs/tos-privacy.md)
|
||||
- **Terms of Service**: [Terms & Privacy](./docs/resources/tos-privacy.md)
|
||||
- **Security**: [Security Policy](SECURITY.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,6 +27,7 @@ implementation. It allows you to:
|
||||
- [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode)
|
||||
- [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
|
||||
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
|
||||
- [Automatic Model Routing](#automatic-model-routing)
|
||||
|
||||
## Enabling Plan Mode
|
||||
|
||||
@@ -242,6 +243,32 @@ modes = ["plan"]
|
||||
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
|
||||
```
|
||||
|
||||
## Automatic Model Routing
|
||||
|
||||
When using an [**auto model**], Gemini CLI automatically optimizes [**model
|
||||
routing**] based on the current phase of your task:
|
||||
|
||||
1. **Planning Phase:** While in Plan Mode, the CLI routes requests to a
|
||||
high-reasoning **Pro** model to ensure robust architectural decisions and
|
||||
high-quality plans.
|
||||
2. **Implementation Phase:** Once a plan is approved and you exit Plan Mode,
|
||||
the CLI detects the existence of the approved plan and automatically
|
||||
switches to a high-speed **Flash** model. This provides a faster, more
|
||||
responsive experience during the implementation of the plan.
|
||||
|
||||
This behavior is enabled by default to provide the best balance of quality and
|
||||
performance. You can disable this automatic switching in your settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"plan": {
|
||||
"modelRouting": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
|
||||
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
|
||||
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
|
||||
@@ -259,3 +286,5 @@ argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-
|
||||
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
|
||||
[`plan.toml`]:
|
||||
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
|
||||
[auto model]: /docs/reference/configuration.md#model-settings
|
||||
[model routing]: /docs/cli/telemetry.md#model-routing
|
||||
|
||||
@@ -29,6 +29,7 @@ they appear in the UI.
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
|
||||
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
|
||||
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
|
||||
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
|
||||
|
||||
@@ -487,6 +487,7 @@ Captures Gemini API requests, responses, and errors.
|
||||
- `reasoning` (string, optional)
|
||||
- `failed` (boolean)
|
||||
- `error_message` (string, optional)
|
||||
- `approval_mode` (string)
|
||||
|
||||
#### Chat and streaming
|
||||
|
||||
@@ -711,12 +712,14 @@ Routing latency/failures and slash-command selections.
|
||||
- **Attributes**:
|
||||
- `routing.decision_model` (string)
|
||||
- `routing.decision_source` (string)
|
||||
- `routing.approval_mode` (string)
|
||||
|
||||
- `gemini_cli.model_routing.failure.count` (Counter, Int): Counts model routing
|
||||
failures.
|
||||
- **Attributes**:
|
||||
- `routing.decision_source` (string)
|
||||
- `routing.error_message` (string)
|
||||
- `routing.approval_mode` (string)
|
||||
|
||||
##### Agent runs
|
||||
|
||||
|
||||
+24
-25
@@ -21,8 +21,10 @@ Jump in to Gemini CLI.
|
||||
personal and enterprise accounts.
|
||||
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
|
||||
action.
|
||||
- **[Cheatsheet](./cli/cli-reference.md):** A quick reference for common
|
||||
- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
|
||||
commands and options.
|
||||
- **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn about Gemini 3
|
||||
support in Gemini CLI.
|
||||
|
||||
## Use Gemini CLI
|
||||
|
||||
@@ -50,33 +52,29 @@ User-focused guides and tutorials for daily development workflows.
|
||||
|
||||
Technical documentation for each capability of Gemini CLI.
|
||||
|
||||
- **[Activate skill (tool)](./tools/activate-skill.md):** Internal mechanism for
|
||||
loading expert procedures.
|
||||
- **[Ask user (tool)](./tools/ask-user.md):** Internal dialog system for
|
||||
clarification.
|
||||
- **[Checkpointing](./cli/checkpointing.md):** Automatic session snapshots.
|
||||
- **[File system (tool)](./tools/file-system.md):** Technical details for local
|
||||
file operations.
|
||||
- **[Headless mode](./cli/headless.md):** Programmatic and scripting interface.
|
||||
- **[Internal documentation (tool)](./tools/internal-docs.md):** Technical
|
||||
lookup for CLI features.
|
||||
- **[Memory (tool)](./tools/memory.md):** Storage details for persistent facts.
|
||||
- **[Model routing](./cli/model-routing.md):** Automatic fallback resilience.
|
||||
- **[Plan mode 🧪](./cli/plan-mode.md):** Use a safe, read-only mode for
|
||||
planning complex changes.
|
||||
- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific
|
||||
- **[Extensions](./extensions/index.md):** Extend Gemini CLI with new tools and
|
||||
capabilities.
|
||||
- **[Agent Skills](./cli/skills.md):** Use specialized agents for specific
|
||||
tasks.
|
||||
- **[Remote subagents 🧪](./core/remote-agents.md):** Connecting to and using
|
||||
- **[Checkpointing](./cli/checkpointing.md):** Automatic session snapshots.
|
||||
- **[Headless mode](./cli/headless.md):** Programmatic and scripting interface.
|
||||
- **[Hooks](./hooks/index.md):** Customize Gemini CLI behavior with scripts.
|
||||
- **[IDE integration](./ide-integration/index.md):** Integrate Gemini CLI with
|
||||
your favorite IDE.
|
||||
- **[MCP servers](./tools/mcp-server.md):** Connect to and use remote agents.
|
||||
- **[Model routing](./cli/model-routing.md):** Automatic fallback resilience.
|
||||
- **[Model selection](./cli/model.md):** Choose the best model for your needs.
|
||||
- **[Plan mode 🔬](./cli/plan-mode.md):** Use a safe, read-only mode for
|
||||
planning complex changes.
|
||||
- **[Subagents 🔬](./core/subagents.md):** Using specialized agents for specific
|
||||
tasks.
|
||||
- **[Remote subagents 🔬](./core/remote-agents.md):** Connecting to and using
|
||||
remote agents.
|
||||
- **[Rewind](./cli/rewind.md):** Rewind and replay sessions.
|
||||
- **[Sandboxing](./cli/sandbox.md):** Isolate tool execution.
|
||||
- **[Shell (tool)](./tools/shell.md):** Detailed system execution parameters.
|
||||
- **[Settings](./cli/settings.md):** Full configuration reference.
|
||||
- **[Telemetry](./cli/telemetry.md):** Usage and performance metric details.
|
||||
- **[Todo (tool)](./tools/todos.md):** Progress tracking specification.
|
||||
- **[Token caching](./cli/token-caching.md):** Performance optimization.
|
||||
- **[Web fetch (tool)](./tools/web-fetch.md):** URL retrieval and extraction
|
||||
details.
|
||||
- **[Web search (tool)](./tools/web-search.md):** Google Search integration
|
||||
technicals.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -91,7 +89,6 @@ Settings and customization options for Gemini CLI.
|
||||
parameters like temperature and thinking budget.
|
||||
- **[Project context (GEMINI.md)](./cli/gemini-md.md):** Technical hierarchy of
|
||||
context files.
|
||||
- **[Settings](./cli/settings.md):** Full configuration reference.
|
||||
- **[System prompt override](./cli/system-prompt.md):** Instruction replacement
|
||||
logic.
|
||||
- **[Themes](./cli/themes.md):** UI personalization technical guide.
|
||||
@@ -119,11 +116,13 @@ Deep technical documentation and API specifications.
|
||||
Support, release history, and legal information.
|
||||
|
||||
- **[FAQ](./resources/faq.md):** Answers to frequently asked questions.
|
||||
- **[Changelogs](./changelogs/index.md):** Highlights and notable changes.
|
||||
- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing
|
||||
details.
|
||||
- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and
|
||||
terms.
|
||||
- **[Troubleshooting](./resources/troubleshooting.md):** Common issues and
|
||||
solutions.
|
||||
- **[Uninstall](./resources/uninstall.md):** How to uninstall Gemini CLI.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -137,6 +137,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`general.plan.modelRouting`** (boolean):
|
||||
- **Description:** Automatically switch between Pro and Flash models based on
|
||||
Plan Mode status. Uses Pro for the planning phase and Flash for the
|
||||
implementation phase.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`general.retryFetchErrors`** (boolean):
|
||||
- **Description:** Retry on "exception TypeError: fetch failed sending
|
||||
request" errors.
|
||||
|
||||
+32
-39
@@ -61,31 +61,53 @@
|
||||
{
|
||||
"label": "Features",
|
||||
"items": [
|
||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Authentication",
|
||||
"slug": "docs/get-started/authentication"
|
||||
},
|
||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||
{
|
||||
"label": "Extensions",
|
||||
"slug": "docs/extensions/index"
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{
|
||||
"label": "Overview",
|
||||
"slug": "docs/extensions"
|
||||
},
|
||||
{
|
||||
"label": "User guide: Install and manage",
|
||||
"link": "/docs/extensions/#manage-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Build extensions",
|
||||
"slug": "docs/extensions/writing-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Best practices",
|
||||
"slug": "docs/extensions/best-practices"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Releasing",
|
||||
"slug": "docs/extensions/releasing"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Reference",
|
||||
"slug": "docs/extensions/reference"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
||||
{ "label": "Hooks", "slug": "docs/hooks" },
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
{ "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" },
|
||||
{ "label": "Plan mode", "badge": "🔬", "slug": "docs/cli/plan-mode" },
|
||||
{
|
||||
"label": "Subagents",
|
||||
"badge": "🧪",
|
||||
"badge": "🔬",
|
||||
"slug": "docs/core/subagents"
|
||||
},
|
||||
{
|
||||
"label": "Remote subagents",
|
||||
"badge": "🧪",
|
||||
"badge": "🔬",
|
||||
"slug": "docs/core/remote-agents"
|
||||
},
|
||||
{ "label": "Rewind", "slug": "docs/cli/rewind" },
|
||||
@@ -124,35 +146,6 @@
|
||||
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Extensions",
|
||||
"items": [
|
||||
{
|
||||
"label": "Overview",
|
||||
"slug": "docs/extensions"
|
||||
},
|
||||
{
|
||||
"label": "User guide: Install and manage",
|
||||
"link": "/docs/extensions/#manage-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Build extensions",
|
||||
"slug": "docs/extensions/writing-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Best practices",
|
||||
"slug": "docs/extensions/best-practices"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Releasing",
|
||||
"slug": "docs/extensions/releasing"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Reference",
|
||||
"slug": "docs/extensions/reference"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Development",
|
||||
"items": [
|
||||
|
||||
+2
-2
@@ -82,7 +82,7 @@ const commonAliases = {
|
||||
const cliConfig = {
|
||||
...baseConfig,
|
||||
banner: {
|
||||
js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
|
||||
},
|
||||
entryPoints: ['packages/cli/index.ts'],
|
||||
outfile: 'bundle/gemini.js',
|
||||
@@ -100,7 +100,7 @@ const cliConfig = {
|
||||
const a2aServerConfig = {
|
||||
...baseConfig,
|
||||
banner: {
|
||||
js: `const require = (await import('module')).createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
|
||||
},
|
||||
entryPoints: ['packages/a2a-server/src/http/server.ts'],
|
||||
outfile: 'packages/a2a-server/dist/a2a-server.mjs',
|
||||
|
||||
+1
-11
@@ -128,17 +128,7 @@ export default tseslint.config(
|
||||
],
|
||||
// Prevent async errors from bypassing catch handlers
|
||||
'@typescript-eslint/return-await': ['error', 'in-try-catch'],
|
||||
'import/no-internal-modules': [
|
||||
'error',
|
||||
{
|
||||
allow: [
|
||||
'react-dom/test-utils',
|
||||
'memfs/lib/volume.js',
|
||||
'yargs/**',
|
||||
'msw/node',
|
||||
],
|
||||
},
|
||||
],
|
||||
'import/no-internal-modules': 'off',
|
||||
'import/no-relative-packages': 'error',
|
||||
'no-cond-assign': 'error',
|
||||
'no-debugger': 'error',
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, checkModelOutputContent } from './test-helper.js';
|
||||
|
||||
describe('Plan Mode', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should allow read-only tools but deny write tools in plan mode', async () => {
|
||||
await rig.setup(
|
||||
'should allow read-only tools but deny write tools in plan mode',
|
||||
{
|
||||
settings: {
|
||||
experimental: { plan: true },
|
||||
tools: {
|
||||
core: [
|
||||
'run_shell_command',
|
||||
'list_directory',
|
||||
'write_file',
|
||||
'read_file',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// We use a prompt that asks for both a read-only action and a write action.
|
||||
// "List files" (read-only) followed by "touch denied.txt" (write).
|
||||
const result = await rig.run({
|
||||
approvalMode: 'plan',
|
||||
stdin:
|
||||
'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
|
||||
});
|
||||
|
||||
const lsCallFound = await rig.waitForToolCall('list_directory');
|
||||
expect(lsCallFound, 'Expected list_directory to be called').toBe(true);
|
||||
|
||||
const shellCallFound = await rig.waitForToolCall('run_shell_command');
|
||||
expect(shellCallFound, 'Expected run_shell_command to fail').toBe(false);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory');
|
||||
expect(
|
||||
toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(lsLog?.toolRequest.success).toBe(true);
|
||||
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: ['Plan Mode', 'read-only'],
|
||||
testName: 'Plan Mode restrictions test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow write_file only in the plans directory in plan mode', async () => {
|
||||
await rig.setup(
|
||||
'should allow write_file only in the plans directory in plan mode',
|
||||
{
|
||||
settings: {
|
||||
experimental: { plan: true },
|
||||
tools: {
|
||||
core: ['write_file', 'read_file', 'list_directory'],
|
||||
allowed: ['write_file'],
|
||||
},
|
||||
general: { defaultApprovalMode: 'plan' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory.
|
||||
// Verify that write_file outside of plan directory fails
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
stdin:
|
||||
'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const writeLogs = toolLogs.filter(
|
||||
(l) => l.toolRequest.name === 'write_file',
|
||||
);
|
||||
|
||||
const planWrite = writeLogs.find(
|
||||
(l) =>
|
||||
l.toolRequest.args.includes('plans') &&
|
||||
l.toolRequest.args.includes('plan.md'),
|
||||
);
|
||||
|
||||
const blockedWrite = writeLogs.find((l) =>
|
||||
l.toolRequest.args.includes('hello.txt'),
|
||||
);
|
||||
|
||||
// Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't
|
||||
if (blockedWrite) {
|
||||
expect(blockedWrite?.toolRequest.success).toBe(false);
|
||||
}
|
||||
|
||||
expect(planWrite?.toolRequest.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should be able to enter plan mode from default mode', async () => {
|
||||
await rig.setup('should be able to enter plan mode from default mode', {
|
||||
settings: {
|
||||
experimental: { plan: true },
|
||||
tools: {
|
||||
core: ['enter_plan_mode'],
|
||||
allowed: ['enter_plan_mode'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Start in default mode and ask to enter plan mode.
|
||||
await rig.run({
|
||||
approvalMode: 'default',
|
||||
stdin:
|
||||
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
|
||||
});
|
||||
|
||||
const enterPlanCallFound = await rig.waitForToolCall(
|
||||
'enter_plan_mode',
|
||||
10000,
|
||||
);
|
||||
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const enterLog = toolLogs.find(
|
||||
(l) => l.toolRequest.name === 'enter_plan_mode',
|
||||
);
|
||||
expect(enterLog?.toolRequest.success).toBe(true);
|
||||
});
|
||||
});
|
||||
Generated
+566
-472
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -71,7 +71,9 @@
|
||||
},
|
||||
"glob": "^12.0.0",
|
||||
"node-domexception": "npm:empty@^0.10.1",
|
||||
"prebuild-install": "npm:nop@1.0.0"
|
||||
"prebuild-install": "npm:nop@1.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"minimatch": "^10.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"gemini": "bundle/gemini.js"
|
||||
@@ -104,7 +106,7 @@
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-headers": "^1.3.3",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"glob": "^12.0.0",
|
||||
|
||||
@@ -9,7 +9,11 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
type CommandHookConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
@@ -248,9 +252,11 @@ System using model: \${MODEL_NAME}
|
||||
|
||||
expect(extension.hooks).toBeDefined();
|
||||
expect(extension.hooks?.BeforeTool).toHaveLength(1);
|
||||
expect(extension.hooks?.BeforeTool![0].hooks[0].env?.['HOOK_CMD']).toBe(
|
||||
'hello-world',
|
||||
);
|
||||
expect(
|
||||
(extension.hooks?.BeforeTool![0].hooks[0] as CommandHookConfig).env?.[
|
||||
'HOOK_CMD'
|
||||
],
|
||||
).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('should pick up new settings after restartExtension', async () => {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
CoreToolCallStatus,
|
||||
HookType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { maybeRequestConsentOrFail } from './extensions/consent.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
@@ -735,8 +736,10 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
if (eventHooks) {
|
||||
for (const definition of eventHooks) {
|
||||
for (const hook of definition.hooks) {
|
||||
// Merge existing env with new env vars, giving extension settings precedence.
|
||||
hook.env = { ...hook.env, ...hookEnv };
|
||||
if (hook.type === HookType.Command) {
|
||||
// Merge existing env with new env vars, giving extension settings precedence.
|
||||
hook.env = { ...hook.env, ...hookEnv };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@ describe('settings-validation', () => {
|
||||
expect(formatted).toContain('Expected: string, but received: object');
|
||||
expect(formatted).toContain('Please fix the configuration.');
|
||||
expect(formatted).toContain(
|
||||
'https://github.com/google-gemini/gemini-cli',
|
||||
'https://geminicli.com/docs/reference/configuration/',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -364,9 +364,8 @@ describe('settings-validation', () => {
|
||||
const formatted = formatValidationError(result.error, 'test.json');
|
||||
|
||||
expect(formatted).toContain(
|
||||
'https://github.com/google-gemini/gemini-cli',
|
||||
'https://geminicli.com/docs/reference/configuration/',
|
||||
);
|
||||
expect(formatted).toContain('configuration.md');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -327,9 +327,7 @@ export function formatValidationError(
|
||||
}
|
||||
|
||||
lines.push('Please fix the configuration.');
|
||||
lines.push(
|
||||
'See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md',
|
||||
);
|
||||
lines.push('See: https://geminicli.com/docs/reference/configuration/');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -285,6 +285,16 @@ const SETTINGS_SCHEMA = {
|
||||
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
modelRouting: {
|
||||
type: 'boolean',
|
||||
label: 'Plan Model Routing',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
retryFetchErrors: {
|
||||
|
||||
@@ -47,6 +47,8 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
isYoloModeDisabled: vi.fn(() => false),
|
||||
isPlanEnabled: vi.fn(() => false),
|
||||
getPlanModeRoutingEnabled: vi.fn().mockResolvedValue(true),
|
||||
getApprovedPlanPath: vi.fn(() => undefined),
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getAllowedTools: vi.fn(() => []),
|
||||
getApprovalMode: vi.fn(() => 'default'),
|
||||
|
||||
@@ -150,6 +150,7 @@ import { useSettings } from './contexts/SettingsContext.js';
|
||||
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
|
||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
import { useBanner } from './hooks/useBanner.js';
|
||||
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
|
||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
|
||||
import {
|
||||
@@ -606,6 +607,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
initializeFromLogger(logger);
|
||||
}, [logger, initializeFromLogger]);
|
||||
|
||||
// One-time prompt to suggest running /terminal-setup when it would help.
|
||||
useTerminalSetupPrompt({
|
||||
addConfirmUpdateExtensionRequest,
|
||||
addItem: historyManager.addItem,
|
||||
});
|
||||
|
||||
const refreshStatic = useCallback(() => {
|
||||
if (!isAlternateBuffer) {
|
||||
stdout.write(ansiEscapes.clearTerminal);
|
||||
|
||||
@@ -250,9 +250,7 @@ export function AuthDialog({
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.link}>
|
||||
{
|
||||
'https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md'
|
||||
}
|
||||
{'https://geminicli.com/docs/resources/tos-privacy/'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -15,7 +15,7 @@ exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = `
|
||||
│ │
|
||||
│ Terms of Services and Privacy Notice for Gemini CLI │
|
||||
│ │
|
||||
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
|
||||
│ https://geminicli.com/docs/resources/tos-privacy/ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
@@ -34,7 +34,7 @@ exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = `
|
||||
│ │
|
||||
│ Terms of Services and Privacy Notice for Gemini CLI │
|
||||
│ │
|
||||
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
|
||||
│ https://geminicli.com/docs/resources/tos-privacy/ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
@@ -53,7 +53,7 @@ exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`]
|
||||
│ │
|
||||
│ Terms of Services and Privacy Notice for Gemini CLI │
|
||||
│ │
|
||||
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
|
||||
│ https://geminicli.com/docs/resources/tos-privacy/ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
|
||||
@@ -23,15 +23,17 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
export const GITHUB_WORKFLOW_PATHS = [
|
||||
'gemini-dispatch/gemini-dispatch.yml',
|
||||
'gemini-assistant/gemini-invoke.yml',
|
||||
'issue-triage/gemini-triage.yml',
|
||||
'gemini-assistant/gemini-plan-execute.yml',
|
||||
'gemini-dispatch/gemini-dispatch.yml',
|
||||
'issue-triage/gemini-scheduled-triage.yml',
|
||||
'issue-triage/gemini-triage.yml',
|
||||
'pr-review/gemini-review.yml',
|
||||
];
|
||||
|
||||
export const GITHUB_COMMANDS_PATHS = [
|
||||
'gemini-assistant/gemini-invoke.toml',
|
||||
'gemini-assistant/gemini-plan-execute.toml',
|
||||
'issue-triage/gemini-scheduled-triage.toml',
|
||||
'issue-triage/gemini-triage.toml',
|
||||
'pr-review/gemini-review.toml',
|
||||
|
||||
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false* │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ > Apply To │
|
||||
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging true* │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { terminalSetup, VSCODE_SHIFT_ENTER_SEQUENCE } from './terminalSetup.js';
|
||||
import {
|
||||
terminalSetup,
|
||||
VSCODE_SHIFT_ENTER_SEQUENCE,
|
||||
shouldPromptForTerminalSetup,
|
||||
} from './terminalSetup.js';
|
||||
import { terminalCapabilityManager } from './terminalCapabilityManager.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -195,4 +200,51 @@ describe('terminalSetup', () => {
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldPromptForTerminalSetup', () => {
|
||||
it('should return false when kitty protocol is already enabled', async () => {
|
||||
vi.mocked(
|
||||
terminalCapabilityManager.isKittyProtocolEnabled,
|
||||
).mockReturnValue(true);
|
||||
|
||||
const result = await shouldPromptForTerminalSetup();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when both Shift+Enter and Ctrl+Enter bindings already exist', async () => {
|
||||
vi.mocked(
|
||||
terminalCapabilityManager.isKittyProtocolEnabled,
|
||||
).mockReturnValue(false);
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
|
||||
const existingBindings = [
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
|
||||
},
|
||||
{
|
||||
key: 'ctrl+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
|
||||
},
|
||||
];
|
||||
mocks.readFile.mockResolvedValue(JSON.stringify(existingBindings));
|
||||
|
||||
const result = await shouldPromptForTerminalSetup();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when keybindings file does not exist', async () => {
|
||||
vi.mocked(
|
||||
terminalCapabilityManager.isKittyProtocolEnabled,
|
||||
).mockReturnValue(false);
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
|
||||
mocks.readFile.mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await shouldPromptForTerminalSetup();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,13 @@ import { promisify } from 'node:util';
|
||||
import { terminalCapabilityManager } from './terminalCapabilityManager.js';
|
||||
|
||||
import { debugLogger, homedir } from '@google/gemini-cli-core';
|
||||
import { useEffect } from 'react';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
import { requestConsentInteractive } from '../../config/extensions/consent.js';
|
||||
import type { ConfirmationRequest } from '../types.js';
|
||||
import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
|
||||
type AddItemFn = UseHistoryManagerReturn['addItem'];
|
||||
|
||||
export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n';
|
||||
|
||||
@@ -54,6 +61,56 @@ export interface TerminalSetupResult {
|
||||
|
||||
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf' | 'antigravity';
|
||||
|
||||
/**
|
||||
* Terminal metadata used for configuration.
|
||||
*/
|
||||
interface TerminalData {
|
||||
terminalName: string;
|
||||
appName: string;
|
||||
}
|
||||
const TERMINAL_DATA: Record<SupportedTerminal, TerminalData> = {
|
||||
vscode: { terminalName: 'VS Code', appName: 'Code' },
|
||||
cursor: { terminalName: 'Cursor', appName: 'Cursor' },
|
||||
windsurf: { terminalName: 'Windsurf', appName: 'Windsurf' },
|
||||
antigravity: { terminalName: 'Antigravity', appName: 'Antigravity' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps a supported terminal ID to its display name and config folder name.
|
||||
*/
|
||||
function getSupportedTerminalData(
|
||||
terminal: SupportedTerminal,
|
||||
): TerminalData | null {
|
||||
return TERMINAL_DATA[terminal] || null;
|
||||
}
|
||||
|
||||
type Keybinding = {
|
||||
key?: string;
|
||||
command?: string;
|
||||
args?: { text?: string };
|
||||
};
|
||||
|
||||
function isKeybinding(kb: unknown): kb is Keybinding {
|
||||
return typeof kb === 'object' && kb !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a keybindings array contains our specific binding for a given key.
|
||||
*/
|
||||
function hasOurBinding(
|
||||
keybindings: unknown[],
|
||||
key: 'shift+enter' | 'ctrl+enter',
|
||||
): boolean {
|
||||
return keybindings.some((kb) => {
|
||||
if (!isKeybinding(kb)) return false;
|
||||
return (
|
||||
kb.key === key &&
|
||||
kb.command === 'workbench.action.terminal.sendSequence' &&
|
||||
kb.args?.text === VSCODE_SHIFT_ENTER_SEQUENCE
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function getTerminalProgram(): SupportedTerminal | null {
|
||||
const termProgram = process.env['TERM_PROGRAM'];
|
||||
|
||||
@@ -246,23 +303,17 @@ async function configureVSCodeStyle(
|
||||
|
||||
const results = targetBindings.map((target) => {
|
||||
const hasOurBinding = keybindings.some((kb) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const binding = kb as {
|
||||
command?: string;
|
||||
args?: { text?: string };
|
||||
key?: string;
|
||||
};
|
||||
if (!isKeybinding(kb)) return false;
|
||||
return (
|
||||
binding.key === target.key &&
|
||||
binding.command === target.command &&
|
||||
binding.args?.text === target.args.text
|
||||
kb.key === target.key &&
|
||||
kb.command === target.command &&
|
||||
kb.args?.text === target.args.text
|
||||
);
|
||||
});
|
||||
|
||||
const existingBinding = keybindings.find((kb) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const binding = kb as { key?: string };
|
||||
return binding.key === target.key;
|
||||
if (!isKeybinding(kb)) return false;
|
||||
return kb.key === target.key;
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -316,22 +367,57 @@ async function configureVSCodeStyle(
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal-specific configuration functions
|
||||
/**
|
||||
* Determines whether it is useful to prompt the user to run /terminal-setup
|
||||
* in the current environment.
|
||||
*
|
||||
* Returns true when:
|
||||
* - Kitty/modifyOtherKeys keyboard protocol is not already enabled, and
|
||||
* - We're running inside a supported terminal (VS Code, Cursor, Windsurf, Antigravity), and
|
||||
* - The keybindings file either does not exist or does not already contain both
|
||||
* of our Shift+Enter and Ctrl+Enter bindings.
|
||||
*/
|
||||
export async function shouldPromptForTerminalSetup(): Promise<boolean> {
|
||||
if (terminalCapabilityManager.isKittyProtocolEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function configureVSCode(): Promise<TerminalSetupResult> {
|
||||
return configureVSCodeStyle('VS Code', 'Code');
|
||||
}
|
||||
const terminal = await detectTerminal();
|
||||
if (!terminal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function configureCursor(): Promise<TerminalSetupResult> {
|
||||
return configureVSCodeStyle('Cursor', 'Cursor');
|
||||
}
|
||||
const terminalData = getSupportedTerminalData(terminal);
|
||||
if (!terminalData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function configureWindsurf(): Promise<TerminalSetupResult> {
|
||||
return configureVSCodeStyle('Windsurf', 'Windsurf');
|
||||
}
|
||||
const configDir = getVSCodeStyleConfigDir(terminalData.appName);
|
||||
if (!configDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function configureAntigravity(): Promise<TerminalSetupResult> {
|
||||
return configureVSCodeStyle('Antigravity', 'Antigravity');
|
||||
const keybindingsFile = path.join(configDir, 'keybindings.json');
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(keybindingsFile, 'utf8');
|
||||
const cleanContent = stripJsonComments(content);
|
||||
const parsedContent: unknown = JSON.parse(cleanContent) as unknown;
|
||||
|
||||
if (!Array.isArray(parsedContent)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasOurShiftEnter = hasOurBinding(parsedContent, 'shift+enter');
|
||||
const hasOurCtrlEnter = hasOurBinding(parsedContent, 'ctrl+enter');
|
||||
|
||||
return !(hasOurShiftEnter && hasOurCtrlEnter);
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`Failed to read or parse keybindings, assuming prompt is needed: ${error}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -373,19 +459,79 @@ export async function terminalSetup(): Promise<TerminalSetupResult> {
|
||||
};
|
||||
}
|
||||
|
||||
switch (terminal) {
|
||||
case 'vscode':
|
||||
return configureVSCode();
|
||||
case 'cursor':
|
||||
return configureCursor();
|
||||
case 'windsurf':
|
||||
return configureWindsurf();
|
||||
case 'antigravity':
|
||||
return configureAntigravity();
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
message: `Terminal "${terminal}" is not supported yet.`,
|
||||
};
|
||||
const terminalData = getSupportedTerminalData(terminal);
|
||||
if (!terminalData) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Terminal "${terminal}" is not supported yet.`,
|
||||
};
|
||||
}
|
||||
|
||||
return configureVSCodeStyle(terminalData.terminalName, terminalData.appName);
|
||||
}
|
||||
|
||||
export const TERMINAL_SETUP_CONSENT_MESSAGE =
|
||||
'Gemini CLI works best with Shift+Enter/Ctrl+Enter for multiline input. ' +
|
||||
'Would you like to automatically configure your terminal keybindings?';
|
||||
|
||||
export function formatTerminalSetupResultMessage(
|
||||
result: TerminalSetupResult,
|
||||
): string {
|
||||
let content = result.message;
|
||||
if (result.requiresRestart) {
|
||||
content +=
|
||||
'\n\nPlease restart your terminal for the changes to take effect.';
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
interface UseTerminalSetupPromptParams {
|
||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||
addItem: AddItemFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that shows a one-time prompt to run /terminal-setup when it would help.
|
||||
*/
|
||||
export function useTerminalSetupPrompt({
|
||||
addConfirmUpdateExtensionRequest,
|
||||
addItem,
|
||||
}: UseTerminalSetupPromptParams): void {
|
||||
useEffect(() => {
|
||||
const hasBeenPrompted = persistentState.get('terminalSetupPromptShown');
|
||||
if (hasBeenPrompted) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
(async () => {
|
||||
const shouldPrompt = await shouldPromptForTerminalSetup();
|
||||
if (!shouldPrompt || cancelled) return;
|
||||
|
||||
persistentState.set('terminalSetupPromptShown', true);
|
||||
|
||||
const confirmed = await requestConsentInteractive(
|
||||
TERMINAL_SETUP_CONSENT_MESSAGE,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
);
|
||||
|
||||
if (!confirmed || cancelled) return;
|
||||
|
||||
const result = await terminalSetup();
|
||||
if (cancelled) return;
|
||||
addItem(
|
||||
{
|
||||
type: result.success ? 'info' : 'error',
|
||||
text: formatTerminalSetupResultMessage(result),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [addConfirmUpdateExtensionRequest, addItem]);
|
||||
}
|
||||
|
||||
@@ -48,12 +48,12 @@ describe('textUtils', () => {
|
||||
it('should handle unicode characters that crash string-width', () => {
|
||||
// U+0602 caused string-width to crash (see #16418)
|
||||
const char = '';
|
||||
expect(getCachedStringWidth(char)).toBe(1);
|
||||
expect(getCachedStringWidth(char)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle unicode characters that crash string-width with ANSI codes', () => {
|
||||
const charWithAnsi = '\u001b[31m' + '' + '\u001b[0m';
|
||||
expect(getCachedStringWidth(charWithAnsi)).toBe(1);
|
||||
expect(getCachedStringWidth(charWithAnsi)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const STATE_FILENAME = 'state.json';
|
||||
|
||||
interface PersistentStateData {
|
||||
defaultBannerShownCount?: Record<string, number>;
|
||||
terminalSetupPromptShown?: boolean;
|
||||
tipsShown?: number;
|
||||
hasSeenScreenReaderNudge?: boolean;
|
||||
focusUiEnabled?: boolean;
|
||||
|
||||
@@ -177,6 +177,14 @@ describe('GeminiAgent', () => {
|
||||
|
||||
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
|
||||
expect(response.authMethods).toHaveLength(3);
|
||||
const geminiAuth = response.authMethods?.find(
|
||||
(m) => m.id === AuthType.USE_GEMINI,
|
||||
);
|
||||
expect(geminiAuth?._meta).toEqual({
|
||||
'api-key': {
|
||||
provider: 'google',
|
||||
},
|
||||
});
|
||||
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||
});
|
||||
|
||||
@@ -187,6 +195,7 @@ describe('GeminiAgent', () => {
|
||||
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
undefined,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
@@ -195,6 +204,25 @@ describe('GeminiAgent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should authenticate correctly with api-key in _meta', async () => {
|
||||
await agent.authenticate({
|
||||
methodId: AuthType.USE_GEMINI,
|
||||
_meta: {
|
||||
'api-key': 'test-api-key',
|
||||
},
|
||||
} as unknown as acp.AuthenticateRequest);
|
||||
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.USE_GEMINI,
|
||||
'test-api-key',
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new session', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
|
||||
@@ -37,12 +37,17 @@ import {
|
||||
partListUnionToString,
|
||||
LlmRole,
|
||||
ApprovalMode,
|
||||
getVersion,
|
||||
convertSessionToClientHistory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
import { getAcpErrorMessage } from './acpErrors.js';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
|
||||
function hasMeta(obj: unknown): obj is { _meta?: Record<string, unknown> } {
|
||||
return typeof obj === 'object' && obj !== null && '_meta' in obj;
|
||||
}
|
||||
import type { Content, Part, FunctionCall } from '@google/genai';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { SettingScope, loadSettings } from '../config/settings.js';
|
||||
@@ -81,6 +86,7 @@ export async function runZedIntegration(
|
||||
export class GeminiAgent {
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private clientCapabilities: acp.ClientCapabilities | undefined;
|
||||
private apiKey: string | undefined;
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
@@ -97,25 +103,35 @@ export class GeminiAgent {
|
||||
{
|
||||
id: AuthType.LOGIN_WITH_GOOGLE,
|
||||
name: 'Log in with Google',
|
||||
description: null,
|
||||
description: 'Log in with your Google account',
|
||||
},
|
||||
{
|
||||
id: AuthType.USE_GEMINI,
|
||||
name: 'Use Gemini API key',
|
||||
description:
|
||||
'Requires setting the `GEMINI_API_KEY` environment variable',
|
||||
name: 'Gemini API key',
|
||||
description: 'Use an API key with Gemini Developer API',
|
||||
_meta: {
|
||||
'api-key': {
|
||||
provider: 'google',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: AuthType.USE_VERTEX_AI,
|
||||
name: 'Vertex AI',
|
||||
description: null,
|
||||
description: 'Use an API key with Vertex AI GenAI API',
|
||||
},
|
||||
];
|
||||
|
||||
await this.config.initialize();
|
||||
const version = await getVersion();
|
||||
return {
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
authMethods,
|
||||
agentInfo: {
|
||||
name: 'gemini-cli',
|
||||
title: 'Gemini CLI',
|
||||
version,
|
||||
},
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: {
|
||||
@@ -131,7 +147,8 @@ export class GeminiAgent {
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
|
||||
async authenticate(req: acp.AuthenticateRequest): Promise<void> {
|
||||
const { methodId } = req;
|
||||
const method = z.nativeEnum(AuthType).parse(methodId);
|
||||
const selectedAuthType = this.settings.merged.security.auth.selectedType;
|
||||
|
||||
@@ -139,17 +156,21 @@ export class GeminiAgent {
|
||||
if (selectedAuthType && selectedAuthType !== method) {
|
||||
await clearCachedCredentialFile();
|
||||
}
|
||||
// Check for api-key in _meta
|
||||
const meta = hasMeta(req) ? req._meta : undefined;
|
||||
const apiKey =
|
||||
typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined;
|
||||
|
||||
// Refresh auth with the requested method
|
||||
// This will reuse existing credentials if they're valid,
|
||||
// or perform new authentication if needed
|
||||
try {
|
||||
await this.config.refreshAuth(method);
|
||||
if (apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
await this.config.refreshAuth(method, apiKey ?? this.apiKey);
|
||||
} catch (e) {
|
||||
throw new acp.RequestError(
|
||||
getErrorStatus(e) || 401,
|
||||
getAcpErrorMessage(e),
|
||||
);
|
||||
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
|
||||
}
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
@@ -177,7 +198,7 @@ export class GeminiAgent {
|
||||
let isAuthenticated = false;
|
||||
let authErrorMessage = '';
|
||||
try {
|
||||
await config.refreshAuth(authType);
|
||||
await config.refreshAuth(authType, this.apiKey);
|
||||
isAuthenticated = true;
|
||||
|
||||
// Extra validation for Gemini API key
|
||||
@@ -199,7 +220,7 @@ export class GeminiAgent {
|
||||
|
||||
if (!isAuthenticated) {
|
||||
throw new acp.RequestError(
|
||||
401,
|
||||
-32000,
|
||||
authErrorMessage || 'Authentication required.',
|
||||
);
|
||||
}
|
||||
@@ -302,7 +323,7 @@ export class GeminiAgent {
|
||||
// This satisfies the security requirement to verify the user before executing
|
||||
// potentially unsafe server definitions.
|
||||
try {
|
||||
await config.refreshAuth(selectedAuthType);
|
||||
await config.refreshAuth(selectedAuthType, this.apiKey);
|
||||
} catch (e) {
|
||||
debugLogger.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired();
|
||||
|
||||
@@ -95,6 +95,7 @@ const mockConfig = {
|
||||
getNoBrowser: () => false,
|
||||
getProxy: () => 'http://test.proxy.com:8080',
|
||||
isBrowserLaunchSuppressed: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
// Mock fetch globally
|
||||
|
||||
@@ -271,9 +271,12 @@ async function initOauthClient(
|
||||
|
||||
await triggerPostAuthCallbacks(client.credentials);
|
||||
} else {
|
||||
const userConsent = await getConsentForOauth('');
|
||||
if (!userConsent) {
|
||||
throw new FatalCancellationError('Authentication cancelled by user.');
|
||||
// In Zed integration, we skip the interactive consent and directly open the browser
|
||||
if (!config.getExperimentalZedIntegration()) {
|
||||
const userConsent = await getConsentForOauth('');
|
||||
if (!userConsent) {
|
||||
throw new FatalCancellationError('Authentication cancelled by user.');
|
||||
}
|
||||
}
|
||||
|
||||
const webLogin = await authWithWeb(client);
|
||||
|
||||
@@ -499,6 +499,7 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(createContentGeneratorConfig).toHaveBeenCalledWith(
|
||||
config,
|
||||
authType,
|
||||
undefined,
|
||||
);
|
||||
// Verify that contentGeneratorConfig is updated
|
||||
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
|
||||
@@ -1928,7 +1929,7 @@ describe('Config getHooks', () => {
|
||||
const mockHooks = {
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [{ type: HookType.Command, command: 'echo 1' }],
|
||||
hooks: [{ type: HookType.Command, command: 'echo 1' } as const],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -2235,7 +2236,7 @@ describe('Hooks configuration', () => {
|
||||
const initialHooks = {
|
||||
BeforeAgent: [
|
||||
{
|
||||
hooks: [{ type: HookType.Command, command: 'initial' }],
|
||||
hooks: [{ type: HookType.Command as const, command: 'initial' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -2533,6 +2534,29 @@ describe('Config Quota & Preview Model Access', () => {
|
||||
expect(config.isPlanEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlanModeRoutingEnabled', () => {
|
||||
it('should default to true when not provided', async () => {
|
||||
const config = new Config(baseParams);
|
||||
expect(await config.getPlanModeRoutingEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when explicitly enabled in planSettings', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
planSettings: { modelRouting: true },
|
||||
});
|
||||
expect(await config.getPlanModeRoutingEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when explicitly disabled in planSettings', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
planSettings: { modelRouting: false },
|
||||
});
|
||||
expect(await config.getPlanModeRoutingEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config JIT Initialization', () => {
|
||||
|
||||
@@ -153,6 +153,7 @@ export interface SummarizeToolOutputSettings {
|
||||
|
||||
export interface PlanSettings {
|
||||
directory?: string;
|
||||
modelRouting?: boolean;
|
||||
}
|
||||
|
||||
export interface TelemetrySettings {
|
||||
@@ -734,6 +735,7 @@ export class Config {
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
private readonly planEnabled: boolean;
|
||||
private readonly planModeRoutingEnabled: boolean;
|
||||
private readonly modelSteering: boolean;
|
||||
private contextManager?: ContextManager;
|
||||
private terminalBackground: string | undefined = undefined;
|
||||
@@ -823,6 +825,7 @@ export class Config {
|
||||
this.agents = params.agents ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
this.planEnabled = params.plan ?? false;
|
||||
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
|
||||
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
|
||||
this.skillsSupport = params.skillsSupport ?? true;
|
||||
this.disabledSkills = params.disabledSkills ?? [];
|
||||
@@ -1123,7 +1126,7 @@ export class Config {
|
||||
return this.contentGenerator;
|
||||
}
|
||||
|
||||
async refreshAuth(authMethod: AuthType) {
|
||||
async refreshAuth(authMethod: AuthType, apiKey?: string) {
|
||||
// Reset availability service when switching auth
|
||||
this.modelAvailabilityService.reset();
|
||||
|
||||
@@ -1149,6 +1152,7 @@ export class Config {
|
||||
const newContentGeneratorConfig = await createContentGeneratorConfig(
|
||||
this,
|
||||
authMethod,
|
||||
apiKey,
|
||||
);
|
||||
this.contentGenerator = await createContentGenerator(
|
||||
newContentGeneratorConfig,
|
||||
@@ -2318,6 +2322,10 @@ export class Config {
|
||||
return this.experiments?.flags[ExperimentFlags.USER_CACHING]?.boolValue;
|
||||
}
|
||||
|
||||
async getPlanModeRoutingEnabled(): Promise<boolean> {
|
||||
return this.planModeRoutingEnabled;
|
||||
}
|
||||
|
||||
async getNumericalRoutingEnabled(): Promise<boolean> {
|
||||
await this.ensureExperimentsLoaded();
|
||||
|
||||
|
||||
@@ -90,9 +90,13 @@ export type ContentGeneratorConfig = {
|
||||
export async function createContentGeneratorConfig(
|
||||
config: Config,
|
||||
authType: AuthType | undefined,
|
||||
apiKey?: string,
|
||||
): Promise<ContentGeneratorConfig> {
|
||||
const geminiApiKey =
|
||||
process.env['GEMINI_API_KEY'] || (await loadApiKey()) || undefined;
|
||||
apiKey ||
|
||||
process.env['GEMINI_API_KEY'] ||
|
||||
(await loadApiKey()) ||
|
||||
undefined;
|
||||
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
|
||||
const googleCloudProject =
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] ||
|
||||
|
||||
@@ -24,11 +24,16 @@ vi.mock('../telemetry/trace.js', () => ({
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type {
|
||||
Content,
|
||||
GenerateContentConfig,
|
||||
GenerateContentResponse,
|
||||
EmbedContentResponse,
|
||||
} from '@google/genai';
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import { LoggingContentGenerator } from './loggingContentGenerator.js';
|
||||
import {
|
||||
LoggingContentGenerator,
|
||||
estimateContextBreakdown,
|
||||
} from './loggingContentGenerator.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { UserTierId } from '../code_assist/types.js';
|
||||
import { ApiRequestEvent, LlmRole } from '../telemetry/types.js';
|
||||
@@ -346,3 +351,280 @@ describe('LoggingContentGenerator', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateContextBreakdown', () => {
|
||||
it('should return zeros for empty contents and no config', () => {
|
||||
const result = estimateContextBreakdown([], undefined);
|
||||
expect(result).toEqual({
|
||||
system_instructions: 0,
|
||||
tool_definitions: 0,
|
||||
history: 0,
|
||||
tool_calls: {},
|
||||
mcp_servers: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should estimate system instruction tokens', () => {
|
||||
const config = {
|
||||
systemInstruction: 'You are a helpful assistant.',
|
||||
} as GenerateContentConfig;
|
||||
const result = estimateContextBreakdown([], config);
|
||||
expect(result.system_instructions).toBeGreaterThan(0);
|
||||
expect(result.tool_definitions).toBe(0);
|
||||
expect(result.history).toBe(0);
|
||||
});
|
||||
|
||||
it('should estimate non-MCP tool definition tokens', () => {
|
||||
const config = {
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{ name: 'read_file', description: 'Reads a file', parameters: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentConfig;
|
||||
const result = estimateContextBreakdown([], config);
|
||||
expect(result.tool_definitions).toBeGreaterThan(0);
|
||||
expect(result.mcp_servers).toBe(0);
|
||||
});
|
||||
|
||||
it('should classify MCP tool definitions into mcp_servers, not tool_definitions', () => {
|
||||
const config = {
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'myserver__search',
|
||||
description: 'Search via MCP',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'read_file',
|
||||
description: 'Reads a file',
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentConfig;
|
||||
const result = estimateContextBreakdown([], config);
|
||||
expect(result.mcp_servers).toBeGreaterThan(0);
|
||||
expect(result.tool_definitions).toBeGreaterThan(0);
|
||||
// MCP tokens should not be in tool_definitions
|
||||
const configOnlyBuiltin = {
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'read_file',
|
||||
description: 'Reads a file',
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentConfig;
|
||||
const builtinOnly = estimateContextBreakdown([], configOnlyBuiltin);
|
||||
// tool_definitions should be smaller when MCP tools are separated out
|
||||
expect(result.tool_definitions).toBeLessThan(
|
||||
result.tool_definitions + result.mcp_servers,
|
||||
);
|
||||
expect(builtinOnly.mcp_servers).toBe(0);
|
||||
});
|
||||
|
||||
it('should not classify tools with __ in the middle of a segment as MCP', () => {
|
||||
// "__" at start or end (not a valid server__tool pattern) should not be MCP
|
||||
const config = {
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{ name: '__leading', description: 'test', parameters: {} },
|
||||
{ name: 'trailing__', description: 'test', parameters: {} },
|
||||
{
|
||||
name: 'a__b__c',
|
||||
description: 'three parts - not valid MCP',
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentConfig;
|
||||
const result = estimateContextBreakdown([], config);
|
||||
expect(result.mcp_servers).toBe(0);
|
||||
});
|
||||
|
||||
it('should estimate history tokens excluding tool call/response parts', () => {
|
||||
const contents: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Hello world' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi there!' }] },
|
||||
];
|
||||
const result = estimateContextBreakdown(contents);
|
||||
expect(result.history).toBeGreaterThan(0);
|
||||
expect(result.tool_calls).toEqual({});
|
||||
});
|
||||
|
||||
it('should separate tool call tokens from history', () => {
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/test.txt' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'function',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'read_file',
|
||||
response: { content: 'file contents here' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = estimateContextBreakdown(contents);
|
||||
expect(result.tool_calls['read_file']).toBeGreaterThan(0);
|
||||
// history should be zero since all parts are tool calls
|
||||
expect(result.history).toBe(0);
|
||||
});
|
||||
|
||||
it('should produce additive (non-overlapping) fields', () => {
|
||||
const contents: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/test.txt' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'function',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'read_file',
|
||||
response: { content: 'data' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const config = {
|
||||
systemInstruction: 'Be helpful.',
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{ name: 'read_file', description: 'Read', parameters: {} },
|
||||
{
|
||||
name: 'myserver__search',
|
||||
description: 'MCP search',
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentConfig;
|
||||
const result = estimateContextBreakdown(contents, config);
|
||||
|
||||
// All fields should be non-overlapping
|
||||
expect(result.system_instructions).toBeGreaterThan(0);
|
||||
expect(result.tool_definitions).toBeGreaterThan(0);
|
||||
expect(result.history).toBeGreaterThan(0);
|
||||
// tool_calls should only contain non-MCP tools
|
||||
expect(result.tool_calls['read_file']).toBeGreaterThan(0);
|
||||
expect(result.tool_calls['myserver__search']).toBeUndefined();
|
||||
// MCP tokens are only in mcp_servers
|
||||
expect(result.mcp_servers).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should classify MCP tool calls into mcp_servers only, not tool_calls', () => {
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'myserver__search',
|
||||
args: { query: 'test' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'function',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'myserver__search',
|
||||
response: { results: [] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = estimateContextBreakdown(contents);
|
||||
// MCP tool calls should NOT appear in tool_calls
|
||||
expect(result.tool_calls['myserver__search']).toBeUndefined();
|
||||
// MCP call tokens should only be counted in mcp_servers
|
||||
expect(result.mcp_servers).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle mixed MCP and non-MCP tool calls', () => {
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'read_file',
|
||||
args: { path: '/test' },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionCall: {
|
||||
name: 'myserver__search',
|
||||
args: { q: 'hello' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = estimateContextBreakdown(contents);
|
||||
// Non-MCP tools should be in tool_calls
|
||||
expect(result.tool_calls['read_file']).toBeGreaterThan(0);
|
||||
// MCP tools should NOT be in tool_calls
|
||||
expect(result.tool_calls['myserver__search']).toBeUndefined();
|
||||
// MCP tool calls should only be in mcp_servers
|
||||
expect(result.mcp_servers).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should use "unknown" for tool calls without a name', () => {
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: undefined as unknown as string,
|
||||
args: { x: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = estimateContextBreakdown(contents);
|
||||
expect(result.tool_calls['unknown']).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
GenerateContentResponseUsageMetadata,
|
||||
GenerateContentResponse,
|
||||
} from '@google/genai';
|
||||
import type { ServerDetails } from '../telemetry/types.js';
|
||||
import type { ServerDetails, ContextBreakdown } from '../telemetry/types.js';
|
||||
import {
|
||||
ApiRequestEvent,
|
||||
ApiResponseEvent,
|
||||
@@ -37,14 +37,104 @@ import { isStructuredError } from '../utils/quotaErrorDetection.js';
|
||||
import { runInDevTraceSpan, type SpanMetadata } from '../telemetry/trace.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { getErrorType } from '../utils/errors.js';
|
||||
import { isMcpToolName } from '../tools/mcp-tool.js';
|
||||
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
|
||||
|
||||
interface StructuredError {
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A decorator that wraps a ContentGenerator to add logging to API calls.
|
||||
* Rough token estimate for non-Part config objects (tool definitions, etc.)
|
||||
* where estimateTokenCountSync cannot be used directly.
|
||||
*/
|
||||
function estimateConfigTokens(value: unknown): number {
|
||||
return Math.floor(JSON.stringify(value).length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the context breakdown for telemetry. All returned fields are
|
||||
* additive (non-overlapping), so their sum approximates the total context size.
|
||||
*
|
||||
* - system_instructions: tokens from system instruction config
|
||||
* - tool_definitions: tokens from non-MCP tool definitions
|
||||
* - history: tokens from conversation history, excluding tool call/response parts
|
||||
* - tool_calls: per-tool token counts for non-MCP function call + response parts
|
||||
* - mcp_servers: tokens from MCP tool definitions + MCP tool call/response parts
|
||||
*
|
||||
* MCP tool calls are excluded from tool_calls and counted only in mcp_servers
|
||||
* to keep fields non-overlapping and avoid leaking MCP server names in telemetry.
|
||||
*/
|
||||
export function estimateContextBreakdown(
|
||||
contents: Content[],
|
||||
config?: GenerateContentConfig,
|
||||
): ContextBreakdown {
|
||||
let systemInstructions = 0;
|
||||
let toolDefinitions = 0;
|
||||
let history = 0;
|
||||
let mcpServers = 0;
|
||||
const toolCalls: Record<string, number> = {};
|
||||
|
||||
if (config?.systemInstruction) {
|
||||
systemInstructions += estimateConfigTokens(config.systemInstruction);
|
||||
}
|
||||
|
||||
if (config?.tools) {
|
||||
for (const tool of config.tools) {
|
||||
const toolTokens = estimateConfigTokens(tool);
|
||||
if (
|
||||
tool &&
|
||||
typeof tool === 'object' &&
|
||||
'functionDeclarations' in tool &&
|
||||
tool.functionDeclarations
|
||||
) {
|
||||
let mcpTokensInTool = 0;
|
||||
for (const func of tool.functionDeclarations) {
|
||||
if (func.name && isMcpToolName(func.name)) {
|
||||
mcpTokensInTool += estimateConfigTokens(func);
|
||||
}
|
||||
}
|
||||
mcpServers += mcpTokensInTool;
|
||||
toolDefinitions += toolTokens - mcpTokensInTool;
|
||||
} else {
|
||||
toolDefinitions += toolTokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const content of contents) {
|
||||
for (const part of content.parts || []) {
|
||||
if (part.functionCall) {
|
||||
const name = part.functionCall.name || 'unknown';
|
||||
const tokens = estimateTokenCountSync([part]);
|
||||
if (isMcpToolName(name)) {
|
||||
mcpServers += tokens;
|
||||
} else {
|
||||
toolCalls[name] = (toolCalls[name] || 0) + tokens;
|
||||
}
|
||||
} else if (part.functionResponse) {
|
||||
const name = part.functionResponse.name || 'unknown';
|
||||
const tokens = estimateTokenCountSync([part]);
|
||||
if (isMcpToolName(name)) {
|
||||
mcpServers += tokens;
|
||||
} else {
|
||||
toolCalls[name] = (toolCalls[name] || 0) + tokens;
|
||||
}
|
||||
} else {
|
||||
history += estimateTokenCountSync([part]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
system_instructions: systemInstructions,
|
||||
tool_definitions: toolDefinitions,
|
||||
history,
|
||||
tool_calls: toolCalls,
|
||||
mcp_servers: mcpServers,
|
||||
};
|
||||
}
|
||||
|
||||
export class LoggingContentGenerator implements ContentGenerator {
|
||||
constructor(
|
||||
private readonly wrapped: ContentGenerator,
|
||||
@@ -134,27 +224,40 @@ export class LoggingContentGenerator implements ContentGenerator {
|
||||
generationConfig?: GenerateContentConfig,
|
||||
serverDetails?: ServerDetails,
|
||||
): void {
|
||||
logApiResponse(
|
||||
this.config,
|
||||
new ApiResponseEvent(
|
||||
model,
|
||||
durationMs,
|
||||
{
|
||||
prompt_id,
|
||||
contents: requestContents,
|
||||
generate_content_config: generationConfig,
|
||||
server: serverDetails,
|
||||
},
|
||||
{
|
||||
candidates: responseCandidates,
|
||||
response_id: responseId,
|
||||
},
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
usageMetadata,
|
||||
responseText,
|
||||
role,
|
||||
),
|
||||
const event = new ApiResponseEvent(
|
||||
model,
|
||||
durationMs,
|
||||
{
|
||||
prompt_id,
|
||||
contents: requestContents,
|
||||
generate_content_config: generationConfig,
|
||||
server: serverDetails,
|
||||
},
|
||||
{
|
||||
candidates: responseCandidates,
|
||||
response_id: responseId,
|
||||
},
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
usageMetadata,
|
||||
responseText,
|
||||
role,
|
||||
);
|
||||
|
||||
// Only compute context breakdown for turn-ending responses (when the user
|
||||
// gets back control to type). If the response contains function calls, the
|
||||
// model is in a tool-use loop and will make more API calls — skip to avoid
|
||||
// emitting redundant cumulative snapshots for every intermediate step.
|
||||
const hasToolCalls = responseCandidates?.some((c) =>
|
||||
c.content?.parts?.some((p) => p.functionCall),
|
||||
);
|
||||
if (!hasToolCalls) {
|
||||
event.usage.context_breakdown = estimateContextBreakdown(
|
||||
requestContents,
|
||||
generationConfig,
|
||||
);
|
||||
}
|
||||
|
||||
logApiResponse(this.config, event);
|
||||
}
|
||||
|
||||
private _logApiError(
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Config } from '../config/config.js';
|
||||
import type { HookPlanner, HookEventContext } from './hookPlanner.js';
|
||||
import type { HookRunner } from './hookRunner.js';
|
||||
import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js';
|
||||
import { HookEventName } from './types.js';
|
||||
import { HookEventName, HookType } from './types.js';
|
||||
import type {
|
||||
HookConfig,
|
||||
HookInput,
|
||||
@@ -500,7 +500,10 @@ export class HookEventHandler {
|
||||
* Get hook name from config for display or telemetry
|
||||
*/
|
||||
private getHookName(config: HookConfig): string {
|
||||
return config.name || config.command || 'unknown-command';
|
||||
if (config.type === HookType.Command) {
|
||||
return config.name || config.command || 'unknown-command';
|
||||
}
|
||||
return config.name || 'unknown-hook';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -513,7 +516,7 @@ export class HookEventHandler {
|
||||
/**
|
||||
* Get hook type from execution result for telemetry
|
||||
*/
|
||||
private getHookTypeFromResult(result: HookExecutionResult): 'command' {
|
||||
private getHookTypeFromResult(result: HookExecutionResult): HookType {
|
||||
return result.hookConfig.type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
HookEventName,
|
||||
HookType,
|
||||
HOOKS_CONFIG_FIELDS,
|
||||
type CommandHookConfig,
|
||||
type HookDefinition,
|
||||
} from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { HookDefinition } from './types.js';
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', () => ({
|
||||
@@ -153,7 +154,9 @@ describe('HookRegistry', () => {
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0].eventName).toBe(HookEventName.BeforeTool);
|
||||
expect(hooks[0].config.type).toBe(HookType.Command);
|
||||
expect(hooks[0].config.command).toBe('./hooks/check_style.sh');
|
||||
expect((hooks[0].config as CommandHookConfig).command).toBe(
|
||||
'./hooks/check_style.sh',
|
||||
);
|
||||
expect(hooks[0].matcher).toBe('EditTool');
|
||||
expect(hooks[0].source).toBe(ConfigSource.Project);
|
||||
});
|
||||
@@ -186,7 +189,9 @@ describe('HookRegistry', () => {
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0].eventName).toBe(HookEventName.AfterTool);
|
||||
expect(hooks[0].config.type).toBe(HookType.Command);
|
||||
expect(hooks[0].config.command).toBe('./hooks/after-tool.sh');
|
||||
expect((hooks[0].config as CommandHookConfig).command).toBe(
|
||||
'./hooks/after-tool.sh',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid configuration gracefully', async () => {
|
||||
@@ -632,7 +637,9 @@ describe('HookRegistry', () => {
|
||||
// Should only load the valid hook
|
||||
const hooks = hookRegistry.getAllHooks();
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0].config.command).toBe('./valid-hook.sh');
|
||||
expect((hooks[0].config as CommandHookConfig).command).toBe(
|
||||
'./valid-hook.sh',
|
||||
);
|
||||
|
||||
// Verify the warnings for invalid configurations
|
||||
// 1st warning: non-object hookConfig ('invalid-string')
|
||||
|
||||
@@ -34,11 +34,40 @@ export class HookRegistry {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new hook programmatically
|
||||
*/
|
||||
registerHook(
|
||||
config: HookConfig,
|
||||
eventName: HookEventName,
|
||||
options?: { matcher?: string; sequential?: boolean; source?: ConfigSource },
|
||||
): void {
|
||||
const source = options?.source ?? ConfigSource.Runtime;
|
||||
|
||||
if (!this.validateHookConfig(config, eventName, source)) {
|
||||
throw new Error(
|
||||
`Invalid hook configuration for ${eventName} from ${source}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.entries.push({
|
||||
config,
|
||||
source,
|
||||
eventName,
|
||||
matcher: options?.matcher,
|
||||
sequential: options?.sequential,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the registry by processing hooks from config
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
this.entries = [];
|
||||
const runtimeHooks = this.entries.filter(
|
||||
(entry) => entry.source === ConfigSource.Runtime,
|
||||
);
|
||||
this.entries = [...runtimeHooks];
|
||||
this.processHooksFromConfig();
|
||||
|
||||
debugLogger.debug(
|
||||
@@ -93,7 +122,10 @@ export class HookRegistry {
|
||||
private getHookName(
|
||||
entry: HookRegistryEntry | { config: HookConfig },
|
||||
): string {
|
||||
return entry.config.name || entry.config.command || 'unknown-command';
|
||||
if (entry.config.type === 'command') {
|
||||
return entry.config.name || entry.config.command || 'unknown-command';
|
||||
}
|
||||
return entry.config.name || 'unknown-hook';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,7 +293,10 @@ please review the project settings (.gemini/settings.json) and remove them.`;
|
||||
eventName: HookEventName,
|
||||
source: ConfigSource,
|
||||
): boolean {
|
||||
if (!config.type || !['command', 'plugin'].includes(config.type)) {
|
||||
if (
|
||||
!config.type ||
|
||||
!['command', 'plugin', 'runtime'].includes(config.type)
|
||||
) {
|
||||
debugLogger.warn(
|
||||
`Invalid hook ${eventName} from ${source} type: ${config.type}`,
|
||||
);
|
||||
@@ -275,6 +310,13 @@ please review the project settings (.gemini/settings.json) and remove them.`;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.type === 'runtime' && !config.name) {
|
||||
debugLogger.warn(
|
||||
`Runtime hook ${eventName} from ${source} missing name field`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -292,6 +334,8 @@ please review the project settings (.gemini/settings.json) and remove them.`;
|
||||
*/
|
||||
private getSourcePriority(source: ConfigSource): number {
|
||||
switch (source) {
|
||||
case ConfigSource.Runtime:
|
||||
return 0; // Highest
|
||||
case ConfigSource.Project:
|
||||
return 1;
|
||||
case ConfigSource.User:
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type {
|
||||
HookConfig,
|
||||
CommandHookConfig,
|
||||
RuntimeHookConfig,
|
||||
HookInput,
|
||||
HookOutput,
|
||||
HookExecutionResult,
|
||||
@@ -15,7 +17,7 @@ import type {
|
||||
BeforeModelOutput,
|
||||
BeforeToolInput,
|
||||
} from './types.js';
|
||||
import { HookEventName, ConfigSource } from './types.js';
|
||||
import { HookEventName, ConfigSource, HookType } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { LLMRequest } from './hookTranslator.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
@@ -75,6 +77,15 @@ export class HookRunner {
|
||||
}
|
||||
|
||||
try {
|
||||
if (hookConfig.type === HookType.Runtime) {
|
||||
return await this.executeRuntimeHook(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
startTime,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.executeCommandHook(
|
||||
hookConfig,
|
||||
eventName,
|
||||
@@ -83,7 +94,10 @@ export class HookRunner {
|
||||
);
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const hookId = hookConfig.name || hookConfig.command || 'unknown';
|
||||
const hookId =
|
||||
hookConfig.name ||
|
||||
(hookConfig.type === HookType.Command ? hookConfig.command : '') ||
|
||||
'unknown';
|
||||
const errorMessage = `Hook execution failed for event '${eventName}' (hook: ${hookId}): ${error}`;
|
||||
debugLogger.warn(`Hook execution error (non-fatal): ${errorMessage}`);
|
||||
|
||||
@@ -230,11 +244,66 @@ export class HookRunner {
|
||||
return modifiedInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a runtime hook
|
||||
*/
|
||||
private async executeRuntimeHook(
|
||||
hookConfig: RuntimeHookConfig,
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
startTime: number,
|
||||
): Promise<HookExecutionResult> {
|
||||
const timeout = hookConfig.timeout ?? DEFAULT_HOOK_TIMEOUT;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
// Create a promise that rejects after timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(
|
||||
() => reject(new Error(`Hook timed out after ${timeout}ms`)),
|
||||
timeout,
|
||||
);
|
||||
});
|
||||
|
||||
// Execute action with timeout race
|
||||
const result = await Promise.race([
|
||||
hookConfig.action(input, { signal: controller.signal }),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
const output =
|
||||
result === null || result === undefined ? undefined : result;
|
||||
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: true,
|
||||
output,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
} catch (error) {
|
||||
// Abort the ongoing hook action if it timed out or errored
|
||||
controller.abort();
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command hook
|
||||
*/
|
||||
private async executeCommandHook(
|
||||
hookConfig: HookConfig,
|
||||
hookConfig: CommandHookConfig,
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
startTime: number,
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('HookSystem Integration', () => {
|
||||
matcher: 'TestTool',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
type: HookType.Command as const,
|
||||
command: 'echo',
|
||||
timeout: 5000,
|
||||
},
|
||||
@@ -164,7 +164,8 @@ describe('HookSystem Integration', () => {
|
||||
{
|
||||
type: 'invalid-type' as HookType, // Invalid hook type for testing
|
||||
command: './test.sh',
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -279,12 +280,12 @@ describe('HookSystem Integration', () => {
|
||||
matcher: 'TestTool',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
type: HookType.Command as const,
|
||||
command: 'echo "enabled-hook"',
|
||||
timeout: 5000,
|
||||
},
|
||||
{
|
||||
type: HookType.Command,
|
||||
type: HookType.Command as const,
|
||||
command: 'echo "disabled-hook"',
|
||||
timeout: 5000,
|
||||
},
|
||||
@@ -350,7 +351,7 @@ describe('HookSystem Integration', () => {
|
||||
matcher: 'TestTool',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
type: HookType.Command as const,
|
||||
command: 'echo "will-be-disabled"',
|
||||
timeout: 5000,
|
||||
},
|
||||
|
||||
@@ -21,6 +21,9 @@ import type {
|
||||
AfterModelHookOutput,
|
||||
BeforeToolSelectionHookOutput,
|
||||
McpToolContext,
|
||||
HookConfig,
|
||||
HookEventName,
|
||||
ConfigSource,
|
||||
} from './types.js';
|
||||
import { NotificationType } from './types.js';
|
||||
import type { AggregatedHookResult } from './hookAggregator.js';
|
||||
@@ -202,6 +205,17 @@ export class HookSystem {
|
||||
return this.hookRegistry.getAllHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new hook programmatically
|
||||
*/
|
||||
registerHook(
|
||||
config: HookConfig,
|
||||
eventName: HookEventName,
|
||||
options?: { matcher?: string; sequential?: boolean; source?: ConfigSource },
|
||||
): void {
|
||||
this.hookRegistry.registerHook(config, eventName, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire hook events directly
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HookSystem } from './hookSystem.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { HookType, HookEventName, ConfigSource } from './types.js';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
// Mock console methods
|
||||
vi.stubGlobal('console', {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
});
|
||||
|
||||
describe('Runtime Hooks', () => {
|
||||
let hookSystem: HookSystem;
|
||||
let config: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
const testDir = path.join(os.tmpdir(), 'test-runtime-hooks');
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
|
||||
config = new Config({
|
||||
model: 'gemini-3-flash-preview',
|
||||
targetDir: testDir,
|
||||
sessionId: 'test-session',
|
||||
debugMode: false,
|
||||
cwd: testDir,
|
||||
});
|
||||
|
||||
// Stub getMessageBus
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(config as any).getMessageBus = () => undefined;
|
||||
|
||||
hookSystem = new HookSystem(config);
|
||||
});
|
||||
|
||||
it('should register a runtime hook', async () => {
|
||||
await hookSystem.initialize();
|
||||
|
||||
const action = vi.fn().mockResolvedValue(undefined);
|
||||
hookSystem.registerHook(
|
||||
{
|
||||
type: HookType.Runtime,
|
||||
name: 'test-hook',
|
||||
action,
|
||||
},
|
||||
HookEventName.BeforeTool,
|
||||
{ matcher: 'TestTool' },
|
||||
);
|
||||
|
||||
const hooks = hookSystem.getAllHooks();
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0].config.name).toBe('test-hook');
|
||||
expect(hooks[0].source).toBe(ConfigSource.Runtime);
|
||||
});
|
||||
|
||||
it('should execute a runtime hook', async () => {
|
||||
await hookSystem.initialize();
|
||||
|
||||
const action = vi.fn().mockImplementation(async () => ({
|
||||
decision: 'allow',
|
||||
systemMessage: 'Hook ran',
|
||||
}));
|
||||
|
||||
hookSystem.registerHook(
|
||||
{
|
||||
type: HookType.Runtime,
|
||||
name: 'test-hook',
|
||||
action,
|
||||
},
|
||||
HookEventName.BeforeTool,
|
||||
{ matcher: 'TestTool' },
|
||||
);
|
||||
|
||||
const result = await hookSystem
|
||||
.getEventHandler()
|
||||
.fireBeforeToolEvent('TestTool', { foo: 'bar' });
|
||||
|
||||
expect(action).toHaveBeenCalled();
|
||||
expect(action.mock.calls[0][0]).toMatchObject({
|
||||
tool_name: 'TestTool',
|
||||
tool_input: { foo: 'bar' },
|
||||
hook_event_name: 'BeforeTool',
|
||||
});
|
||||
|
||||
expect(result.finalOutput?.systemMessage).toBe('Hook ran');
|
||||
});
|
||||
|
||||
it('should handle runtime hook errors', async () => {
|
||||
await hookSystem.initialize();
|
||||
|
||||
const action = vi.fn().mockRejectedValue(new Error('Hook failed'));
|
||||
|
||||
hookSystem.registerHook(
|
||||
{
|
||||
type: HookType.Runtime,
|
||||
name: 'fail-hook',
|
||||
action,
|
||||
},
|
||||
HookEventName.BeforeTool,
|
||||
{ matcher: 'TestTool' },
|
||||
);
|
||||
|
||||
// Should not throw, but handle error gracefully
|
||||
await hookSystem.getEventHandler().fireBeforeToolEvent('TestTool', {});
|
||||
|
||||
expect(action).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve runtime hooks across re-initialization', async () => {
|
||||
await hookSystem.initialize();
|
||||
|
||||
hookSystem.registerHook(
|
||||
{
|
||||
type: HookType.Runtime,
|
||||
name: 'persist-hook',
|
||||
action: async () => {},
|
||||
},
|
||||
HookEventName.BeforeTool,
|
||||
{ matcher: 'TestTool' },
|
||||
);
|
||||
|
||||
expect(hookSystem.getAllHooks()).toHaveLength(1);
|
||||
|
||||
// Re-initialize
|
||||
await hookSystem.initialize();
|
||||
|
||||
expect(hookSystem.getAllHooks()).toHaveLength(1);
|
||||
expect(hookSystem.getAllHooks()[0].config.name).toBe('persist-hook');
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import { TrustedHooksManager } from './trustedHooks.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { HookEventName, HookType } from './types.js';
|
||||
import { HookEventName, HookType, type HookDefinition } from './types.js';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('../config/storage.js');
|
||||
@@ -72,8 +72,16 @@ describe('TrustedHooksManager', () => {
|
||||
[HookEventName.BeforeTool]: [
|
||||
{
|
||||
hooks: [
|
||||
{ name: 'trusted-hook', type: HookType.Command, command: 'cmd1' },
|
||||
{ name: 'new-hook', type: HookType.Command, command: 'cmd2' },
|
||||
{
|
||||
name: 'trusted-hook',
|
||||
type: HookType.Command,
|
||||
command: 'cmd1',
|
||||
} as const,
|
||||
{
|
||||
name: 'new-hook',
|
||||
type: HookType.Command,
|
||||
command: 'cmd2',
|
||||
} as const,
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -90,7 +98,11 @@ describe('TrustedHooksManager', () => {
|
||||
[HookEventName.BeforeTool]: [
|
||||
{
|
||||
hooks: [
|
||||
{ name: 'trusted-hook', type: HookType.Command, command: 'cmd1' },
|
||||
{
|
||||
name: 'trusted-hook',
|
||||
type: HookType.Command,
|
||||
command: 'cmd1',
|
||||
} as const,
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -114,9 +126,12 @@ describe('TrustedHooksManager', () => {
|
||||
],
|
||||
};
|
||||
|
||||
expect(manager.getUntrustedHooks('/project', projectHooks)).toEqual([
|
||||
'./script.sh',
|
||||
]);
|
||||
expect(
|
||||
manager.getUntrustedHooks(
|
||||
'/project',
|
||||
projectHooks as Partial<Record<HookEventName, HookDefinition[]>>,
|
||||
),
|
||||
).toEqual(['./script.sh']);
|
||||
});
|
||||
|
||||
it('should detect change in command as untrusted', () => {
|
||||
@@ -142,11 +157,17 @@ describe('TrustedHooksManager', () => {
|
||||
],
|
||||
};
|
||||
|
||||
manager.trustHooks('/project', originalHook);
|
||||
manager.trustHooks(
|
||||
'/project',
|
||||
originalHook as Partial<Record<HookEventName, HookDefinition[]>>,
|
||||
);
|
||||
|
||||
expect(manager.getUntrustedHooks('/project', updatedHook)).toEqual([
|
||||
'my-hook',
|
||||
]);
|
||||
expect(
|
||||
manager.getUntrustedHooks(
|
||||
'/project',
|
||||
updatedHook as Partial<Record<HookEventName, HookDefinition[]>>,
|
||||
),
|
||||
).toEqual(['my-hook']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as path from 'node:path';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import {
|
||||
getHookKey,
|
||||
HookType,
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
} from './types.js';
|
||||
@@ -79,6 +80,7 @@ export class TrustedHooksManager {
|
||||
for (const def of definitions) {
|
||||
if (!def || !Array.isArray(def.hooks)) continue;
|
||||
for (const hook of def.hooks) {
|
||||
if (hook.type === HookType.Runtime) continue;
|
||||
const key = getHookKey(hook);
|
||||
if (!trustedKeys.has(key)) {
|
||||
// Return friendly name or command
|
||||
@@ -108,6 +110,7 @@ export class TrustedHooksManager {
|
||||
for (const def of definitions) {
|
||||
if (!def || !Array.isArray(def.hooks)) continue;
|
||||
for (const hook of def.hooks) {
|
||||
if (hook.type === HookType.Runtime) continue;
|
||||
currentTrusted.add(getHookKey(hook));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { defaultHookTranslator } from './hookTranslator.js';
|
||||
* Configuration source levels in precedence order (highest to lowest)
|
||||
*/
|
||||
export enum ConfigSource {
|
||||
Runtime = 'runtime',
|
||||
Project = 'project',
|
||||
User = 'user',
|
||||
System = 'system',
|
||||
@@ -50,11 +51,43 @@ export enum HookEventName {
|
||||
export const HOOKS_CONFIG_FIELDS = ['enabled', 'disabled', 'notifications'];
|
||||
|
||||
/**
|
||||
* Hook configuration entry
|
||||
* Hook implementation types
|
||||
*/
|
||||
export enum HookType {
|
||||
Command = 'command',
|
||||
Runtime = 'runtime',
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook action function
|
||||
*/
|
||||
export type HookAction = (
|
||||
input: HookInput,
|
||||
options?: { signal: AbortSignal },
|
||||
) => Promise<HookOutput | void | null>;
|
||||
|
||||
/**
|
||||
* Runtime hook configuration
|
||||
*/
|
||||
export interface RuntimeHookConfig {
|
||||
type: HookType.Runtime;
|
||||
/** Unique name for the runtime hook */
|
||||
name: string;
|
||||
/** Function to execute when the hook is triggered */
|
||||
action: HookAction;
|
||||
command?: never;
|
||||
source?: ConfigSource;
|
||||
/** Maximum time allowed for hook execution in milliseconds */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command hook configuration entry
|
||||
*/
|
||||
export interface CommandHookConfig {
|
||||
type: HookType.Command;
|
||||
command: string;
|
||||
action?: never;
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
@@ -62,7 +95,7 @@ export interface CommandHookConfig {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type HookConfig = CommandHookConfig;
|
||||
export type HookConfig = CommandHookConfig | RuntimeHookConfig;
|
||||
|
||||
/**
|
||||
* Hook definition with matcher
|
||||
@@ -73,19 +106,12 @@ export interface HookDefinition {
|
||||
hooks: HookConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook implementation types
|
||||
*/
|
||||
export enum HookType {
|
||||
Command = 'command',
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for a hook configuration
|
||||
*/
|
||||
export function getHookKey(hook: HookConfig): string {
|
||||
const name = hook.name || '';
|
||||
const command = hook.command || '';
|
||||
const command = hook.type === HookType.Command ? hook.command : '';
|
||||
return `${name}:${command}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ import { DefaultStrategy } from './strategies/defaultStrategy.js';
|
||||
import { CompositeStrategy } from './strategies/compositeStrategy.js';
|
||||
import { FallbackStrategy } from './strategies/fallbackStrategy.js';
|
||||
import { OverrideStrategy } from './strategies/overrideStrategy.js';
|
||||
import { ApprovalModeStrategy } from './strategies/approvalModeStrategy.js';
|
||||
import { ClassifierStrategy } from './strategies/classifierStrategy.js';
|
||||
import { NumericalClassifierStrategy } from './strategies/numericalClassifierStrategy.js';
|
||||
import { logModelRouting } from '../telemetry/loggers.js';
|
||||
import { ModelRoutingEvent } from '../telemetry/types.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
|
||||
vi.mock('../config/config.js');
|
||||
vi.mock('../core/baseLlmClient.js');
|
||||
@@ -25,6 +27,7 @@ vi.mock('./strategies/defaultStrategy.js');
|
||||
vi.mock('./strategies/compositeStrategy.js');
|
||||
vi.mock('./strategies/fallbackStrategy.js');
|
||||
vi.mock('./strategies/overrideStrategy.js');
|
||||
vi.mock('./strategies/approvalModeStrategy.js');
|
||||
vi.mock('./strategies/classifierStrategy.js');
|
||||
vi.mock('./strategies/numericalClassifierStrategy.js');
|
||||
vi.mock('../telemetry/loggers.js');
|
||||
@@ -45,11 +48,15 @@ describe('ModelRouterService', () => {
|
||||
vi.spyOn(mockConfig, 'getBaseLlmClient').mockReturnValue(mockBaseLlmClient);
|
||||
vi.spyOn(mockConfig, 'getNumericalRoutingEnabled').mockResolvedValue(false);
|
||||
vi.spyOn(mockConfig, 'getClassifierThreshold').mockResolvedValue(undefined);
|
||||
vi.spyOn(mockConfig, 'getApprovalMode').mockReturnValue(
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
|
||||
mockCompositeStrategy = new CompositeStrategy(
|
||||
[
|
||||
new FallbackStrategy(),
|
||||
new OverrideStrategy(),
|
||||
new ApprovalModeStrategy(),
|
||||
new ClassifierStrategy(),
|
||||
new NumericalClassifierStrategy(),
|
||||
new DefaultStrategy(),
|
||||
@@ -79,12 +86,13 @@ describe('ModelRouterService', () => {
|
||||
const compositeStrategyArgs = vi.mocked(CompositeStrategy).mock.calls[0];
|
||||
const childStrategies = compositeStrategyArgs[0];
|
||||
|
||||
expect(childStrategies.length).toBe(5);
|
||||
expect(childStrategies.length).toBe(6);
|
||||
expect(childStrategies[0]).toBeInstanceOf(FallbackStrategy);
|
||||
expect(childStrategies[1]).toBeInstanceOf(OverrideStrategy);
|
||||
expect(childStrategies[2]).toBeInstanceOf(ClassifierStrategy);
|
||||
expect(childStrategies[3]).toBeInstanceOf(NumericalClassifierStrategy);
|
||||
expect(childStrategies[4]).toBeInstanceOf(DefaultStrategy);
|
||||
expect(childStrategies[2]).toBeInstanceOf(ApprovalModeStrategy);
|
||||
expect(childStrategies[3]).toBeInstanceOf(ClassifierStrategy);
|
||||
expect(childStrategies[4]).toBeInstanceOf(NumericalClassifierStrategy);
|
||||
expect(childStrategies[5]).toBeInstanceOf(DefaultStrategy);
|
||||
expect(compositeStrategyArgs[1]).toBe('agent-router');
|
||||
});
|
||||
|
||||
@@ -127,6 +135,7 @@ describe('ModelRouterService', () => {
|
||||
'Strategy reasoning',
|
||||
false,
|
||||
undefined,
|
||||
ApprovalMode.DEFAULT,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
@@ -153,6 +162,7 @@ describe('ModelRouterService', () => {
|
||||
'An exception occurred during routing.',
|
||||
true,
|
||||
'Strategy failed',
|
||||
ApprovalMode.DEFAULT,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { NumericalClassifierStrategy } from './strategies/numericalClassifierStr
|
||||
import { CompositeStrategy } from './strategies/compositeStrategy.js';
|
||||
import { FallbackStrategy } from './strategies/fallbackStrategy.js';
|
||||
import { OverrideStrategy } from './strategies/overrideStrategy.js';
|
||||
import { ApprovalModeStrategy } from './strategies/approvalModeStrategy.js';
|
||||
|
||||
import { logModelRouting } from '../telemetry/loggers.js';
|
||||
import { ModelRoutingEvent } from '../telemetry/types.js';
|
||||
@@ -40,6 +41,7 @@ export class ModelRouterService {
|
||||
[
|
||||
new FallbackStrategy(),
|
||||
new OverrideStrategy(),
|
||||
new ApprovalModeStrategy(),
|
||||
new ClassifierStrategy(),
|
||||
new NumericalClassifierStrategy(),
|
||||
new DefaultStrategy(),
|
||||
@@ -105,6 +107,7 @@ export class ModelRouterService {
|
||||
decision!.metadata.reasoning,
|
||||
failed,
|
||||
error_message,
|
||||
this.config.getApprovalMode(),
|
||||
enableNumericalRouting,
|
||||
classifierThreshold,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ApprovalModeStrategy } from './approvalModeStrategy.js';
|
||||
import type { RoutingContext } from '../routingStrategy.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
} from '../../config/models.js';
|
||||
import { ApprovalMode } from '../../policy/types.js';
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
|
||||
describe('ApprovalModeStrategy', () => {
|
||||
let strategy: ApprovalModeStrategy;
|
||||
let mockContext: RoutingContext;
|
||||
let mockConfig: Config;
|
||||
let mockBaseLlmClient: BaseLlmClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
strategy = new ApprovalModeStrategy();
|
||||
mockContext = {
|
||||
history: [],
|
||||
request: [{ text: 'test' }],
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO),
|
||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
|
||||
getPlanModeRoutingEnabled: vi.fn().mockResolvedValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
mockBaseLlmClient = {} as BaseLlmClient;
|
||||
});
|
||||
|
||||
it('should return null if the model is not an auto model', async () => {
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
);
|
||||
|
||||
expect(decision).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if plan mode routing is disabled', async () => {
|
||||
vi.mocked(mockConfig.getPlanModeRoutingEnabled).mockResolvedValue(false);
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
);
|
||||
|
||||
expect(decision).toBeNull();
|
||||
});
|
||||
|
||||
it('should route to PRO model if ApprovalMode is PLAN (Gemini 2.5)', async () => {
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
);
|
||||
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: {
|
||||
source: 'approval-mode',
|
||||
latencyMs: expect.any(Number),
|
||||
reasoning: 'Routing to Pro model because ApprovalMode is PLAN.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route to PRO model if ApprovalMode is PLAN (Gemini 3)', async () => {
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
);
|
||||
|
||||
expect(decision).toEqual({
|
||||
model: PREVIEW_GEMINI_MODEL,
|
||||
metadata: {
|
||||
source: 'approval-mode',
|
||||
latencyMs: expect.any(Number),
|
||||
reasoning: 'Routing to Pro model because ApprovalMode is PLAN.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route to FLASH model if an approved plan exists (Gemini 2.5)', async () => {
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT);
|
||||
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(
|
||||
'/path/to/plan.md',
|
||||
);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
);
|
||||
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
metadata: {
|
||||
source: 'approval-mode',
|
||||
latencyMs: expect.any(Number),
|
||||
reasoning:
|
||||
'Routing to Flash model because an approved plan exists at /path/to/plan.md.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route to FLASH model if an approved plan exists (Gemini 3)', async () => {
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT);
|
||||
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(
|
||||
'/path/to/plan.md',
|
||||
);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
);
|
||||
|
||||
expect(decision).toEqual({
|
||||
model: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
metadata: {
|
||||
source: 'approval-mode',
|
||||
latencyMs: expect.any(Number),
|
||||
reasoning:
|
||||
'Routing to Flash model because an approved plan exists at /path/to/plan.md.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if not in PLAN mode and no approved plan exists', async () => {
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT);
|
||||
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(undefined);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
);
|
||||
|
||||
expect(decision).toBeNull();
|
||||
});
|
||||
|
||||
it('should prioritize requestedModel over config model if it is an auto model', async () => {
|
||||
mockContext.requestedModel = PREVIEW_GEMINI_MODEL_AUTO;
|
||||
vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
|
||||
|
||||
const decision = await strategy.route(
|
||||
mockContext,
|
||||
mockConfig,
|
||||
mockBaseLlmClient,
|
||||
);
|
||||
|
||||
expect(decision?.model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../../config/config.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
isAutoModel,
|
||||
isPreviewModel,
|
||||
} from '../../config/models.js';
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
import { ApprovalMode } from '../../policy/types.js';
|
||||
import type {
|
||||
RoutingContext,
|
||||
RoutingDecision,
|
||||
RoutingStrategy,
|
||||
} from '../routingStrategy.js';
|
||||
|
||||
/**
|
||||
* A strategy that routes based on the current ApprovalMode and plan status.
|
||||
*
|
||||
* - In PLAN mode: Routes to the PRO model for high-quality planning.
|
||||
* - In other modes with an approved plan: Routes to the FLASH model for efficient implementation.
|
||||
*/
|
||||
export class ApprovalModeStrategy implements RoutingStrategy {
|
||||
readonly name = 'approval-mode';
|
||||
|
||||
async route(
|
||||
context: RoutingContext,
|
||||
config: Config,
|
||||
_baseLlmClient: BaseLlmClient,
|
||||
): Promise<RoutingDecision | null> {
|
||||
const model = context.requestedModel ?? config.getModel();
|
||||
|
||||
// This strategy only applies to "auto" models.
|
||||
if (!isAutoModel(model)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(await config.getPlanModeRoutingEnabled())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const approvalMode = config.getApprovalMode();
|
||||
const approvedPlanPath = config.getApprovedPlanPath();
|
||||
|
||||
const isPreview = isPreviewModel(model);
|
||||
|
||||
// 1. Planning Phase: If ApprovalMode === PLAN, explicitly route to the Pro model.
|
||||
if (approvalMode === ApprovalMode.PLAN) {
|
||||
const proModel = isPreview ? PREVIEW_GEMINI_MODEL : DEFAULT_GEMINI_MODEL;
|
||||
return {
|
||||
model: proModel,
|
||||
metadata: {
|
||||
source: this.name,
|
||||
latencyMs: Date.now() - startTime,
|
||||
reasoning: 'Routing to Pro model because ApprovalMode is PLAN.',
|
||||
},
|
||||
};
|
||||
} else if (approvedPlanPath) {
|
||||
// 2. Implementation Phase: If ApprovalMode !== PLAN AND an approved plan path is set, prefer the Flash model.
|
||||
const flashModel = isPreview
|
||||
? PREVIEW_GEMINI_FLASH_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_MODEL;
|
||||
return {
|
||||
model: flashModel,
|
||||
metadata: {
|
||||
source: this.name,
|
||||
latencyMs: Date.now() - startTime,
|
||||
reasoning: `Routing to Flash model because an approved plan exists at ${approvedPlanPath}.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,9 @@ import {
|
||||
WebFetchFallbackAttemptEvent,
|
||||
HookCallEvent,
|
||||
} from '../types.js';
|
||||
import { HookType } from '../../hooks/types.js';
|
||||
import { AgentTerminateMode } from '../../agents/types.js';
|
||||
import { ApprovalMode } from '../../policy/types.js';
|
||||
import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js';
|
||||
import { UserAccountManager } from '../../utils/userAccountManager.js';
|
||||
import { InstallationManager } from '../../utils/installationManager.js';
|
||||
@@ -904,6 +906,7 @@ describe('ClearcutLogger', () => {
|
||||
'some reasoning',
|
||||
false,
|
||||
undefined,
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
|
||||
logger?.logModelRoutingEvent(event);
|
||||
@@ -938,6 +941,7 @@ describe('ClearcutLogger', () => {
|
||||
'some reasoning',
|
||||
true,
|
||||
'Something went wrong',
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
|
||||
logger?.logModelRoutingEvent(event);
|
||||
@@ -976,6 +980,7 @@ describe('ClearcutLogger', () => {
|
||||
'[Score: 90 / Threshold: 80] reasoning',
|
||||
false,
|
||||
undefined,
|
||||
ApprovalMode.DEFAULT,
|
||||
true,
|
||||
'80',
|
||||
);
|
||||
@@ -1401,7 +1406,7 @@ describe('ClearcutLogger', () => {
|
||||
|
||||
const event = new HookCallEvent(
|
||||
'before-tool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
hookName,
|
||||
{}, // input
|
||||
150, // duration
|
||||
|
||||
@@ -846,6 +846,40 @@ export class ClearcutLogger {
|
||||
EventMetadataKey.GEMINI_CLI_API_RESPONSE_TOOL_TOKEN_COUNT,
|
||||
value: JSON.stringify(event.usage.tool_token_count),
|
||||
},
|
||||
// Context breakdown fields are only populated on turn-ending responses
|
||||
// (when the user gets back control), not during intermediate tool-use
|
||||
// loops. Values still grow across turns as conversation history
|
||||
// accumulates, so downstream consumers should use the last event per
|
||||
// session (MAX) rather than summing across events.
|
||||
{
|
||||
gemini_cli_key:
|
||||
EventMetadataKey.GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_SYSTEM_INSTRUCTIONS,
|
||||
value: JSON.stringify(
|
||||
event.usage.context_breakdown?.system_instructions ?? 0,
|
||||
),
|
||||
},
|
||||
{
|
||||
gemini_cli_key:
|
||||
EventMetadataKey.GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_TOOL_DEFINITIONS,
|
||||
value: JSON.stringify(
|
||||
event.usage.context_breakdown?.tool_definitions ?? 0,
|
||||
),
|
||||
},
|
||||
{
|
||||
gemini_cli_key:
|
||||
EventMetadataKey.GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_HISTORY,
|
||||
value: JSON.stringify(event.usage.context_breakdown?.history ?? 0),
|
||||
},
|
||||
{
|
||||
gemini_cli_key:
|
||||
EventMetadataKey.GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_TOOL_CALLS,
|
||||
value: JSON.stringify(event.usage.context_breakdown?.tool_calls ?? {}),
|
||||
},
|
||||
{
|
||||
gemini_cli_key:
|
||||
EventMetadataKey.GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_MCP_SERVERS,
|
||||
value: JSON.stringify(event.usage.context_breakdown?.mcp_servers ?? 0),
|
||||
},
|
||||
];
|
||||
|
||||
this.enqueueLogEvent(this.createLogEvent(EventNames.API_RESPONSE, data));
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Defines valid event metadata keys for Clearcut logging.
|
||||
export enum EventMetadataKey {
|
||||
// Deleted enums: 24
|
||||
// Next ID: 167
|
||||
// Next ID: 172
|
||||
|
||||
GEMINI_CLI_KEY_UNKNOWN = 0,
|
||||
|
||||
@@ -137,6 +137,21 @@ export enum EventMetadataKey {
|
||||
// Logs the tool use token count of the API call.
|
||||
GEMINI_CLI_API_RESPONSE_TOOL_TOKEN_COUNT = 29,
|
||||
|
||||
// Logs the token count for system instructions.
|
||||
GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_SYSTEM_INSTRUCTIONS = 167,
|
||||
|
||||
// Logs the token count for tool definitions.
|
||||
GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_TOOL_DEFINITIONS = 168,
|
||||
|
||||
// Logs the token count for conversation history.
|
||||
GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_HISTORY = 169,
|
||||
|
||||
// Logs the token count for tool calls (JSON map of tool name to tokens).
|
||||
GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_TOOL_CALLS = 170,
|
||||
|
||||
// Logs the token count from MCP servers (tool definitions + tool inputs/outputs).
|
||||
GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_MCP_SERVERS = 171,
|
||||
|
||||
// ==========================================================================
|
||||
// GenAI API Error Event Keys
|
||||
// ===========================================================================
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { OutputFormat } from '../output/types.js';
|
||||
import { logs } from '@opentelemetry/api-logs';
|
||||
import type { Config, GeminiCLIExtension } from '../config/config.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import {
|
||||
logApiError,
|
||||
logApiRequest,
|
||||
@@ -95,6 +96,7 @@ import {
|
||||
EVENT_HOOK_CALL,
|
||||
LlmRole,
|
||||
} from './types.js';
|
||||
import { HookType } from '../hooks/types.js';
|
||||
import * as metrics from './metrics.js';
|
||||
import { FileOperation } from './metrics.js';
|
||||
import * as sdk from './sdk.js';
|
||||
@@ -1855,6 +1857,7 @@ describe('loggers', () => {
|
||||
'test-reason',
|
||||
false,
|
||||
undefined,
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
|
||||
logModelRouting(mockConfig, event);
|
||||
@@ -1889,6 +1892,7 @@ describe('loggers', () => {
|
||||
'[Score: 90 / Threshold: 80] reasoning',
|
||||
false,
|
||||
undefined,
|
||||
ApprovalMode.DEFAULT,
|
||||
true,
|
||||
'80',
|
||||
);
|
||||
@@ -1922,6 +1926,7 @@ describe('loggers', () => {
|
||||
'test-reason',
|
||||
false,
|
||||
undefined,
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
|
||||
logModelRouting(mockConfig, event);
|
||||
@@ -2327,7 +2332,7 @@ describe('loggers', () => {
|
||||
it('should log hook call event to Clearcut and OTEL', () => {
|
||||
const event = new HookCallEvent(
|
||||
'before-tool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'/path/to/script.sh',
|
||||
{ arg: 'val' },
|
||||
150,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
TokenStorageInitializationEvent,
|
||||
} from './types.js';
|
||||
import { AgentTerminateMode } from '../agents/types.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
|
||||
const mockCounterAddFn: Mock<
|
||||
(value: number, attributes?: Attributes, context?: Context) => void
|
||||
@@ -490,6 +491,7 @@ describe('Telemetry Metrics', () => {
|
||||
'test-reason',
|
||||
false,
|
||||
undefined,
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
recordModelRoutingMetricsModule(mockConfig, event);
|
||||
expect(mockHistogramRecordFn).not.toHaveBeenCalled();
|
||||
@@ -505,6 +507,7 @@ describe('Telemetry Metrics', () => {
|
||||
'test-reason',
|
||||
false,
|
||||
undefined,
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
recordModelRoutingMetricsModule(mockConfig, event);
|
||||
|
||||
@@ -516,6 +519,7 @@ describe('Telemetry Metrics', () => {
|
||||
'routing.decision_source': 'default',
|
||||
'routing.failed': false,
|
||||
'routing.reasoning': 'test-reason',
|
||||
'routing.approval_mode': ApprovalMode.DEFAULT,
|
||||
});
|
||||
// The session counter is called once on init
|
||||
expect(mockCounterAddFn).toHaveBeenCalledTimes(1);
|
||||
@@ -530,6 +534,7 @@ describe('Telemetry Metrics', () => {
|
||||
'test-reason',
|
||||
true,
|
||||
'test-error',
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
recordModelRoutingMetricsModule(mockConfig, event);
|
||||
|
||||
@@ -541,6 +546,7 @@ describe('Telemetry Metrics', () => {
|
||||
'routing.decision_source': 'Classifier',
|
||||
'routing.failed': true,
|
||||
'routing.reasoning': 'test-reason',
|
||||
'routing.approval_mode': ApprovalMode.DEFAULT,
|
||||
});
|
||||
|
||||
expect(mockCounterAddFn).toHaveBeenCalledTimes(2);
|
||||
@@ -552,6 +558,7 @@ describe('Telemetry Metrics', () => {
|
||||
'routing.decision_source': 'Classifier',
|
||||
'routing.failed': true,
|
||||
'routing.reasoning': 'test-reason',
|
||||
'routing.approval_mode': ApprovalMode.DEFAULT,
|
||||
'routing.error_message': 'test-error',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -863,6 +863,7 @@ export function recordModelRoutingMetrics(
|
||||
'routing.decision_model': event.decision_model,
|
||||
'routing.decision_source': event.decision_source,
|
||||
'routing.failed': event.failed,
|
||||
'routing.approval_mode': event.approval_mode,
|
||||
};
|
||||
|
||||
if (event.reasoning) {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HookCallEvent, EVENT_HOOK_CALL } from './types.js';
|
||||
import { HookType } from '../hooks/types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should create an event with all fields', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'test-hook',
|
||||
{ tool_name: 'ReadFile' },
|
||||
100,
|
||||
@@ -69,7 +70,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should create an event with minimal fields', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'test-hook',
|
||||
{ tool_name: 'ReadFile' },
|
||||
100,
|
||||
@@ -90,7 +91,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should include all fields when logPrompts is enabled', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'/path/to/.gemini/hooks/check-secrets.sh --api-key=abc123',
|
||||
{ tool_name: 'ReadFile', args: { file: 'secret.txt' } },
|
||||
100,
|
||||
@@ -123,7 +124,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should include hook_input and hook_output as JSON strings', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'test-hook',
|
||||
{ tool_name: 'ReadFile', args: { file: 'test.txt' } },
|
||||
100,
|
||||
@@ -154,7 +155,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should exclude PII-sensitive fields when logPrompts is disabled', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'/path/to/.gemini/hooks/check-secrets.sh --api-key=abc123',
|
||||
{ tool_name: 'ReadFile', args: { file: 'secret.txt' } },
|
||||
100,
|
||||
@@ -232,7 +233,7 @@ describe('Telemetry Sanitization', () => {
|
||||
for (const testCase of testCases) {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
testCase.input,
|
||||
{ tool_name: 'ReadFile' },
|
||||
100,
|
||||
@@ -248,7 +249,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should still include error field even when logPrompts is disabled', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'test-hook',
|
||||
{ tool_name: 'ReadFile' },
|
||||
100,
|
||||
@@ -276,7 +277,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should handle commands with multiple spaces', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'python script.py --arg1 --arg2',
|
||||
{},
|
||||
100,
|
||||
@@ -290,7 +291,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should handle mixed path separators', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'/path/to\\mixed\\separators.sh',
|
||||
{},
|
||||
100,
|
||||
@@ -304,7 +305,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should handle trailing slashes', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'/path/to/directory/',
|
||||
{},
|
||||
100,
|
||||
@@ -320,7 +321,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should format success message correctly', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'test-hook',
|
||||
{},
|
||||
150,
|
||||
@@ -335,7 +336,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should format failure message correctly', () => {
|
||||
const event = new HookCallEvent(
|
||||
'AfterTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'validation-hook',
|
||||
{},
|
||||
75,
|
||||
@@ -354,7 +355,7 @@ describe('Telemetry Sanitization', () => {
|
||||
|
||||
const event = new HookCallEvent(
|
||||
'BeforeModel',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'$GEMINI_PROJECT_DIR/.gemini/hooks/add-context.sh',
|
||||
{
|
||||
llm_request: {
|
||||
@@ -394,7 +395,7 @@ describe('Telemetry Sanitization', () => {
|
||||
|
||||
const event = new HookCallEvent(
|
||||
'BeforeModel',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'$GEMINI_PROJECT_DIR/.gemini/hooks/add-context.sh',
|
||||
{
|
||||
llm_request: {
|
||||
@@ -438,7 +439,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should sanitize commands with API keys', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'curl https://api.example.com -H "Authorization: Bearer sk-abc123xyz"',
|
||||
{},
|
||||
100,
|
||||
@@ -452,7 +453,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should sanitize commands with database credentials', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'psql postgresql://user:password@localhost/db',
|
||||
{},
|
||||
100,
|
||||
@@ -466,7 +467,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should sanitize commands with environment variables containing secrets', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'AWS_SECRET_KEY=abc123 aws s3 ls',
|
||||
{},
|
||||
100,
|
||||
@@ -480,7 +481,7 @@ describe('Telemetry Sanitization', () => {
|
||||
it('should sanitize Python scripts with file paths', () => {
|
||||
const event = new HookCallEvent(
|
||||
'BeforeTool',
|
||||
'command',
|
||||
HookType.Command,
|
||||
'python /home/john.doe/projects/secret-scanner/scan.py --config=/etc/secrets.yml',
|
||||
{},
|
||||
100,
|
||||
|
||||
@@ -43,6 +43,7 @@ import { sanitizeHookName } from './sanitize.js';
|
||||
import { getFileDiffFromResultDisplay } from '../utils/fileDiffUtils.js';
|
||||
import { LlmRole } from './llmRole.js';
|
||||
export { LlmRole };
|
||||
import type { HookType } from '../hooks/types.js';
|
||||
|
||||
export interface BaseTelemetryEvent {
|
||||
'event.name': string;
|
||||
@@ -568,6 +569,14 @@ export interface GenAIResponseDetails {
|
||||
candidates?: Candidate[];
|
||||
}
|
||||
|
||||
export interface ContextBreakdown {
|
||||
system_instructions: number;
|
||||
tool_definitions: number;
|
||||
history: number;
|
||||
tool_calls: Record<string, number>;
|
||||
mcp_servers: number;
|
||||
}
|
||||
|
||||
export interface GenAIUsageDetails {
|
||||
input_token_count: number;
|
||||
output_token_count: number;
|
||||
@@ -575,6 +584,7 @@ export interface GenAIUsageDetails {
|
||||
thoughts_token_count: number;
|
||||
tool_token_count: number;
|
||||
total_token_count: number;
|
||||
context_breakdown?: ContextBreakdown;
|
||||
}
|
||||
|
||||
export const EVENT_API_RESPONSE = 'gemini_cli.api_response';
|
||||
@@ -1360,6 +1370,7 @@ export class ModelRoutingEvent implements BaseTelemetryEvent {
|
||||
error_message?: string;
|
||||
enable_numerical_routing?: boolean;
|
||||
classifier_threshold?: string;
|
||||
approval_mode: ApprovalMode;
|
||||
|
||||
constructor(
|
||||
decision_model: string,
|
||||
@@ -1368,6 +1379,7 @@ export class ModelRoutingEvent implements BaseTelemetryEvent {
|
||||
reasoning: string | undefined,
|
||||
failed: boolean,
|
||||
error_message: string | undefined,
|
||||
approval_mode: ApprovalMode,
|
||||
enable_numerical_routing?: boolean,
|
||||
classifier_threshold?: string,
|
||||
) {
|
||||
@@ -1379,6 +1391,7 @@ export class ModelRoutingEvent implements BaseTelemetryEvent {
|
||||
this.reasoning = reasoning;
|
||||
this.failed = failed;
|
||||
this.error_message = error_message;
|
||||
this.approval_mode = approval_mode;
|
||||
this.enable_numerical_routing = enable_numerical_routing;
|
||||
this.classifier_threshold = classifier_threshold;
|
||||
}
|
||||
@@ -1392,6 +1405,7 @@ export class ModelRoutingEvent implements BaseTelemetryEvent {
|
||||
decision_source: this.decision_source,
|
||||
routing_latency_ms: this.routing_latency_ms,
|
||||
failed: this.failed,
|
||||
approval_mode: this.approval_mode,
|
||||
};
|
||||
|
||||
if (this.reasoning) {
|
||||
@@ -2166,7 +2180,7 @@ export class HookCallEvent implements BaseTelemetryEvent {
|
||||
'event.name': string;
|
||||
'event.timestamp': string;
|
||||
hook_event_name: string;
|
||||
hook_type: 'command';
|
||||
hook_type: HookType;
|
||||
hook_name: string;
|
||||
hook_input: Record<string, unknown>;
|
||||
hook_output?: Record<string, unknown>;
|
||||
@@ -2179,7 +2193,7 @@ export class HookCallEvent implements BaseTelemetryEvent {
|
||||
|
||||
constructor(
|
||||
hookEventName: string,
|
||||
hookType: 'command',
|
||||
hookType: HookType,
|
||||
hookName: string,
|
||||
hookInput: Record<string, unknown>,
|
||||
durationMs: number,
|
||||
|
||||
@@ -29,6 +29,16 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
*/
|
||||
export const MCP_QUALIFIED_NAME_SEPARATOR = '__';
|
||||
|
||||
/**
|
||||
* Returns true if `name` matches the MCP qualified name format: "server__tool",
|
||||
* i.e. exactly two non-empty parts separated by the MCP_QUALIFIED_NAME_SEPARATOR.
|
||||
*/
|
||||
export function isMcpToolName(name: string): boolean {
|
||||
if (!name.includes(MCP_QUALIFIED_NAME_SEPARATOR)) return false;
|
||||
const parts = name.split(MCP_QUALIFIED_NAME_SEPARATOR);
|
||||
return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0;
|
||||
}
|
||||
|
||||
type ToolParams = Record<string, unknown>;
|
||||
|
||||
// Discriminated union for MCP Content Blocks to ensure type safety.
|
||||
|
||||
@@ -2129,6 +2129,33 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
============================================================
|
||||
path-to-regexp@6.3.0
|
||||
(https://github.com/pillarjs/path-to-regexp.git)
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
send@1.2.1
|
||||
(No repository found)
|
||||
|
||||
@@ -31,4 +31,4 @@ To use this extension, you'll need:
|
||||
# Terms of Service and Privacy Notice
|
||||
|
||||
By installing this extension, you agree to the
|
||||
[Terms of Service](https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md).
|
||||
[Terms of Service](https://geminicli.com/docs/resources/tos-privacy/).
|
||||
|
||||
@@ -117,6 +117,13 @@
|
||||
"description": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.",
|
||||
"markdownDescription": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.\n\n- Category: `General`\n- Requires restart: `yes`",
|
||||
"type": "string"
|
||||
},
|
||||
"modelRouting": {
|
||||
"title": "Plan Model Routing",
|
||||
"description": "Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase.",
|
||||
"markdownDescription": "Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `true`",
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
Reference in New Issue
Block a user