Compare commits

..

20 Commits

Author SHA1 Message Date
Sehoon Shon affe6856f1 fix(cli): address PR comments for /note command (v3) 2026-04-21 22:15:39 -07:00
Sehoon Shon f727139c72 feat(cli): add /note slash command to append or view notes 2026-04-21 22:03:59 -07:00
Sandy Tao ffb28c772b test(e2e): default integration tests to Flash Preview (#25753) 2026-04-21 22:21:52 +00:00
Adam Weidman d6f88f8720 fix(core): remove duplicate initialize call on agents refreshed (#25670) 2026-04-21 20:17:21 +00:00
Jason Matthew Suhari 194c779f9b fix(cli): start auto memory in ACP sessions (#25626) 2026-04-21 20:06:30 +00:00
Vedant Mahajan 189c0ac0a0 feat: add /new as alias for /clear and refine command description (#17865) 2026-04-21 20:04:40 +00:00
euxaristia c47233a474 fix(core): disable detached mode in Bun to prevent immediate SIGHUP of child processes (#22620) 2026-04-21 20:01:28 +00:00
JAYADITYA 8999a885f0 fix(cli): ensure theme dialog labels are rendered for all themes (#24599)
Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com>
2026-04-21 19:57:15 +00:00
Coco Sheng 93a8d9001c fix(cli): use newline in shell command wrapping to avoid breaking heredocs (#25537) 2026-04-21 19:12:50 +00:00
PRAS Samin cdc5cccc13 feat: detect new files in @ recommendations with watcher based updates (#25256) 2026-04-21 18:35:14 +00:00
Mahima Shanware a4e98c0a4c fix(core): resolve nested plan directory duplication and relative path policies (#25138) 2026-04-21 18:20:57 +00:00
Spencer c260550146 feat(telemetry): add flag for enabling traces specifically (#25343) 2026-04-21 18:07:32 +00:00
Danyel Cabello 7f8f3309a6 Allow dots on GEMINI_API_KEY (#25497) 2026-04-21 11:43:39 -07:00
Muhammad Ahsan Farooq ebebbbfc20 Fix/allow for session persistence (#25176) 2026-04-21 11:20:07 -07:00
Gordon Hui 27344833cb feat(vertex): add settings for Vertex AI request routing (#25513) 2026-04-21 17:48:30 +00:00
cynthialong0-0 aee2cde1a3 feat(test): refactor the memory usage test to use metrics from CLI process instead of test runner (#25708) 2026-04-21 17:06:22 +00:00
Mundur 2c14954010 Fix: Disallow overriding IDE stdio via workspace .env (RCE) (#25022)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-04-21 10:31:10 -07:00
gemini-cli-robot a38e2f0048 Changelog for v0.38.2 (#25593)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-04-21 00:39:10 +00:00
Samee Zahid 1d383a4a8e feat(cli): add streamlined gemini gemma local model setup (#25498)
Co-authored-by: Abhijit Balaji <abhijitbalaji@google.com>
Co-authored-by: Samee Zahid <sameez@google.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-20 23:57:56 +00:00
Timo 6afc47f81c docs(cli): fix inconsistent system.md casing in system prompt docs (#25414)
Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com>
2026-04-20 23:03:36 +00:00
112 changed files with 4808 additions and 565 deletions
+6 -3
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.38.1
# Latest stable release: v0.38.2
Released: April 15, 2026
Released: April 17, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -29,6 +29,9 @@ npm install -g @google/gemini-cli
## What's Changed
- fix(patch): cherry-pick 14b2f35 to release/v0.38.1-pr-24974 to patch version
v0.38.1 and create version 0.38.2 by @gemini-cli-robot in
[#25585](https://github.com/google-gemini/gemini-cli/pull/25585)
- fix(patch): cherry-pick 050c303 to release/v0.38.0-pr-25317 to patch version
v0.38.0 and create version 0.38.1 by @gemini-cli-robot in
[#25466](https://github.com/google-gemini/gemini-cli/pull/25466)
@@ -268,4 +271,4 @@ npm install -g @google/gemini-cli
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.1
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.2
+1 -2
View File
@@ -331,7 +331,6 @@ Storage whenever Gemini CLI exits Plan Mode to start the implementation.
#!/usr/bin/env bash
# Extract the plan filename from the tool input JSON
plan_filename=$(jq -r '.tool_input.plan_filename // empty')
plan_filename=$(basename -- "$plan_filename")
# Construct the absolute path using the GEMINI_PLANS_DIR environment variable
plan_path="$GEMINI_PLANS_DIR/$plan_filename"
@@ -360,7 +359,7 @@ To register this `AfterTool` hook, add it to your `settings.json`:
{
"name": "archive-plan",
"type": "command",
"command": "./.gemini/hooks/archive-plan.sh"
"command": "~/.gemini/hooks/archive-plan.sh"
}
]
}
+13 -11
View File
@@ -161,17 +161,19 @@ they appear in the UI.
### Experimental
| UI Label | Setting | Description | Default |
| ---------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
| UI Label | Setting | Description | Default |
| ---------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
### Skills
+5 -5
View File
@@ -51,7 +51,7 @@ error with: `missing system prompt file '<path>'`.
- Create `.gemini/system.md`, then add to `.gemini/.env`:
- `GEMINI_SYSTEM_MD=1`
- Use a custom file under your home directory:
- `GEMINI_SYSTEM_MD=~/prompts/SYSTEM.md gemini`
- `GEMINI_SYSTEM_MD=~/prompts/system.md gemini`
## UI indicator
@@ -102,17 +102,17 @@ safety and workflow rules.
This creates the file and writes the current builtin system prompt to it.
## Best practices: SYSTEM.md vs GEMINI.md
## Best practices: system.md vs GEMINI.md
- SYSTEM.md (firmware):
- system.md (firmware):
- Nonnegotiable operational rules: safety, tooluse protocols, approvals, and
mechanics that keep the CLI reliable.
- Stable across tasks and projects (or per project when needed).
- GEMINI.md (strategy):
- Persona, goals, methodologies, and project/domain context.
- Evolves per task; relies on SYSTEM.md for safe execution.
- Evolves per task; relies on system.md for safe execution.
Keep SYSTEM.md minimal but complete for safety and tool operation. Keep
Keep system.md minimal but complete for safety and tool operation. Keep
GEMINI.md focused on highlevel guidance and project specifics.
## Troubleshooting
+18 -11
View File
@@ -35,17 +35,18 @@ The observability system provides:
You control telemetry behavior through the `.gemini/settings.json` file.
Environment variables can override these settings.
| Setting | Environment Variable | Description | Values | Default |
| -------------- | -------------------------------- | --------------------------------------------------- | ----------------- | ----------------------- |
| `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` |
| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` |
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` |
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` |
| `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` |
| - | `GEMINI_CLI_SURFACE` | Optional custom label for traffic reporting | string | - |
| Setting | Environment Variable | Description | Values | Default |
| -------------- | --------------------------------- | --------------------------------------------------- | ----------------- | ----------------------- |
| `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` |
| `traces` | `GEMINI_TELEMETRY_TRACES_ENABLED` | Enable detailed attribute tracing | `true`/`false` | `false` |
| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` |
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` |
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` |
| `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` |
| - | `GEMINI_CLI_SURFACE` | Optional custom label for traffic reporting | string | - |
**Note on boolean environment variables:** For boolean settings like `enabled`,
setting the environment variable to `true` or `1` enables the feature.
@@ -1235,6 +1236,12 @@ These metrics follow standard [OpenTelemetry GenAI semantic conventions].
Traces provide an "under-the-hood" view of agent and backend operations. Use
traces to debug tool interactions and optimize performance.
<!-- prettier-ignore -->
> [!NOTE]
> Detailed trace attributes (like full prompts and tool outputs) are disabled by default
> to minimize overhead. You must explicitly set `telemetry.traces` to `true` (or set
> `GEMINI_TELEMETRY_TRACES_ENABLED=true`) to capture them.
Every trace captures rich metadata via standard span attributes.
<details open>
+38
View File
@@ -436,6 +436,20 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `"ask"`
- **Values:** `"ask"`, `"always"`, `"never"`
- **`billing.vertexAi.requestType`** (enum):
- **Description:** Sets the X-Vertex-AI-LLM-Request-Type header for Vertex AI
requests.
- **Default:** `undefined`
- **Values:** `"dedicated"`, `"shared"`
- **Requires restart:** Yes
- **`billing.vertexAi.sharedRequestType`** (enum):
- **Description:** Sets the X-Vertex-AI-LLM-Shared-Request-Type header for
Vertex AI requests.
- **Default:** `undefined`
- **Values:** `"priority"`, `"flex"`
- **Requires restart:** Yes
#### `model`
- **`model.name`** (string):
@@ -1359,6 +1373,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`context.fileFiltering.enableFileWatcher`** (boolean):
- **Description:** Enable file watcher updates for @ file suggestions
(experimental).
- **Default:** `false`
- **Requires restart:** Yes
- **`context.fileFiltering.enableRecursiveFileSearch`** (boolean):
- **Description:** Enable recursive file search functionality when completing
@ references in the prompt.
@@ -1711,6 +1731,18 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.autoStartServer`** (boolean):
- **Description:** Automatically start the LiteRT-LM server when Gemini CLI
starts and the Gemma router is enabled.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.binaryPath`** (string):
- **Description:** Custom path to the LiteRT-LM binary. Leave empty to use the
default location (~/.gemini/bin/litert/).
- **Default:** `""`
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.classifier.host`** (string):
- **Description:** The host of the classifier.
- **Default:** `"http://localhost:9379"`
@@ -1986,6 +2018,8 @@ see [Telemetry](../cli/telemetry.md).
- **Properties:**
- **`enabled`** (boolean): Whether or not telemetry is enabled.
- **`traces`** (boolean): Whether detailed traces with large attributes (like
tool outputs and file reads) are captured. Defaults to `false`.
- **`target`** (string): The destination for collected telemetry. Supported
values are `local` and `gcp`.
- **`otlpEndpoint`** (string): The endpoint for the OTLP Exporter.
@@ -2186,6 +2220,10 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Set to `true` or `1` to enable telemetry. Any other value is treated as
disabling it.
- Overrides the `telemetry.enabled` setting.
- **`GEMINI_TELEMETRY_TRACES_ENABLED`**:
- Set to `true` or `1` to enable detailed tracing with large attributes. Any
other value is treated as disabling it.
- Overrides the `telemetry.traces` setting.
- **`GEMINI_TELEMETRY_TARGET`**:
- Sets the telemetry target (`local` or `gcp`).
- Overrides the `telemetry.target` setting.
+45 -1
View File
@@ -305,7 +305,7 @@ describe('plan_mode', () => {
settings,
},
prompt:
'Enter plan mode and plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
'I agree with your strategy. Please enter plan mode and draft the plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
assert: async (rig, result) => {
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
expect(
@@ -376,4 +376,48 @@ describe('plan_mode', () => {
assertModelHasOutput(result);
},
});
evalTest('USUALLY_PASSES', {
name: 'should handle nested plan directories correctly',
suiteName: 'plan_mode',
suiteType: 'behavioral',
approvalMode: ApprovalMode.PLAN,
params: {
settings,
},
prompt:
'Please create a new architectural plan in a nested folder called "architecture/frontend-v2.md" within the plans directory. The plan should contain the text "# Frontend V2 Plan". Then, exit plan mode',
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const writeCalls = toolLogs.filter((log) =>
['write_file', 'replace'].includes(log.toolRequest.name),
);
const wroteToNestedPath = writeCalls.some((log) => {
try {
const args = JSON.parse(log.toolRequest.args);
if (!args.file_path) return false;
// In plan mode, paths can be passed as relative (architecture/frontend-v2.md)
// or they might be resolved as absolute by the tool depending on the exact mock state.
// We strictly ensure it ends exactly with the expected nested path and doesn't contain extra nesting.
const normalizedPath = args.file_path.replace(/\\/g, '/');
return (
normalizedPath === 'architecture/frontend-v2.md' ||
normalizedPath.endsWith('/plans/architecture/frontend-v2.md')
);
} catch {
return false;
}
});
expect(
wroteToNestedPath,
'Expected model to successfully target the nested plan file path',
).toBe(true);
assertModelHasOutput(result);
},
});
});
+1
View File
@@ -70,6 +70,7 @@ describe('ACP telemetry', () => {
GEMINI_API_KEY: 'fake-key',
GEMINI_CLI_HOME: rig.homeDir!,
GEMINI_TELEMETRY_ENABLED: 'true',
GEMINI_TELEMETRY_TRACES_ENABLED: 'true',
GEMINI_TELEMETRY_TARGET: 'local',
GEMINI_TELEMETRY_OUTFILE: telemetryPath,
},
@@ -8,7 +8,16 @@ import { expect, describe, it, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { join } from 'node:path';
describe('Interactive Mode', () => {
// Skip on macOS: every interactive test in this file is chronically flaky
// because the captured pty buffer contains the CLI's startup escape
// sequences (`q4;?m...true color warning`) instead of the streamed output,
// causing `expectText(...)` to time out. Reproducible across unrelated
// runs on `main` (24740161950, 24739323404) and on consecutive merge-queue
// gates for #25753 (24743605639, 24747624513) — different tests in the
// same describe fail on different runs. Not specific to any model.
const skipOnDarwin = process.platform === 'darwin';
describe.skipIf(skipOnDarwin)('Interactive Mode', () => {
let rig: TestRig;
beforeEach(() => {
+3 -1
View File
@@ -134,7 +134,9 @@ describe('file-system', () => {
).toBeTruthy();
const newFileContent = rig.readFile(fileName);
expect(newFileContent).toBe('hello');
// Trim to tolerate models that idiomatically append a trailing newline.
// This test is about path-with-spaces handling, not whitespace fidelity.
expect(newFileContent.trim()).toBe('hello');
});
it('should perform a read-then-write sequence', async () => {
+9 -2
View File
@@ -81,7 +81,10 @@ describe('Plan Mode', () => {
await rig.run({
approvalMode: 'plan',
args: 'Create a file called plan.md in the plans directory.',
args:
'Create a file called plan.md in the plans directory with the ' +
'content "# Plan". Treat this as a Directive and write the file ' +
'immediately without proposing strategy or asking for confirmation.',
});
const toolLogs = rig.readToolLogs();
@@ -194,7 +197,11 @@ describe('Plan Mode', () => {
await rig.run({
approvalMode: 'plan',
args: 'Create a file called plan-no-session.md in the plans directory.',
args:
'Create a file called plan-no-session.md in the plans directory ' +
'with the content "# Plan". Treat this as a Directive and write ' +
'the file immediately without proposing strategy or asking for ' +
'confirmation.',
});
const toolLogs = rig.readToolLogs();
+36 -36
View File
@@ -1,55 +1,55 @@
{
"version": 1,
"updatedAt": "2026-04-10T15:36:04.547Z",
"updatedAt": "2026-04-20T18:04:59.671Z",
"scenarios": {
"multi-turn-conversation": {
"heapUsedBytes": 120082704,
"heapTotalBytes": 177586176,
"rssBytes": 269172736,
"externalBytes": 4304053,
"timestamp": "2026-04-10T15:35:17.603Z"
"heapUsedMB": 68.8,
"heapTotalMB": 91.2,
"rssMB": 215.4,
"externalMB": 93.8,
"timestamp": "2026-04-20T18:02:40.101Z"
},
"multi-function-call-repo-search": {
"heapUsedBytes": 104644984,
"heapTotalBytes": 111575040,
"rssBytes": 204079104,
"externalBytes": 4304053,
"timestamp": "2026-04-10T15:35:22.480Z"
"heapUsedMB": 73.5,
"heapTotalMB": 93.1,
"rssMB": 223.6,
"externalMB": 97.7,
"timestamp": "2026-04-20T18:02:42.032Z"
},
"idle-session-startup": {
"heapUsedBytes": 119813672,
"heapTotalBytes": 177061888,
"rssBytes": 267943936,
"externalBytes": 4304053,
"timestamp": "2026-04-10T15:35:08.035Z"
"heapUsedMB": 69.8,
"heapTotalMB": 92.4,
"rssMB": 217.4,
"externalMB": 93.8,
"timestamp": "2026-04-20T18:02:36.294Z"
},
"simple-prompt-response": {
"heapUsedBytes": 119722064,
"heapTotalBytes": 177324032,
"rssBytes": 268812288,
"externalBytes": 4304053,
"timestamp": "2026-04-10T15:35:12.770Z"
"heapUsedMB": 69.5,
"heapTotalMB": 92.4,
"rssMB": 216.1,
"externalMB": 93.8,
"timestamp": "2026-04-20T18:02:38.198Z"
},
"resume-large-chat-with-messages": {
"heapUsedBytes": 106545568,
"heapTotalBytes": 111509504,
"rssBytes": 202596352,
"externalBytes": 4306101,
"timestamp": "2026-04-10T15:36:04.547Z"
"heapUsedMB": 887.1,
"heapTotalMB": 954.3,
"rssMB": 1109.6,
"externalMB": 103.2,
"timestamp": "2026-04-20T18:04:59.671Z"
},
"resume-large-chat": {
"heapUsedBytes": 106513760,
"heapTotalBytes": 111509504,
"rssBytes": 202596352,
"externalBytes": 4306101,
"timestamp": "2026-04-10T15:35:59.528Z"
"heapUsedMB": 885.6,
"heapTotalMB": 955.6,
"rssMB": 1107.8,
"externalMB": 110.5,
"timestamp": "2026-04-20T18:04:06.526Z"
},
"large-chat": {
"heapUsedBytes": 106471568,
"heapTotalBytes": 111509504,
"rssBytes": 202596352,
"externalBytes": 4306101,
"timestamp": "2026-04-10T15:35:53.180Z"
"heapUsedMB": 158.5,
"heapTotalMB": 193,
"rssMB": 787.9,
"externalMB": 104,
"timestamp": "2026-04-20T18:03:12.486Z"
}
}
}
+30 -8
View File
@@ -16,15 +16,21 @@ import {
mkdirSync,
rmSync,
} from 'node:fs';
import { randomUUID } from 'node:crypto';
import { randomUUID, createHash } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASELINES_PATH = join(__dirname, 'baselines.json');
const UPDATE_BASELINES = process.env['UPDATE_MEMORY_BASELINES'] === 'true';
function getProjectHash(projectRoot: string): string {
return createHash('sha256').update(projectRoot).digest('hex');
}
const TOLERANCE_PERCENT = 10;
// Fake API key for tests using fake responses
const TEST_ENV = { GEMINI_API_KEY: 'fake-memory-test-key' };
const TEST_ENV = {
GEMINI_API_KEY: 'fake-memory-test-key',
GEMINI_MEMORY_MONITOR_INTERVAL: '100',
};
describe('Memory Usage Tests', () => {
let harness: MemoryTestHarness;
@@ -56,6 +62,7 @@ describe('Memory Usage Tests', () => {
});
const result = await harness.runScenario(
rig,
'idle-session-startup',
async (recordSnapshot) => {
await rig.run({
@@ -85,6 +92,7 @@ describe('Memory Usage Tests', () => {
});
const result = await harness.runScenario(
rig,
'simple-prompt-response',
async (recordSnapshot) => {
await rig.run({
@@ -122,6 +130,7 @@ describe('Memory Usage Tests', () => {
];
const result = await harness.runScenario(
rig,
'multi-turn-conversation',
async (recordSnapshot) => {
// Run through all turns as a piped sequence
@@ -144,6 +153,9 @@ describe('Memory Usage Tests', () => {
);
} else {
harness.assertWithinBaseline(result);
harness.assertMemoryReturnsToBaseline(result.snapshots, 20);
const { leaked, message } = harness.analyzeSnapshots(result.snapshots);
if (leaked) console.warn(`${message}`);
}
});
@@ -168,6 +180,7 @@ describe('Memory Usage Tests', () => {
);
const result = await harness.runScenario(
rig,
'multi-function-call-repo-search',
async (recordSnapshot) => {
await rig.run({
@@ -189,6 +202,7 @@ describe('Memory Usage Tests', () => {
);
} else {
harness.assertWithinBaseline(result);
harness.assertMemoryReturnsToBaseline(result.snapshots, 20);
}
});
@@ -228,6 +242,7 @@ describe('Memory Usage Tests', () => {
});
const result = await harness.runScenario(
rig,
'large-chat',
async (recordSnapshot) => {
await rig.run({
@@ -257,19 +272,21 @@ describe('Memory Usage Tests', () => {
});
const result = await harness.runScenario(
rig,
'resume-large-chat',
async (recordSnapshot) => {
// Ensure the history file is linked
const targetChatsDir = join(
rig.testDir!,
rig.homeDir!,
'.gemini',
'tmp',
'test-project-hash',
getProjectHash(rig.testDir!),
'chats',
);
mkdirSync(targetChatsDir, { recursive: true });
const targetHistoryPath = join(
targetChatsDir,
'large-chat-session.json',
'session-large-chat.json',
);
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
copyFileSync(sharedHistoryPath, targetHistoryPath);
@@ -302,19 +319,21 @@ describe('Memory Usage Tests', () => {
});
const result = await harness.runScenario(
rig,
'resume-large-chat-with-messages',
async (recordSnapshot) => {
// Ensure the history file is linked
const targetChatsDir = join(
rig.testDir!,
rig.homeDir!,
'.gemini',
'tmp',
'test-project-hash',
getProjectHash(rig.testDir!),
'chats',
);
mkdirSync(targetChatsDir, { recursive: true });
const targetHistoryPath = join(
targetChatsDir,
'large-chat-session.json',
'session-large-chat.json',
);
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
copyFileSync(sharedHistoryPath, targetHistoryPath);
@@ -457,6 +476,9 @@ async function generateSharedLargeChatData(tempDir: string) {
// Generate responses for resumed chat
const resumeResponsesStream = createWriteStream(resumeResponsesPath);
for (let i = 0; i < 5; i++) {
// Doubling up on non-streaming responses to satisfy classifier and complexity checks
resumeResponsesStream.write(JSON.stringify(complexityResponse) + '\n');
resumeResponsesStream.write(JSON.stringify(summaryResponse) + '\n');
resumeResponsesStream.write(JSON.stringify(complexityResponse) + '\n');
resumeResponsesStream.write(
JSON.stringify({
+61 -53
View File
@@ -449,7 +449,8 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
@@ -1473,6 +1474,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -2150,6 +2152,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2330,6 +2333,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2379,6 +2383,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2753,6 +2758,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2786,6 +2792,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2840,6 +2847,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4046,6 +4054,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4319,6 +4328,7 @@
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
@@ -4593,56 +4603,6 @@
}
}
},
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
"integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.47.0",
"@typescript-eslint/visitor-keys": "8.47.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
"integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
"integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.47.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -5113,6 +5073,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7190,7 +7151,8 @@
"version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7775,6 +7737,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8292,6 +8255,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9558,6 +9522,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -9817,6 +9782,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.3",
@@ -13530,6 +13496,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13540,6 +13507,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15659,6 +15627,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15881,7 +15850,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -15889,6 +15859,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16054,6 +16025,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16121,6 +16093,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -16507,6 +16480,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -17077,6 +17051,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17089,6 +17064,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17727,6 +17703,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18054,6 +18031,7 @@
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"chokidar": "^5.0.0",
"diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
@@ -18161,6 +18139,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -18203,6 +18182,21 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"packages/core/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"packages/core/node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -18264,6 +18258,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -18271,6 +18266,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"packages/core/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"packages/core/node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -98,6 +98,7 @@ export function createMockConfig(
getMcpServers: vi.fn().mockReturnValue({}),
}),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn(),
validatePathAccess: vi.fn().mockReturnValue(undefined),
getShellExecutionConfig: vi.fn().mockReturnValue({
+34
View File
@@ -41,6 +41,8 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { ApprovalMode } from '@google/gemini-cli-core/src/policy/types.js';
const startMemoryServiceMock = vi.hoisted(() => vi.fn());
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(),
}));
@@ -101,6 +103,7 @@ vi.mock(
const actual = await importOriginal();
return {
...actual,
startMemoryService: startMemoryServiceMock,
updatePolicy: vi.fn(),
createPolicyUpdater: vi.fn(),
ReadManyFilesTool: vi.fn(),
@@ -148,6 +151,8 @@ describe('GeminiAgent', () => {
let agent: GeminiAgent;
beforeEach(() => {
vi.clearAllMocks();
startMemoryServiceMock.mockResolvedValue(undefined);
mockConfig = {
refreshAuth: vi.fn(),
initialize: vi.fn(),
@@ -155,6 +160,7 @@ describe('GeminiAgent', () => {
getFileSystemService: vi.fn(),
setFileSystemService: vi.fn(),
getContentGeneratorConfig: vi.fn(),
isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getGeminiClient: vi.fn().mockReturnValue({
@@ -354,6 +360,34 @@ describe('GeminiAgent', () => {
vi.useRealTimers();
});
it('should start auto memory for new ACP sessions when enabled', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.isAutoMemoryEnabled = vi.fn().mockReturnValue(true);
await agent.newSession({
cwd: '/tmp',
mcpServers: [],
});
expect(startMemoryServiceMock).toHaveBeenCalledWith(mockConfig);
});
it('should not start auto memory for new ACP sessions when disabled', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.isAutoMemoryEnabled = vi.fn().mockReturnValue(false);
await agent.newSession({
cwd: '/tmp',
mcpServers: [],
});
expect(startMemoryServiceMock).not.toHaveBeenCalled();
});
it('should return modes without plan mode when plan is disabled', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
+3
View File
@@ -76,6 +76,7 @@ import { randomUUID } from 'node:crypto';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import { runExitCleanup } from '../utils/cleanup.js';
import { SessionSelector } from '../utils/sessionUtils.js';
import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
import { CommandHandler } from './commandHandler.js';
@@ -324,6 +325,7 @@ export class GeminiAgent {
await config.initialize();
startupProfiler.flush(config);
startAutoMemoryIfEnabled(config);
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
@@ -465,6 +467,7 @@ export class GeminiAgent {
// which starts the MCP servers and other heavy resources.
await config.initialize();
startupProfiler.flush(config);
startAutoMemoryIfEnabled(config);
return config;
}
+1
View File
@@ -100,6 +100,7 @@ describe('GeminiAgent Session Resume', () => {
unsubscribe: vi.fn(),
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
+33
View File
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule, Argv } from 'yargs';
import { initializeOutputListenersAndFlush } from '../gemini.js';
import { defer } from '../deferred.js';
import { setupCommand } from './gemma/setup.js';
import { startCommand } from './gemma/start.js';
import { stopCommand } from './gemma/stop.js';
import { statusCommand } from './gemma/status.js';
import { logsCommand } from './gemma/logs.js';
export const gemmaCommand: CommandModule = {
command: 'gemma',
describe: 'Manage local Gemma model routing',
builder: (yargs: Argv) =>
yargs
.middleware((argv) => {
initializeOutputListenersAndFlush();
argv['isCommand'] = true;
})
.command(defer(setupCommand, 'gemma'))
.command(defer(startCommand, 'gemma'))
.command(defer(stopCommand, 'gemma'))
.command(defer(statusCommand, 'gemma'))
.command(defer(logsCommand, 'gemma'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {},
};
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { Storage } from '@google/gemini-cli-core';
export const LITERT_RELEASE_VERSION = 'v0.9.0-alpha03';
export const LITERT_RELEASE_BASE_URL =
'https://github.com/google-ai-edge/LiteRT-LM/releases/download';
export const GEMMA_MODEL_NAME = 'gemma3-1b-gpu-custom';
export const DEFAULT_PORT = 9379;
export const HEALTH_CHECK_TIMEOUT_MS = 5000;
export const LITERT_API_VERSION = 'v1beta';
export const SERVER_START_WAIT_MS = 3000;
export const PLATFORM_BINARY_MAP: Record<string, string> = {
'darwin-arm64': 'lit.macos_arm64',
'linux-x64': 'lit.linux_x86_64',
'win32-x64': 'lit.windows_x86_64.exe',
};
// SHA-256 hashes for the official LiteRT-LM v0.9.0-alpha03 release binaries.
export const PLATFORM_BINARY_SHA256: Record<string, string> = {
'lit.macos_arm64':
'9e826a2634f2e8b220ad0f1e1b5c139e0b47cb172326e3b7d46d31382f49478e',
'lit.linux_x86_64':
'66601df8a07f08244b188e9fcab0bf4a16562fe76d8d47e49f40273d57541ee8',
'lit.windows_x86_64.exe':
'de82d2829d2fb1cbdb318e2d8a78dc2f9659ff14cb11b2894d1f30e0bfde2bf6',
};
export function getLiteRtBinDir(): string {
return path.join(Storage.getGlobalGeminiDir(), 'bin', 'litert');
}
export function getPidFilePath(): string {
return path.join(Storage.getGlobalTempDir(), 'litert-server.pid');
}
export function getLogFilePath(): string {
return path.join(Storage.getGlobalTempDir(), 'litert-server.log');
}
@@ -0,0 +1,186 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import type { ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { spawn } from 'node:child_process';
import { exitCli } from '../utils.js';
import { getLogFilePath } from './constants.js';
import { logsCommand, readLastLines } from './logs.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const { mockCoreDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return mockCoreDebugLogger(
await importOriginal<typeof import('@google/gemini-cli-core')>(),
{
stripAnsi: false,
},
);
});
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return {
...actual,
spawn: vi.fn(),
};
});
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
vi.mock('./constants.js', () => ({
getLogFilePath: vi.fn(),
}));
function createMockChild(): ChildProcess {
return Object.assign(new EventEmitter(), {
kill: vi.fn(),
}) as unknown as ChildProcess;
}
async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
}
describe('readLastLines', () => {
const tempFiles: string[] = [];
afterEach(async () => {
await Promise.all(
tempFiles
.splice(0)
.map((filePath) => fs.promises.rm(filePath, { force: true })),
);
});
it('returns only the requested tail lines without reading the whole file eagerly', async () => {
const filePath = path.join(
os.tmpdir(),
`gemma-logs-${Date.now()}-${Math.random().toString(36).slice(2)}.log`,
);
tempFiles.push(filePath);
const content = Array.from({ length: 2000 }, (_, i) => `line-${i + 1}`)
.join('\n')
.concat('\n');
await fs.promises.writeFile(filePath, content, 'utf-8');
await expect(readLastLines(filePath, 3)).resolves.toBe(
'line-1998\nline-1999\nline-2000\n',
);
});
it('returns an empty string when zero lines are requested', async () => {
const filePath = path.join(
os.tmpdir(),
`gemma-logs-${Date.now()}-${Math.random().toString(36).slice(2)}.log`,
);
tempFiles.push(filePath);
await fs.promises.writeFile(filePath, 'line-1\nline-2\n', 'utf-8');
await expect(readLastLines(filePath, 0)).resolves.toBe('');
});
});
describe('logsCommand', () => {
const originalPlatform = process.platform;
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(process, 'platform', {
value: 'linux',
configurable: true,
});
vi.mocked(getLogFilePath).mockReturnValue('/tmp/gemma.log');
vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined);
});
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
});
vi.restoreAllMocks();
});
it('waits for the tail process to close before exiting in follow mode', async () => {
const child = createMockChild();
vi.mocked(spawn).mockReturnValue(child);
let resolved = false;
const handlerPromise = (
logsCommand.handler as (argv: Record<string, unknown>) => Promise<void>
)({}).then(() => {
resolved = true;
});
await flushMicrotasks();
expect(spawn).toHaveBeenCalledWith(
'tail',
['-f', '-n', '20', '/tmp/gemma.log'],
{ stdio: 'inherit' },
);
expect(resolved).toBe(false);
expect(exitCli).not.toHaveBeenCalled();
child.emit('close', 0);
await handlerPromise;
expect(exitCli).toHaveBeenCalledWith(0);
});
it('uses one-shot tail output when follow is disabled', async () => {
const child = createMockChild();
vi.mocked(spawn).mockReturnValue(child);
const handlerPromise = (
logsCommand.handler as (argv: Record<string, unknown>) => Promise<void>
)({ follow: false });
await flushMicrotasks();
expect(spawn).toHaveBeenCalledWith('tail', ['-n', '20', '/tmp/gemma.log'], {
stdio: 'inherit',
});
child.emit('close', 0);
await handlerPromise;
expect(exitCli).toHaveBeenCalledWith(0);
});
it('follows from the requested line count when both --lines and --follow are set', async () => {
const child = createMockChild();
vi.mocked(spawn).mockReturnValue(child);
const handlerPromise = (
logsCommand.handler as (argv: Record<string, unknown>) => Promise<void>
)({ lines: 5, follow: true });
await flushMicrotasks();
expect(spawn).toHaveBeenCalledWith(
'tail',
['-f', '-n', '5', '/tmp/gemma.log'],
{ stdio: 'inherit' },
);
child.emit('close', 0);
await handlerPromise;
expect(exitCli).toHaveBeenCalledWith(0);
});
});
+200
View File
@@ -0,0 +1,200 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import fs from 'node:fs';
import { spawn, type ChildProcess } from 'node:child_process';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import { getLogFilePath } from './constants.js';
export async function readLastLines(
filePath: string,
count: number,
): Promise<string> {
if (count <= 0) {
return '';
}
const CHUNK_SIZE = 64 * 1024;
const fileHandle = await fs.promises.open(filePath, fs.constants.O_RDONLY);
try {
const stats = await fileHandle.stat();
if (stats.size === 0) {
return '';
}
const chunks: Buffer[] = [];
let totalBytes = 0;
let newlineCount = 0;
let position = stats.size;
while (position > 0 && newlineCount <= count) {
const readSize = Math.min(CHUNK_SIZE, position);
position -= readSize;
const buffer = Buffer.allocUnsafe(readSize);
const { bytesRead } = await fileHandle.read(
buffer,
0,
readSize,
position,
);
if (bytesRead === 0) {
break;
}
const chunk =
bytesRead === readSize ? buffer : buffer.subarray(0, bytesRead);
chunks.unshift(chunk);
totalBytes += chunk.length;
for (const byte of chunk) {
if (byte === 0x0a) {
newlineCount += 1;
}
}
}
const content = Buffer.concat(chunks, totalBytes).toString('utf-8');
const lines = content.split('\n');
if (position > 0 && lines.length > 0) {
const boundary = Buffer.allocUnsafe(1);
const { bytesRead } = await fileHandle.read(boundary, 0, 1, position - 1);
if (bytesRead === 1 && boundary[0] !== 0x0a) {
lines.shift();
}
}
if (lines.length > 0 && lines[lines.length - 1] === '') {
lines.pop();
}
if (lines.length === 0) {
return '';
}
return lines.slice(-count).join('\n') + '\n';
} finally {
await fileHandle.close();
}
}
interface LogsArgs {
lines?: number;
follow?: boolean;
}
function waitForChild(child: ChildProcess): Promise<number> {
return new Promise((resolve, reject) => {
child.once('error', reject);
child.once('close', (code) => resolve(code ?? 1));
});
}
async function runTail(logPath: string, lines: number, follow: boolean) {
const tailArgs = follow
? ['-f', '-n', String(lines), logPath]
: ['-n', String(lines), logPath];
const child = spawn('tail', tailArgs, { stdio: 'inherit' });
if (!follow) {
return waitForChild(child);
}
const handleSigint = () => {
child.kill('SIGTERM');
};
process.once('SIGINT', handleSigint);
try {
return await waitForChild(child);
} finally {
process.off('SIGINT', handleSigint);
}
}
export const logsCommand: CommandModule<object, LogsArgs> = {
command: 'logs',
describe: 'View LiteRT-LM server logs',
builder: (yargs) =>
yargs
.option('lines', {
alias: 'n',
type: 'number',
description: 'Show the last N lines and exit (omit to follow live)',
})
.option('follow', {
alias: 'f',
type: 'boolean',
description:
'Follow log output (defaults to true when --lines is omitted)',
}),
handler: async (argv) => {
const logPath = getLogFilePath();
try {
await fs.promises.access(logPath, fs.constants.F_OK);
} catch {
debugLogger.log(`No log file found at ${logPath}`);
debugLogger.log(
'Is the LiteRT server running? Start it with: gemini gemma start',
);
await exitCli(1);
return;
}
const lines = argv.lines;
const follow = argv.follow ?? lines === undefined;
const requestedLines = lines ?? 20;
if (follow && process.platform === 'win32') {
debugLogger.log(
'Live log following is not supported on Windows. Use --lines N to view recent logs.',
);
await exitCli(1);
return;
}
if (process.platform === 'win32') {
process.stdout.write(await readLastLines(logPath, requestedLines));
await exitCli(0);
return;
}
try {
if (follow) {
debugLogger.log(`Tailing ${logPath} (Ctrl+C to stop)\n`);
}
const exitCode = await runTail(logPath, requestedLines, follow);
await exitCli(exitCode);
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
if (!follow) {
process.stdout.write(await readLastLines(logPath, requestedLines));
await exitCli(0);
} else {
debugLogger.error(
'"tail" command not found. Use --lines N to view recent logs without tail.',
);
await exitCli(1);
}
} else {
debugLogger.error(
`Failed to read log output: ${error instanceof Error ? error.message : String(error)}`,
);
await exitCli(1);
}
}
},
};
@@ -0,0 +1,162 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SettingScope } from '../../config/settings.js';
import { getLiteRtBinDir } from './constants.js';
const mockLoadSettings = vi.hoisted(() => vi.fn());
vi.mock('../../config/settings.js', () => ({
loadSettings: mockLoadSettings,
SettingScope: {
User: 'User',
},
}));
import {
getBinaryPath,
isExpectedLiteRtServerCommand,
isBinaryInstalled,
readServerProcessInfo,
resolveGemmaConfig,
} from './platform.js';
describe('gemma platform helpers', () => {
function createMockSettings(
userGemmaSettings?: object,
mergedGemmaSettings?: object,
) {
return {
merged: {
experimental: {
gemmaModelRouter: mergedGemmaSettings,
},
},
forScope: vi.fn((scope: SettingScope) => {
if (scope !== SettingScope.User) {
throw new Error(`Unexpected scope ${scope}`);
}
return {
settings: {
experimental: {
gemmaModelRouter: userGemmaSettings,
},
},
};
}),
};
}
beforeEach(() => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue(createMockSettings());
});
it('prefers the configured binary path from settings', () => {
mockLoadSettings.mockReturnValue(
createMockSettings({ binaryPath: '/custom/lit' }),
);
expect(getBinaryPath('lit.test')).toBe('/custom/lit');
});
it('ignores workspace overrides for the configured binary path', () => {
mockLoadSettings.mockReturnValue(
createMockSettings(
{ binaryPath: '/user/lit' },
{ binaryPath: '/workspace/evil' },
),
);
expect(getBinaryPath('lit.test')).toBe('/user/lit');
});
it('falls back to the default install location when no custom path is set', () => {
expect(getBinaryPath('lit.test')).toBe(
path.join(getLiteRtBinDir(), 'lit.test'),
);
});
it('resolves the configured port and binary path from settings', () => {
mockLoadSettings.mockReturnValue(
createMockSettings(
{ binaryPath: '/custom/lit' },
{
enabled: true,
classifier: {
host: 'http://localhost:8123/v1beta',
},
},
),
);
expect(resolveGemmaConfig(9379)).toEqual({
settingsEnabled: true,
configuredPort: 8123,
configuredBinaryPath: '/custom/lit',
});
});
it('checks binary installation using the resolved binary path', () => {
mockLoadSettings.mockReturnValue(
createMockSettings({ binaryPath: '/custom/lit' }),
);
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
expect(isBinaryInstalled()).toBe(true);
expect(fs.existsSync).toHaveBeenCalledWith('/custom/lit');
});
it('parses structured server process info from the pid file', () => {
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify({
pid: 1234,
binaryPath: '/custom/lit',
port: 8123,
}),
);
expect(readServerProcessInfo()).toEqual({
pid: 1234,
binaryPath: '/custom/lit',
port: 8123,
});
});
it('parses legacy pid-only files for backward compatibility', () => {
vi.spyOn(fs, 'readFileSync').mockReturnValue('4321');
expect(readServerProcessInfo()).toEqual({
pid: 4321,
});
});
it('matches only the expected LiteRT serve command', () => {
expect(
isExpectedLiteRtServerCommand('/custom/lit serve --port=8123 --verbose', {
binaryPath: '/custom/lit',
port: 8123,
}),
).toBe(true);
expect(
isExpectedLiteRtServerCommand('/custom/lit run --port=8123', {
binaryPath: '/custom/lit',
port: 8123,
}),
).toBe(false);
expect(
isExpectedLiteRtServerCommand('/custom/lit serve --port=9000', {
binaryPath: '/custom/lit',
port: 8123,
}),
).toBe(false);
});
});
+316
View File
@@ -0,0 +1,316 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { loadSettings, SettingScope } from '../../config/settings.js';
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import {
PLATFORM_BINARY_MAP,
LITERT_RELEASE_BASE_URL,
LITERT_RELEASE_VERSION,
getLiteRtBinDir,
GEMMA_MODEL_NAME,
HEALTH_CHECK_TIMEOUT_MS,
LITERT_API_VERSION,
getPidFilePath,
} from './constants.js';
export interface PlatformInfo {
key: string;
binaryName: string;
}
export interface GemmaConfigStatus {
settingsEnabled: boolean;
configuredPort: number;
configuredBinaryPath?: string;
}
export interface LiteRtServerProcessInfo {
pid: number;
binaryPath?: string;
port?: number;
}
function getUserConfiguredBinaryPath(
workspaceDir = process.cwd(),
): string | undefined {
try {
const userGemmaSettings = loadSettings(workspaceDir).forScope(
SettingScope.User,
).settings.experimental?.gemmaModelRouter;
return userGemmaSettings?.binaryPath?.trim() || undefined;
} catch {
return undefined;
}
}
function parsePortFromHost(
host: string | undefined,
fallbackPort: number,
): number {
if (!host) {
return fallbackPort;
}
try {
const url = new URL(host);
const port = Number(url.port);
return Number.isFinite(port) && port > 0 ? port : fallbackPort;
} catch {
const match = host.match(/:(\d+)/);
if (!match) {
return fallbackPort;
}
const port = parseInt(match[1], 10);
return Number.isFinite(port) && port > 0 ? port : fallbackPort;
}
}
export function resolveGemmaConfig(fallbackPort: number): GemmaConfigStatus {
let settingsEnabled = false;
let configuredPort = fallbackPort;
const configuredBinaryPath = getUserConfiguredBinaryPath();
try {
const settings = loadSettings(process.cwd());
const gemmaSettings = settings.merged.experimental?.gemmaModelRouter;
settingsEnabled = gemmaSettings?.enabled === true;
configuredPort = parsePortFromHost(
gemmaSettings?.classifier?.host,
fallbackPort,
);
} catch {
// ignore — settings may fail to load outside a workspace
}
return { settingsEnabled, configuredPort, configuredBinaryPath };
}
export function detectPlatform(): PlatformInfo | null {
const key = `${process.platform}-${process.arch}`;
const binaryName = PLATFORM_BINARY_MAP[key];
if (!binaryName) {
return null;
}
return { key, binaryName };
}
export function getBinaryPath(binaryName?: string): string | null {
const configuredBinaryPath = getUserConfiguredBinaryPath();
if (configuredBinaryPath) {
return configuredBinaryPath;
}
const name = binaryName ?? detectPlatform()?.binaryName;
if (!name) return null;
return path.join(getLiteRtBinDir(), name);
}
export function getBinaryDownloadUrl(binaryName: string): string {
return `${LITERT_RELEASE_BASE_URL}/${LITERT_RELEASE_VERSION}/${binaryName}`;
}
export function isBinaryInstalled(binaryPath = getBinaryPath()): boolean {
if (!binaryPath) return false;
return fs.existsSync(binaryPath);
}
export function isModelDownloaded(binaryPath: string): boolean {
try {
const output = execFileSync(binaryPath, ['list'], {
encoding: 'utf-8',
timeout: 10000,
});
return output.includes(GEMMA_MODEL_NAME);
} catch {
return false;
}
}
export async function isServerRunning(port: number): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
HEALTH_CHECK_TIMEOUT_MS,
);
const response = await fetch(
`http://localhost:${port}/${LITERT_API_VERSION}/models/${GEMMA_MODEL_NAME}:generateContent`,
{ method: 'POST', signal: controller.signal },
);
clearTimeout(timeout);
// A 400 (bad request) confirms the route exists — the server recognises
// the model endpoint. Only a 404 means "wrong server / wrong model".
return response.status !== 404;
} catch {
return false;
}
}
function isLiteRtServerProcessInfo(
value: unknown,
): value is LiteRtServerProcessInfo {
if (!value || typeof value !== 'object') {
return false;
}
const isPositiveInteger = (candidate: unknown): candidate is number =>
typeof candidate === 'number' &&
Number.isInteger(candidate) &&
candidate > 0;
const isNonEmptyString = (candidate: unknown): candidate is string =>
typeof candidate === 'string' && candidate.length > 0;
const pid: unknown = Object.getOwnPropertyDescriptor(value, 'pid')?.value;
if (!isPositiveInteger(pid)) {
return false;
}
const binaryPath: unknown = Object.getOwnPropertyDescriptor(
value,
'binaryPath',
)?.value;
if (binaryPath !== undefined && !isNonEmptyString(binaryPath)) {
return false;
}
const port: unknown = Object.getOwnPropertyDescriptor(value, 'port')?.value;
if (port !== undefined && !isPositiveInteger(port)) {
return false;
}
return true;
}
export function readServerProcessInfo(): LiteRtServerProcessInfo | null {
const pidPath = getPidFilePath();
try {
const content = fs.readFileSync(pidPath, 'utf-8').trim();
if (!content) {
return null;
}
if (/^\d+$/.test(content)) {
return { pid: parseInt(content, 10) };
}
const parsed = JSON.parse(content) as unknown;
return isLiteRtServerProcessInfo(parsed) ? parsed : null;
} catch {
return null;
}
}
export function writeServerProcessInfo(
processInfo: LiteRtServerProcessInfo,
): void {
fs.writeFileSync(getPidFilePath(), JSON.stringify(processInfo), 'utf-8');
}
export function readServerPid(): number | null {
return readServerProcessInfo()?.pid ?? null;
}
function normalizeProcessValue(value: string): string {
const normalized = value.replace(/\0/g, ' ').trim();
if (process.platform === 'win32') {
return normalized.replace(/\\/g, '/').replace(/\s+/g, ' ').toLowerCase();
}
return normalized.replace(/\s+/g, ' ');
}
function readProcessCommandLine(pid: number): string | null {
try {
if (process.platform === 'linux') {
const output = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf-8');
return output.trim() ? output : null;
}
if (process.platform === 'win32') {
const output = execFileSync(
'powershell.exe',
[
'-NoProfile',
'-Command',
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
],
{
encoding: 'utf-8',
timeout: 5000,
},
);
return output.trim() || null;
}
const output = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
encoding: 'utf-8',
timeout: 5000,
});
return output.trim() || null;
} catch {
return null;
}
}
export function isExpectedLiteRtServerCommand(
commandLine: string,
options: {
binaryPath?: string | null;
port?: number;
},
): boolean {
const normalizedCommandLine = normalizeProcessValue(commandLine);
if (!normalizedCommandLine) {
return false;
}
if (!/(^|\s|")serve(\s|$)/.test(normalizedCommandLine)) {
return false;
}
if (
options.port !== undefined &&
!normalizedCommandLine.includes(`--port=${options.port}`)
) {
return false;
}
if (!options.binaryPath) {
return true;
}
const normalizedBinaryPath = normalizeProcessValue(options.binaryPath);
const normalizedBinaryName = normalizeProcessValue(
path.basename(options.binaryPath),
);
return (
normalizedCommandLine.includes(normalizedBinaryPath) ||
normalizedCommandLine.includes(normalizedBinaryName)
);
}
export function isExpectedLiteRtServerProcess(
pid: number,
options: {
binaryPath?: string | null;
port?: number;
},
): boolean {
const commandLine = readProcessCommandLine(pid);
if (!commandLine) {
return false;
}
return isExpectedLiteRtServerCommand(commandLine, options);
}
export function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { PLATFORM_BINARY_MAP, PLATFORM_BINARY_SHA256 } from './constants.js';
import { computeFileSha256, verifyFileSha256 } from './setup.js';
describe('gemma setup checksum helpers', () => {
const tempFiles: string[] = [];
afterEach(async () => {
await Promise.all(
tempFiles
.splice(0)
.map((filePath) => fs.promises.rm(filePath, { force: true })),
);
});
it('has a pinned checksum for every supported LiteRT binary', () => {
expect(Object.keys(PLATFORM_BINARY_SHA256).sort()).toEqual(
Object.values(PLATFORM_BINARY_MAP).sort(),
);
});
it('computes the sha256 for a downloaded file', async () => {
const filePath = path.join(
os.tmpdir(),
`gemma-setup-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
tempFiles.push(filePath);
await fs.promises.writeFile(filePath, 'hello world', 'utf-8');
await expect(computeFileSha256(filePath)).resolves.toBe(
'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
);
});
it('verifies whether a file matches the expected sha256', async () => {
const filePath = path.join(
os.tmpdir(),
`gemma-setup-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
tempFiles.push(filePath);
await fs.promises.writeFile(filePath, 'hello world', 'utf-8');
await expect(
verifyFileSha256(
filePath,
'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
),
).resolves.toBe(true);
await expect(verifyFileSha256(filePath, 'deadbeef')).resolves.toBe(false);
});
});
+504
View File
@@ -0,0 +1,504 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
import chalk from 'chalk';
import { debugLogger } from '@google/gemini-cli-core';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { exitCli } from '../utils.js';
import {
DEFAULT_PORT,
GEMMA_MODEL_NAME,
PLATFORM_BINARY_SHA256,
} from './constants.js';
import {
detectPlatform,
getBinaryDownloadUrl,
getBinaryPath,
isBinaryInstalled,
isModelDownloaded,
} from './platform.js';
import { startServer } from './start.js';
import readline from 'node:readline';
const log = (msg: string) => debugLogger.log(msg);
const logError = (msg: string) => debugLogger.error(msg);
async function promptYesNo(question: string): Promise<boolean> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(`${question} (y/N): `, (answer) => {
rl.close();
resolve(
answer.trim().toLowerCase() === 'y' ||
answer.trim().toLowerCase() === 'yes',
);
});
});
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function renderProgress(downloaded: number, total: number | null): void {
const barWidth = 30;
if (total && total > 0) {
const pct = Math.min(downloaded / total, 1);
const filled = Math.round(barWidth * pct);
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
const pctStr = (pct * 100).toFixed(0).padStart(3);
process.stderr.write(
`\r [${bar}] ${pctStr}% ${formatBytes(downloaded)} / ${formatBytes(total)}`,
);
} else {
process.stderr.write(`\r Downloaded ${formatBytes(downloaded)}`);
}
}
async function downloadFile(url: string, destPath: string): Promise<void> {
const tmpPath = destPath + '.downloading';
if (fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
const response = await fetch(url, { redirect: 'follow' });
if (!response.ok) {
throw new Error(
`Download failed: HTTP ${response.status} ${response.statusText}`,
);
}
if (!response.body) {
throw new Error('Download failed: No response body');
}
const contentLength = response.headers.get('content-length');
const totalBytes = contentLength ? parseInt(contentLength, 10) : null;
let downloadedBytes = 0;
const fileStream = fs.createWriteStream(tmpPath);
const reader = response.body.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const writeOk = fileStream.write(value);
if (!writeOk) {
await new Promise<void>((resolve) => fileStream.once('drain', resolve));
}
downloadedBytes += value.byteLength;
renderProgress(downloadedBytes, totalBytes);
}
} finally {
fileStream.end();
process.stderr.write('\r' + ' '.repeat(80) + '\r');
}
await new Promise<void>((resolve, reject) => {
fileStream.on('finish', resolve);
fileStream.on('error', reject);
});
fs.renameSync(tmpPath, destPath);
}
export async function computeFileSha256(filePath: string): Promise<string> {
const hash = createHash('sha256');
const fileStream = fs.createReadStream(filePath);
return new Promise((resolve, reject) => {
fileStream.on('data', (chunk) => {
hash.update(chunk);
});
fileStream.on('error', reject);
fileStream.on('end', () => {
resolve(hash.digest('hex'));
});
});
}
export async function verifyFileSha256(
filePath: string,
expectedHash: string,
): Promise<boolean> {
const actualHash = await computeFileSha256(filePath);
return actualHash === expectedHash;
}
function spawnInherited(command: string, args: string[]): Promise<number> {
return new Promise((resolve, reject) => {
const child = nodeSpawn(command, args, {
stdio: 'inherit',
});
child.on('close', (code) => resolve(code ?? 1));
child.on('error', reject);
});
}
interface SetupArgs {
port: number;
skipModel: boolean;
start: boolean;
force: boolean;
consent: boolean;
}
async function handleSetup(argv: SetupArgs): Promise<number> {
const { port, force } = argv;
let settingsUpdated = false;
let serverStarted = false;
let autoStartServer = true;
log('');
log(chalk.bold('Gemma Local Model Routing Setup'));
log(chalk.dim('─'.repeat(40)));
log('');
const platform = detectPlatform();
if (!platform) {
logError(
chalk.red(`Unsupported platform: ${process.platform}-${process.arch}`),
);
logError(
'LiteRT-LM binaries are available for: macOS (ARM64), Linux (x86_64), Windows (x86_64)',
);
return 1;
}
log(chalk.dim(` Platform: ${platform.key}${platform.binaryName}`));
if (!argv.consent) {
log('');
log('This will download and install the LiteRT-LM runtime and the');
log(
`Gemma model (${GEMMA_MODEL_NAME}, ~1 GB). By proceeding, you agree to the`,
);
log('Gemma Terms of Use: https://ai.google.dev/gemma/terms');
log('');
const accepted = await promptYesNo('Do you want to continue?');
if (!accepted) {
log('Setup cancelled.');
return 0;
}
}
const binaryPath = getBinaryPath(platform.binaryName)!;
const alreadyInstalled = isBinaryInstalled();
if (alreadyInstalled && !force) {
log('');
log(chalk.green(' ✓ LiteRT-LM binary already installed at:'));
log(chalk.dim(` ${binaryPath}`));
} else {
log('');
log(' Downloading LiteRT-LM binary...');
const downloadUrl = getBinaryDownloadUrl(platform.binaryName);
debugLogger.log(`Downloading from: ${downloadUrl}`);
try {
const binDir = path.dirname(binaryPath);
fs.mkdirSync(binDir, { recursive: true });
await downloadFile(downloadUrl, binaryPath);
log(chalk.green(' ✓ Binary downloaded successfully'));
} catch (error) {
logError(
chalk.red(
` ✗ Failed to download binary: ${error instanceof Error ? error.message : String(error)}`,
),
);
logError(' Check your internet connection and try again.');
return 1;
}
const expectedHash = PLATFORM_BINARY_SHA256[platform.binaryName];
if (!expectedHash) {
logError(
chalk.red(
` ✗ No checksum is configured for ${platform.binaryName}. Refusing to install the binary.`,
),
);
try {
fs.rmSync(binaryPath, { force: true });
} catch {
// ignore
}
return 1;
}
try {
const checksumVerified = await verifyFileSha256(binaryPath, expectedHash);
if (!checksumVerified) {
logError(
chalk.red(
' ✗ Downloaded binary checksum did not match the expected release hash.',
),
);
try {
fs.rmSync(binaryPath, { force: true });
} catch {
// ignore
}
return 1;
}
log(chalk.green(' ✓ Binary checksum verified'));
} catch (error) {
logError(
chalk.red(
` ✗ Failed to verify binary checksum: ${error instanceof Error ? error.message : String(error)}`,
),
);
try {
fs.rmSync(binaryPath, { force: true });
} catch {
// ignore
}
return 1;
}
if (process.platform !== 'win32') {
try {
fs.chmodSync(binaryPath, 0o755);
} catch (error) {
logError(
chalk.red(
` ✗ Failed to set executable permission: ${error instanceof Error ? error.message : String(error)}`,
),
);
return 1;
}
}
if (process.platform === 'darwin') {
try {
execFileSync('xattr', ['-d', 'com.apple.quarantine', binaryPath], {
stdio: 'ignore',
});
log(chalk.green(' ✓ macOS quarantine attribute removed'));
} catch {
// Expected if the attribute doesn't exist.
}
}
}
if (!argv.skipModel) {
const modelAlreadyDownloaded = isModelDownloaded(binaryPath);
if (modelAlreadyDownloaded && !force) {
log('');
log(chalk.green(` ✓ Model ${GEMMA_MODEL_NAME} already downloaded`));
} else {
log('');
log(` Downloading model ${GEMMA_MODEL_NAME}...`);
log(chalk.dim(' You may be prompted to accept the Gemma Terms of Use.'));
log('');
const exitCode = await spawnInherited(binaryPath, [
'pull',
GEMMA_MODEL_NAME,
]);
if (exitCode !== 0) {
logError('');
logError(
chalk.red(` ✗ Model download failed (exit code ${exitCode})`),
);
return 1;
}
log('');
log(chalk.green(` ✓ Model ${GEMMA_MODEL_NAME} downloaded`));
}
}
log('');
log(' Configuring settings...');
try {
const settings = loadSettings(process.cwd());
// User scope: security-sensitive settings that must not be overridable
// by workspace configs (prevents arbitrary binary execution).
const existingUserGemma =
settings.forScope(SettingScope.User).settings.experimental
?.gemmaModelRouter ?? {};
autoStartServer = existingUserGemma.autoStartServer ?? true;
const existingUserExperimental =
settings.forScope(SettingScope.User).settings.experimental ?? {};
settings.setValue(SettingScope.User, 'experimental', {
...existingUserExperimental,
gemmaModelRouter: {
autoStartServer,
...(existingUserGemma.binaryPath !== undefined
? { binaryPath: existingUserGemma.binaryPath }
: {}),
},
});
// Workspace scope: project-isolated settings so the local model only
// runs for this specific project, saving resources globally.
const existingWorkspaceGemma =
settings.forScope(SettingScope.Workspace).settings.experimental
?.gemmaModelRouter ?? {};
const existingWorkspaceExperimental =
settings.forScope(SettingScope.Workspace).settings.experimental ?? {};
settings.setValue(SettingScope.Workspace, 'experimental', {
...existingWorkspaceExperimental,
gemmaModelRouter: {
...existingWorkspaceGemma,
enabled: true,
classifier: {
...existingWorkspaceGemma.classifier,
host: `http://localhost:${port}`,
model: GEMMA_MODEL_NAME,
},
},
});
log(chalk.green(' ✓ Settings updated'));
log(chalk.dim(' User (~/.gemini/settings.json): autoStartServer'));
log(
chalk.dim(' Workspace (.gemini/settings.json): enabled, classifier'),
);
settingsUpdated = true;
} catch (error) {
logError(
chalk.red(
` ✗ Failed to update settings: ${error instanceof Error ? error.message : String(error)}`,
),
);
logError(
' You can manually add the configuration to ~/.gemini/settings.json',
);
}
if (argv.start) {
log('');
log(' Starting LiteRT server...');
serverStarted = await startServer(binaryPath, port);
if (serverStarted) {
log(chalk.green(` ✓ Server started on port ${port}`));
} else {
log(
chalk.yellow(
` ! Server may not have started correctly. Check: gemini gemma status`,
),
);
}
}
const routingActive = settingsUpdated && serverStarted;
const setupSucceeded = settingsUpdated && (!argv.start || serverStarted);
log('');
log(chalk.dim('─'.repeat(40)));
if (routingActive) {
log(chalk.bold.green(' Setup complete! Local model routing is active.'));
} else if (settingsUpdated) {
log(
chalk.bold.green(' Setup complete! Local model routing is configured.'),
);
} else {
log(
chalk.bold.yellow(
' Setup incomplete. Manual settings changes are still required.',
),
);
}
log('');
log(' How it works: Every request is classified by the local Gemma model.');
log(
' Simple tasks (file reads, quick edits) route to ' +
chalk.cyan('Flash') +
' for speed.',
);
log(
' Complex tasks (debugging, architecture) route to ' +
chalk.cyan('Pro') +
' for quality.',
);
log(' This happens automatically — just use the CLI as usual.');
log('');
if (!settingsUpdated) {
log(
chalk.yellow(
' Fix the settings update above, then rerun "gemini gemma status".',
),
);
log('');
} else if (!argv.start) {
log(chalk.yellow(' Note: Run "gemini gemma start" to start the server.'));
if (autoStartServer) {
log(
chalk.yellow(
' Or restart the CLI to auto-start it on the next launch.',
),
);
}
log('');
} else if (!serverStarted) {
log(
chalk.yellow(
' Review the server logs and rerun "gemini gemma start" after fixing the issue.',
),
);
log('');
}
log(' Useful commands:');
log(chalk.dim(' gemini gemma status Check routing status'));
log(chalk.dim(' gemini gemma start Start the LiteRT server'));
log(chalk.dim(' gemini gemma stop Stop the LiteRT server'));
log(chalk.dim(' /gemma Check status inside a session'));
log('');
return setupSucceeded ? 0 : 1;
}
export const setupCommand: CommandModule = {
command: 'setup',
describe: 'Download and configure Gemma local model routing',
builder: (yargs) =>
yargs
.option('port', {
type: 'number',
default: DEFAULT_PORT,
description: 'Port for the LiteRT server',
})
.option('skip-model', {
type: 'boolean',
default: false,
description: 'Skip model download (binary only)',
})
.option('start', {
type: 'boolean',
default: true,
description: 'Start the server after setup',
})
.option('force', {
type: 'boolean',
default: false,
description: 'Re-download binary and model even if already present',
})
.option('consent', {
type: 'boolean',
default: false,
description: 'Skip interactive consent prompt (implies acceptance)',
}),
handler: async (argv) => {
const exitCode = await handleSetup({
port: Number(argv['port']),
skipModel: Boolean(argv['skipModel']),
start: Boolean(argv['start']),
force: Boolean(argv['force']),
consent: Boolean(argv['consent']),
});
await exitCli(exitCode);
},
};
+123
View File
@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import chalk from 'chalk';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import {
DEFAULT_PORT,
getPidFilePath,
getLogFilePath,
getLiteRtBinDir,
SERVER_START_WAIT_MS,
} from './constants.js';
import {
getBinaryPath,
isBinaryInstalled,
isServerRunning,
resolveGemmaConfig,
writeServerProcessInfo,
} from './platform.js';
export async function startServer(
binaryPath: string,
port: number,
): Promise<boolean> {
const alreadyRunning = await isServerRunning(port);
if (alreadyRunning) {
debugLogger.log(`LiteRT server already running on port ${port}`);
return true;
}
const logPath = getLogFilePath();
fs.mkdirSync(getLiteRtBinDir(), { recursive: true });
const tmpDir = path.dirname(getPidFilePath());
fs.mkdirSync(tmpDir, { recursive: true });
const logFd = fs.openSync(logPath, 'a');
try {
const child = spawn(binaryPath, ['serve', `--port=${port}`, '--verbose'], {
detached: true,
stdio: ['ignore', logFd, logFd],
});
if (child.pid) {
writeServerProcessInfo({
pid: child.pid,
binaryPath,
port,
});
}
child.unref();
} finally {
fs.closeSync(logFd);
}
await new Promise((resolve) => setTimeout(resolve, SERVER_START_WAIT_MS));
return isServerRunning(port);
}
export const startCommand: CommandModule = {
command: 'start',
describe: 'Start the LiteRT-LM server',
builder: (yargs) =>
yargs.option('port', {
type: 'number',
description: 'Port for the LiteRT server',
}),
handler: async (argv) => {
let port: number | undefined;
if (argv['port'] !== undefined) {
port = Number(argv['port']);
}
if (!port) {
const { configuredPort } = resolveGemmaConfig(DEFAULT_PORT);
port = configuredPort;
}
const binaryPath = getBinaryPath();
if (!binaryPath || !isBinaryInstalled(binaryPath)) {
debugLogger.error(
chalk.red(
'LiteRT-LM binary not found. Run "gemini gemma setup" first.',
),
);
await exitCli(1);
return;
}
const alreadyRunning = await isServerRunning(port);
if (alreadyRunning) {
debugLogger.log(
chalk.green(`LiteRT server is already running on port ${port}.`),
);
await exitCli(0);
return;
}
debugLogger.log(`Starting LiteRT server on port ${port}...`);
const started = await startServer(binaryPath, port);
if (started) {
debugLogger.log(chalk.green(`LiteRT server started on port ${port}.`));
debugLogger.log(chalk.dim(`Logs: ${getLogFilePath()}`));
await exitCli(0);
} else {
debugLogger.error(
chalk.red('Server may not have started correctly. Check logs:'),
);
debugLogger.error(chalk.dim(` ${getLogFilePath()}`));
await exitCli(1);
}
},
};
+165
View File
@@ -0,0 +1,165 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import chalk from 'chalk';
import { DEFAULT_PORT, GEMMA_MODEL_NAME } from './constants.js';
import {
detectPlatform,
getBinaryPath,
isBinaryInstalled,
isModelDownloaded,
isServerRunning,
readServerPid,
isProcessRunning,
resolveGemmaConfig,
} from './platform.js';
import { exitCli } from '../utils.js';
export interface GemmaStatusResult {
binaryInstalled: boolean;
binaryPath: string | null;
modelDownloaded: boolean;
serverRunning: boolean;
serverPid: number | null;
settingsEnabled: boolean;
port: number;
allPassing: boolean;
}
export async function checkGemmaStatus(
port?: number,
): Promise<GemmaStatusResult> {
const { settingsEnabled, configuredPort } = resolveGemmaConfig(DEFAULT_PORT);
const effectivePort = port ?? configuredPort;
const binaryPath = getBinaryPath();
const binaryInstalled = isBinaryInstalled(binaryPath);
const modelDownloaded =
binaryInstalled && binaryPath ? isModelDownloaded(binaryPath) : false;
const serverRunning = await isServerRunning(effectivePort);
const pid = readServerPid();
const serverPid = pid && isProcessRunning(pid) ? pid : null;
const allPassing =
binaryInstalled && modelDownloaded && serverRunning && settingsEnabled;
return {
binaryInstalled,
binaryPath,
modelDownloaded,
serverRunning,
serverPid,
settingsEnabled,
port: effectivePort,
allPassing,
};
}
export function formatGemmaStatus(status: GemmaStatusResult): string {
const check = (ok: boolean) => (ok ? chalk.green('✓') : chalk.red('✗'));
const lines: string[] = [
'',
chalk.bold('Gemma Local Model Routing Status'),
chalk.dim('─'.repeat(40)),
'',
];
if (status.binaryInstalled) {
lines.push(` Binary: ${check(true)} Installed (${status.binaryPath})`);
} else {
const platform = detectPlatform();
if (platform) {
lines.push(` Binary: ${check(false)} Not installed`);
lines.push(chalk.dim(` Run: gemini gemma setup`));
} else {
lines.push(
` Binary: ${check(false)} Unsupported platform (${process.platform}-${process.arch})`,
);
}
}
if (status.modelDownloaded) {
lines.push(` Model: ${check(true)} ${GEMMA_MODEL_NAME} downloaded`);
} else {
lines.push(` Model: ${check(false)} ${GEMMA_MODEL_NAME} not found`);
if (status.binaryInstalled) {
lines.push(
chalk.dim(
` Run: ${status.binaryPath} pull ${GEMMA_MODEL_NAME}`,
),
);
} else {
lines.push(chalk.dim(` Run: gemini gemma setup`));
}
}
if (status.serverRunning) {
const pidInfo = status.serverPid ? ` (PID ${status.serverPid})` : '';
lines.push(
` Server: ${check(true)} Running on port ${status.port}${pidInfo}`,
);
} else {
lines.push(
` Server: ${check(false)} Not running on port ${status.port}`,
);
lines.push(chalk.dim(` Run: gemini gemma start`));
}
if (status.settingsEnabled) {
lines.push(` Settings: ${check(true)} Enabled in settings.json`);
} else {
lines.push(` Settings: ${check(false)} Not enabled in settings.json`);
lines.push(
chalk.dim(
` Run: gemini gemma setup (auto-configures settings)`,
),
);
}
lines.push('');
if (status.allPassing) {
lines.push(chalk.green(' Routing is active — no action needed.'));
lines.push('');
lines.push(
chalk.dim(
' Simple requests → Flash (fast) | Complex requests → Pro (powerful)',
),
);
lines.push(chalk.dim(' This happens automatically on every request.'));
} else {
lines.push(
chalk.yellow(
' Some checks failed. Run "gemini gemma setup" for guided installation.',
),
);
}
lines.push('');
return lines.join('\n');
}
export const statusCommand: CommandModule = {
command: 'status',
describe: 'Check Gemma local model routing status',
builder: (yargs) =>
yargs.option('port', {
type: 'number',
description: 'Port to check for the LiteRT server',
}),
handler: async (argv) => {
let port: number | undefined;
if (argv['port'] !== undefined) {
port = Number(argv['port']);
}
const status = await checkGemmaStatus(port);
const output = formatGemmaStatus(status);
process.stdout.write(output);
await exitCli(status.allPassing ? 0 : 1);
},
};
@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockGetBinaryPath = vi.hoisted(() => vi.fn());
const mockIsExpectedLiteRtServerProcess = vi.hoisted(() => vi.fn());
const mockIsProcessRunning = vi.hoisted(() => vi.fn());
const mockIsServerRunning = vi.hoisted(() => vi.fn());
const mockReadServerPid = vi.hoisted(() => vi.fn());
const mockReadServerProcessInfo = vi.hoisted(() => vi.fn());
const mockResolveGemmaConfig = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const { mockCoreDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return mockCoreDebugLogger(
await importOriginal<typeof import('@google/gemini-cli-core')>(),
{
stripAnsi: false,
},
);
});
vi.mock('./constants.js', () => ({
DEFAULT_PORT: 9379,
getPidFilePath: vi.fn(() => '/tmp/litert-server.pid'),
}));
vi.mock('./platform.js', () => ({
getBinaryPath: mockGetBinaryPath,
isExpectedLiteRtServerProcess: mockIsExpectedLiteRtServerProcess,
isProcessRunning: mockIsProcessRunning,
isServerRunning: mockIsServerRunning,
readServerPid: mockReadServerPid,
readServerProcessInfo: mockReadServerProcessInfo,
resolveGemmaConfig: mockResolveGemmaConfig,
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
import { stopServer } from './stop.js';
describe('gemma stop command', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
mockGetBinaryPath.mockReturnValue('/custom/lit');
mockResolveGemmaConfig.mockReturnValue({ configuredPort: 9379 });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('refuses to signal a pid that does not match the expected LiteRT server', async () => {
mockReadServerProcessInfo.mockReturnValue({
pid: 1234,
binaryPath: '/custom/lit',
port: 8123,
});
mockIsProcessRunning.mockReturnValue(true);
mockIsExpectedLiteRtServerProcess.mockReturnValue(false);
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
await expect(stopServer(8123)).resolves.toBe('unexpected-process');
expect(killSpy).not.toHaveBeenCalled();
});
it('stops the verified LiteRT server and removes the pid file', async () => {
mockReadServerProcessInfo.mockReturnValue({
pid: 1234,
binaryPath: '/custom/lit',
port: 8123,
});
mockIsProcessRunning.mockReturnValueOnce(true).mockReturnValueOnce(false);
mockIsExpectedLiteRtServerProcess.mockReturnValue(true);
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const stopPromise = stopServer(8123);
await vi.runAllTimersAsync();
await expect(stopPromise).resolves.toBe('stopped');
expect(killSpy).toHaveBeenCalledWith(1234, 'SIGTERM');
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/litert-server.pid');
});
it('cleans up a stale pid file when the recorded process is no longer running', async () => {
mockReadServerProcessInfo.mockReturnValue({
pid: 1234,
binaryPath: '/custom/lit',
port: 8123,
});
mockIsProcessRunning.mockReturnValue(false);
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
await expect(stopServer(8123)).resolves.toBe('not-running');
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/litert-server.pid');
});
});
+155
View File
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import fs from 'node:fs';
import chalk from 'chalk';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import { DEFAULT_PORT, getPidFilePath } from './constants.js';
import {
getBinaryPath,
isExpectedLiteRtServerProcess,
isProcessRunning,
isServerRunning,
readServerPid,
readServerProcessInfo,
resolveGemmaConfig,
} from './platform.js';
export type StopServerResult =
| 'stopped'
| 'not-running'
| 'unexpected-process'
| 'failed';
export async function stopServer(
expectedPort?: number,
): Promise<StopServerResult> {
const processInfo = readServerProcessInfo();
const pidPath = getPidFilePath();
if (!processInfo) {
return 'not-running';
}
const { pid } = processInfo;
if (!isProcessRunning(pid)) {
debugLogger.log(
`Stale PID file found (PID ${pid} is not running), removing ${pidPath}`,
);
try {
fs.unlinkSync(pidPath);
} catch {
// ignore
}
return 'not-running';
}
const binaryPath = processInfo.binaryPath ?? getBinaryPath();
const port = processInfo.port ?? expectedPort;
if (!isExpectedLiteRtServerProcess(pid, { binaryPath, port })) {
debugLogger.warn(
`Refusing to stop PID ${pid} because it does not match the expected LiteRT server process.`,
);
return 'unexpected-process';
}
try {
process.kill(pid, 'SIGTERM');
} catch {
return 'failed';
}
await new Promise((resolve) => setTimeout(resolve, 1000));
if (isProcessRunning(pid)) {
try {
process.kill(pid, 'SIGKILL');
} catch {
// ignore
}
await new Promise((resolve) => setTimeout(resolve, 500));
if (isProcessRunning(pid)) {
return 'failed';
}
}
try {
fs.unlinkSync(pidPath);
} catch {
// ignore
}
return 'stopped';
}
export const stopCommand: CommandModule = {
command: 'stop',
describe: 'Stop the LiteRT-LM server',
builder: (yargs) =>
yargs.option('port', {
type: 'number',
description: 'Port where the LiteRT server is running',
}),
handler: async (argv) => {
let port: number | undefined;
if (argv['port'] !== undefined) {
port = Number(argv['port']);
}
if (!port) {
const { configuredPort } = resolveGemmaConfig(DEFAULT_PORT);
port = configuredPort;
}
const processInfo = readServerProcessInfo();
const pid = processInfo?.pid ?? readServerPid();
if (pid !== null && isProcessRunning(pid)) {
debugLogger.log(`Stopping LiteRT server (PID ${pid})...`);
const result = await stopServer(port);
if (result === 'stopped') {
debugLogger.log(chalk.green('LiteRT server stopped.'));
await exitCli(0);
} else if (result === 'unexpected-process') {
debugLogger.error(
chalk.red(
`Refusing to stop PID ${pid} because it does not match the expected LiteRT server process.`,
),
);
debugLogger.error(
chalk.dim(
'Remove the stale pid file after verifying the process, or stop the process manually.',
),
);
await exitCli(1);
} else {
debugLogger.error(chalk.red('Failed to stop LiteRT server.'));
await exitCli(1);
}
return;
}
const running = await isServerRunning(port);
if (running) {
debugLogger.log(
chalk.yellow(
`A server is responding on port ${port}, but it was not started by "gemini gemma start".`,
),
);
debugLogger.log(
chalk.dim(
'If you started it manually, stop it from the terminal where it is running.',
),
);
await exitCli(1);
} else {
debugLogger.log('No LiteRT server is currently running.');
await exitCli(0);
}
},
};
+13
View File
@@ -338,6 +338,7 @@ describe('parseArguments', () => {
{ cmd: 'skill list', expected: true },
{ cmd: 'hooks migrate', expected: true },
{ cmd: 'hook migrate', expected: true },
{ cmd: 'gemma status', expected: true },
{ cmd: 'some query', expected: undefined },
{ cmd: 'hello world', expected: undefined },
])(
@@ -758,6 +759,12 @@ describe('parseArguments', () => {
const argv = await parseArguments(settings);
expect(argv.isCommand).toBe(true);
});
it('should set isCommand to true for gemma command', async () => {
process.argv = ['node', 'script.js', 'gemma', 'status'];
const argv = await parseArguments(createTestMergedSettings());
expect(argv.isCommand).toBe(true);
});
});
describe('loadCliConfig', () => {
@@ -3030,6 +3037,8 @@ describe('loadCliConfig gemmaModelRouter', () => {
experimental: {
gemmaModelRouter: {
enabled: true,
autoStartServer: false,
binaryPath: '/custom/lit',
classifier: {
host: 'http://custom:1234',
model: 'custom-gemma',
@@ -3040,6 +3049,8 @@ describe('loadCliConfig gemmaModelRouter', () => {
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getGemmaModelRouterEnabled()).toBe(true);
const gemmaSettings = config.getGemmaModelRouterSettings();
expect(gemmaSettings.autoStartServer).toBe(false);
expect(gemmaSettings.binaryPath).toBe('/custom/lit');
expect(gemmaSettings.classifier?.host).toBe('http://custom:1234');
expect(gemmaSettings.classifier?.model).toBe('custom-gemma');
});
@@ -3057,6 +3068,8 @@ describe('loadCliConfig gemmaModelRouter', () => {
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getGemmaModelRouterEnabled()).toBe(true);
const gemmaSettings = config.getGemmaModelRouterSettings();
expect(gemmaSettings.autoStartServer).toBe(false);
expect(gemmaSettings.binaryPath).toBe('');
expect(gemmaSettings.classifier?.host).toBe('http://localhost:9379');
expect(gemmaSettings.classifier?.model).toBe('gemma3-1b-gpu-custom');
});
+4
View File
@@ -13,6 +13,7 @@ import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
import { skillsCommand } from '../commands/skills.js';
import { hooksCommand } from '../commands/hooks.js';
import { gemmaCommand } from '../commands/gemma.js';
import {
setGeminiMdFilename as setServerGeminiMdFilename,
getCurrentGeminiMdFilename,
@@ -181,6 +182,7 @@ export async function parseArguments(
extensionsCommand,
skillsCommand,
hooksCommand,
gemmaCommand,
];
const subcommands = commandModules.flatMap((mod) => {
@@ -260,6 +262,7 @@ export async function parseArguments(
yargsInstance.command(extensionsCommand);
yargsInstance.command(skillsCommand);
yargsInstance.command(hooksCommand);
yargsInstance.command(gemmaCommand);
yargsInstance
.command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) =>
@@ -1029,6 +1032,7 @@ export async function loadCliConfig(
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors,
billing: settings.billing,
vertexAiRouting: settings.billing?.vertexAi,
maxAttempts: settings.general?.maxAttempts,
ptyInfo: ptyInfo?.name,
disableLLMCorrection: settings.tools?.disableLLMCorrection,
+6 -1
View File
@@ -78,7 +78,12 @@ export function getMergeStrategyForPath(
export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
export const DEFAULT_EXCLUDED_ENV_VARS = [
'DEBUG',
'DEBUG_MODE',
'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND',
'GEMINI_CLI_IDE_SERVER_STDIO_ARGS',
];
const AUTH_ENV_VAR_WHITELIST = [
'GEMINI_API_KEY',
+43 -1
View File
@@ -138,6 +138,10 @@ describe('SettingsSchema', () => {
getSettingsSchema().context.properties.fileFiltering.properties
?.enableRecursiveFileSearch,
).toBeDefined();
expect(
getSettingsSchema().context.properties.fileFiltering.properties
?.enableFileWatcher,
).toBeDefined();
expect(
getSettingsSchema().context.properties.fileFiltering.properties
?.customIgnoreFilePaths,
@@ -313,6 +317,22 @@ describe('SettingsSchema', () => {
).toBe(false);
});
it('should have Vertex AI routing settings in schema', () => {
const vertexAi =
getSettingsSchema().billing.properties.vertexAi.properties;
expect(vertexAi.requestType).toBeDefined();
expect(vertexAi.requestType.type).toBe('enum');
expect(
vertexAi.requestType.options?.map((option) => option.value),
).toEqual(['dedicated', 'shared']);
expect(vertexAi.sharedRequestType).toBeDefined();
expect(vertexAi.sharedRequestType.type).toBe('enum');
expect(
vertexAi.sharedRequestType.options?.map((option) => option.value),
).toEqual(['priority', 'flex']);
});
it('should have folderTrustFeature setting in schema', () => {
expect(
getSettingsSchema().security.properties.folderTrust.properties.enabled,
@@ -471,11 +491,33 @@ describe('SettingsSchema', () => {
expect(enabled.category).toBe('Experimental');
expect(enabled.default).toBe(false);
expect(enabled.requiresRestart).toBe(true);
expect(enabled.showInDialog).toBe(false);
expect(enabled.showInDialog).toBe(true);
expect(enabled.description).toBe(
'Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.',
);
const autoStartServer = gemmaModelRouter.properties.autoStartServer;
expect(autoStartServer).toBeDefined();
expect(autoStartServer.type).toBe('boolean');
expect(autoStartServer.category).toBe('Experimental');
expect(autoStartServer.default).toBe(false);
expect(autoStartServer.requiresRestart).toBe(true);
expect(autoStartServer.showInDialog).toBe(true);
expect(autoStartServer.description).toBe(
'Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled.',
);
const binaryPath = gemmaModelRouter.properties.binaryPath;
expect(binaryPath).toBeDefined();
expect(binaryPath.type).toBe('string');
expect(binaryPath.category).toBe('Experimental');
expect(binaryPath.default).toBe('');
expect(binaryPath.requiresRestart).toBe(true);
expect(binaryPath.showInDialog).toBe(false);
expect(binaryPath.description).toBe(
'Custom path to the LiteRT-LM binary. Leave empty to use the default location (~/.gemini/bin/litert/).',
);
const classifier = gemmaModelRouter.properties.classifier;
expect(classifier).toBeDefined();
expect(classifier.type).toBe('object');
+76
View File
@@ -21,6 +21,7 @@ import {
type AgentOverride,
type CustomTheme,
type SandboxConfig,
type VertexAiRoutingConfig,
} from '@google/gemini-cli-core';
import type { SessionRetentionSettings } from './settings.js';
import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js';
@@ -990,6 +991,45 @@ const SETTINGS_SCHEMA = {
{ value: 'never', label: 'Never use credits' },
],
},
vertexAi: {
type: 'object',
label: 'Vertex AI',
category: 'Advanced',
requiresRestart: true,
default: undefined as VertexAiRoutingConfig | undefined,
description: 'Vertex AI request routing settings.',
showInDialog: false,
properties: {
requestType: {
type: 'enum',
label: 'Vertex AI Request Type',
category: 'Advanced',
requiresRestart: true,
default: undefined as VertexAiRoutingConfig['requestType'],
description:
'Sets the X-Vertex-AI-LLM-Request-Type header for Vertex AI requests.',
showInDialog: false,
options: [
{ value: 'dedicated', label: 'Dedicated' },
{ value: 'shared', label: 'Shared' },
],
},
sharedRequestType: {
type: 'enum',
label: 'Vertex AI Shared Request Type',
category: 'Advanced',
requiresRestart: true,
default: undefined as VertexAiRoutingConfig['sharedRequestType'],
description:
'Sets the X-Vertex-AI-LLM-Shared-Request-Type header for Vertex AI requests.',
showInDialog: false,
options: [
{ value: 'priority', label: 'Priority' },
{ value: 'flex', label: 'Flex' },
],
},
},
},
},
},
@@ -1431,6 +1471,17 @@ const SETTINGS_SCHEMA = {
description: 'Respect .geminiignore files when searching.',
showInDialog: true,
},
enableFileWatcher: {
type: 'boolean',
label: 'Enable File Watcher',
category: 'Context',
requiresRestart: true,
default: false,
description: oneLine`
Enable file watcher updates for @ file suggestions (experimental).
`,
showInDialog: false,
},
enableRecursiveFileSearch: {
type: 'boolean',
label: 'Enable Recursive File Search',
@@ -2169,6 +2220,26 @@ const SETTINGS_SCHEMA = {
default: false,
description:
'Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.',
showInDialog: true,
},
autoStartServer: {
type: 'boolean',
label: 'Auto-start LiteRT Server',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled.',
showInDialog: true,
},
binaryPath: {
type: 'string',
label: 'LiteRT Binary Path',
category: 'Experimental',
requiresRestart: true,
default: '',
description:
'Custom path to the LiteRT-LM binary. Leave empty to use the default location (~/.gemini/bin/litert/).',
showInDialog: false,
},
classifier: {
@@ -3000,6 +3071,11 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
description: 'Protocol for OTLP exporters.',
enum: ['grpc', 'http'],
},
traces: {
type: 'boolean',
description:
'Whether detailed traces with large attributes are captured.',
},
logPrompts: {
type: 'boolean',
description: 'Whether prompts are logged in telemetry payloads.',
+17
View File
@@ -612,6 +612,23 @@ export async function main() {
const initializationResult = await initializeApp(config, settings);
initAppHandle?.end();
import('./services/liteRtServerManager.js')
.then(({ LiteRtServerManager }) => {
const mergedGemma = settings.merged.experimental?.gemmaModelRouter;
if (!mergedGemma) return;
// Security: binaryPath and autoStartServer must come from user-scoped
// settings only to prevent workspace configs from triggering arbitrary
// binary execution.
const userGemma = settings.forScope(SettingScope.User).settings
.experimental?.gemmaModelRouter;
return LiteRtServerManager.ensureRunning({
...mergedGemma,
binaryPath: userGemma?.binaryPath,
autoStartServer: userGemma?.autoStartServer,
});
})
.catch((e) => debugLogger.warn('LiteRT auto-start import failed:', e));
if (
settings.merged.security.auth.selectedType ===
AuthType.LOGIN_WITH_GOOGLE &&
@@ -42,6 +42,7 @@ import { initCommand } from '../ui/commands/initCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { noteCommand } from '../ui/commands/noteCommand.js';
import { oncallCommand } from '../ui/commands/oncallCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { planCommand } from '../ui/commands/planCommand.js';
@@ -61,6 +62,7 @@ import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
import { upgradeCommand } from '../ui/commands/upgradeCommand.js';
import { gemmaStatusCommand } from '../ui/commands/gemmaStatusCommand.js';
/**
* Loads the core, hard-coded slash commands that are an integral part
@@ -183,6 +185,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [mcpCommand]),
memoryCommand,
modelCommand,
noteCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
...(this.config?.isPlanEnabled() ? [planCommand] : []),
policiesCommand,
@@ -221,6 +224,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [skillsCommand]
: []),
settingsCommand,
gemmaStatusCommand,
tasksCommand,
vimCommand,
setupGithubCommand,
@@ -0,0 +1,68 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { GemmaModelRouterSettings } from '@google/gemini-cli-core';
const mockGetBinaryPath = vi.hoisted(() => vi.fn());
const mockIsServerRunning = vi.hoisted(() => vi.fn());
const mockStartServer = vi.hoisted(() => vi.fn());
vi.mock('../commands/gemma/platform.js', () => ({
getBinaryPath: mockGetBinaryPath,
isServerRunning: mockIsServerRunning,
}));
vi.mock('../commands/gemma/start.js', () => ({
startServer: mockStartServer,
}));
import { LiteRtServerManager } from './liteRtServerManager.js';
describe('LiteRtServerManager', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
mockIsServerRunning.mockResolvedValue(false);
mockStartServer.mockResolvedValue(true);
});
it('uses the configured custom binary path when auto-starting', async () => {
mockGetBinaryPath.mockReturnValue('/user/lit');
const settings: GemmaModelRouterSettings = {
enabled: true,
binaryPath: '/workspace/evil',
classifier: {
host: 'http://localhost:8123',
},
};
await LiteRtServerManager.ensureRunning(settings);
expect(mockGetBinaryPath).toHaveBeenCalledTimes(1);
expect(fs.existsSync).toHaveBeenCalledWith('/user/lit');
expect(mockStartServer).toHaveBeenCalledWith('/user/lit', 8123);
});
it('falls back to the default binary path when no custom path is configured', async () => {
mockGetBinaryPath.mockReturnValue('/default/lit');
const settings: GemmaModelRouterSettings = {
enabled: true,
classifier: {
host: 'http://localhost:9379',
},
};
await LiteRtServerManager.ensureRunning(settings);
expect(mockGetBinaryPath).toHaveBeenCalledTimes(1);
expect(fs.existsSync).toHaveBeenCalledWith('/default/lit');
expect(mockStartServer).toHaveBeenCalledWith('/default/lit', 9379);
});
});
@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { debugLogger } from '@google/gemini-cli-core';
import type { GemmaModelRouterSettings } from '@google/gemini-cli-core';
import { getBinaryPath, isServerRunning } from '../commands/gemma/platform.js';
import { DEFAULT_PORT } from '../commands/gemma/constants.js';
export class LiteRtServerManager {
static async ensureRunning(
gemmaSettings: GemmaModelRouterSettings | undefined,
): Promise<void> {
if (!gemmaSettings?.enabled) return;
if (gemmaSettings.autoStartServer === false) return;
const binaryPath = getBinaryPath();
if (!binaryPath || !fs.existsSync(binaryPath)) {
debugLogger.log(
'[LiteRtServerManager] Binary not installed, skipping auto-start. Run "gemini gemma setup".',
);
return;
}
const port =
parseInt(
gemmaSettings.classifier?.host?.match(/:(\d+)/)?.[1] ?? '',
10,
) || DEFAULT_PORT;
const running = await isServerRunning(port);
if (running) {
debugLogger.log(
`[LiteRtServerManager] Server already running on port ${port}`,
);
return;
}
debugLogger.log(
`[LiteRtServerManager] Auto-starting LiteRT server on port ${port}...`,
);
try {
const { startServer } = await import('../commands/gemma/start.js');
const started = await startServer(binaryPath, port);
if (started) {
debugLogger.log(`[LiteRtServerManager] Server started on port ${port}`);
} else {
debugLogger.warn(
`[LiteRtServerManager] Server may not have started correctly on port ${port}`,
);
}
} catch (error) {
debugLogger.warn('[LiteRtServerManager] Auto-start failed:', error);
}
}
}
@@ -89,6 +89,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getAccessibility: vi.fn().mockReturnValue({}),
getTelemetryEnabled: vi.fn().mockReturnValue(false),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
getTelemetryOtlpEndpoint: vi.fn().mockReturnValue(''),
getTelemetryOtlpProtocol: vi.fn().mockReturnValue('grpc'),
getTelemetryTarget: vi.fn().mockReturnValue(''),
+2 -7
View File
@@ -92,7 +92,6 @@ import {
ApiKeyUpdatedEvent,
LegacyAgentProtocol,
type InjectionSource,
startMemoryService,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
@@ -125,6 +124,7 @@ import { type BackgroundTask } from './hooks/useExecutionLifecycle.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
import { useFocus } from './hooks/useFocus.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { KeypressPriority } from './contexts/KeypressContext.js';
@@ -486,12 +486,7 @@ export const AppContainer = (props: AppContainerProps) => {
setConfigInitialized(true);
startupProfiler.flush(config);
// Fire-and-forget Auto Memory service (skill extraction from past sessions)
if (config.isAutoMemoryEnabled()) {
startMemoryService(config).catch((e) => {
debugLogger.error('Failed to start memory service:', e);
});
}
startAutoMemoryIfEnabled(config);
const sessionStartSource = resumedSessionData
? SessionStartSource.Resume
+1 -1
View File
@@ -52,7 +52,7 @@ export function ApiAuthDialog({
height: 4,
},
inputFilter: (text) =>
text.replace(/[^a-zA-Z0-9_-]/g, '').replace(/[\r\n]/g, ''),
text.replace(/[^a-zA-Z0-9_.-]/g, '').replace(/[\r\n]/g, ''),
singleLine: true,
});
+3 -2
View File
@@ -16,8 +16,9 @@ import { MessageType } from '../types.js';
import { randomUUID } from 'node:crypto';
export const clearCommand: SlashCommand = {
name: 'clear',
description: 'Clear the screen and conversation history',
name: 'clear (new)',
altNames: ['new'],
description: 'Clear the screen and start a new session',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, _args) => {
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType, type HistoryItemGemmaStatus } from '../types.js';
import { checkGemmaStatus } from '../../commands/gemma/status.js';
import { GEMMA_MODEL_NAME } from '../../commands/gemma/constants.js';
export const gemmaStatusCommand: SlashCommand = {
name: 'gemma',
description: 'Check local Gemma model routing status',
kind: CommandKind.BUILT_IN,
autoExecute: true,
isSafeConcurrent: true,
action: async (context) => {
const port =
parseInt(
context.services.settings.merged.experimental?.gemmaModelRouter?.classifier?.host?.match(
/:(\d+)/,
)?.[1] ?? '',
10,
) || undefined;
const status = await checkGemmaStatus(port);
const item: Omit<HistoryItemGemmaStatus, 'id'> = {
type: MessageType.GEMMA_STATUS,
binaryInstalled: status.binaryInstalled,
binaryPath: status.binaryPath,
modelName: GEMMA_MODEL_NAME,
modelDownloaded: status.modelDownloaded,
serverRunning: status.serverRunning,
serverPid: status.serverPid,
serverPort: status.port,
settingsEnabled: status.settingsEnabled,
allPassing: status.allPassing,
};
context.ui.addItem(item);
},
};
@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import { noteCommand } from './noteCommand.js';
import { type CommandContext } from './types.js';
vi.mock('node:fs/promises');
describe('noteCommand', () => {
const mockContext = {} as CommandContext;
const notesPath = path.join(process.cwd(), 'notes.md');
beforeEach(() => {
vi.clearAllMocks();
});
it('should return notes content when no args provided and file exists', async () => {
vi.mocked(fs.readFile).mockResolvedValue('existing note\n');
const result = await noteCommand.action!(mockContext, '');
expect(fs.readFile).toHaveBeenCalledWith(notesPath, 'utf8');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('existing note'),
});
});
it('should return info message when no args provided and file does not exist (ENOENT)', async () => {
const error = new Error('File not found') as NodeJS.ErrnoException;
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
const result = await noteCommand.action!(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No notes found. Use "/note <text>" to add one.',
});
});
it('should return error message when readFile fails with other error', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
const result = await noteCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining(
'Failed to read notes: Permission denied',
),
});
});
it('should append trimmed note to file when args are provided', async () => {
const note = ' this is a new note ';
vi.mocked(fs.appendFile).mockResolvedValue(undefined);
const result = await noteCommand.action!(mockContext, note);
expect(fs.appendFile).toHaveBeenCalledWith(
notesPath,
`this is a new note\n`,
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Note added'),
});
});
it('should return error message when append fails', async () => {
vi.mocked(fs.appendFile).mockRejectedValue(new Error('Permission denied'));
const result = await noteCommand.action!(mockContext, 'some note');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining(
'Failed to save note: Permission denied',
),
});
});
});
@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { isNodeError } from '@google/gemini-cli-core';
import { CommandKind, type SlashCommand } from './types.js';
export const noteCommand: SlashCommand = {
name: 'note',
description: 'Append a note to notes.md or view current notes',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (_context, args) => {
const notesPath = path.join(process.cwd(), 'notes.md');
if (!args || args.trim().length === 0) {
try {
const content = await fs.readFile(notesPath, 'utf8');
return {
type: 'message',
messageType: 'info',
content: `Current notes in ${notesPath}:\n\n${content}`,
};
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return {
type: 'message',
messageType: 'info',
content: 'No notes found. Use "/note <text>" to add one.',
};
}
return {
type: 'message',
messageType: 'error',
content: `Failed to read notes: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
try {
const trimmedNote = args.trim();
await fs.appendFile(notesPath, `${trimmedNote}\n`);
return {
type: 'message',
messageType: 'info',
content: `Note added to ${notesPath}`,
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to save note: ${error instanceof Error ? error.message : String(error)}`,
};
}
},
};
@@ -159,6 +159,7 @@ Implement a comprehensive authentication system with multiple providers.
isTrustedFolder: () => true,
getPreferredEditor: () => undefined,
getSessionId: () => 'test-session-id',
getProjectRoot: () => mockTargetDir,
storage: {
getPlansDir: () => mockPlansDir,
},
@@ -466,6 +467,7 @@ Implement a comprehensive authentication system with multiple providers.
getIdeMode: () => false,
isTrustedFolder: () => true,
getSessionId: () => 'test-session-id',
getProjectRoot: () => mockTargetDir,
storage: {
getPlansDir: () => mockPlansDir,
},
@@ -85,6 +85,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
const pathError = await validatePlanPath(
planPath,
config.storage.getPlansDir(),
config.getProjectRoot(),
);
if (ignore) return;
if (pathError) {
@@ -32,6 +32,7 @@ import { ToolsList } from './views/ToolsList.js';
import { SkillsList } from './views/SkillsList.js';
import { AgentsStatus } from './views/AgentsStatus.js';
import { McpStatus } from './views/McpStatus.js';
import { GemmaStatus } from './views/GemmaStatus.js';
import { ChatList } from './views/ChatList.js';
import { ModelMessage } from './messages/ModelMessage.js';
import { ThinkingMessage } from './messages/ThinkingMessage.js';
@@ -228,6 +229,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}
{itemForDisplay.type === 'gemma_status' && (
<GemmaStatus {...itemForDisplay} />
)}
{itemForDisplay.type === 'chat_list' && (
<ChatList chats={itemForDisplay.chats} />
)}
@@ -219,7 +219,7 @@ describe('Hint Visibility', () => {
<ThemeDialog {...baseProps} settings={settings} />,
{
settings,
uiState: { terminalBackgroundColor: '#FFFFFF' },
uiState: { terminalBackgroundColor: '#123456' },
},
);
+16 -7
View File
@@ -287,11 +287,15 @@ export function ThemeDialog({
const itemWithExtras = item as typeof item & {
themeWarning?: string;
themeMatch?: string;
themeNameDisplay?: string;
themeTypeDisplay?: string;
};
if (item.themeNameDisplay && item.themeTypeDisplay) {
const match = item.themeNameDisplay.match(/^(.*) \((.*)\)$/);
let themeNamePart: React.ReactNode = item.themeNameDisplay;
if (itemWithExtras.themeNameDisplay) {
const match =
itemWithExtras.themeNameDisplay.match(/^(.*) \((.*)\)$/);
let themeNamePart: React.ReactNode =
itemWithExtras.themeNameDisplay;
if (match) {
themeNamePart = (
<>
@@ -303,10 +307,15 @@ export function ThemeDialog({
return (
<Text color={titleColor} wrap="truncate" key={item.key}>
{themeNamePart}{' '}
<Text color={theme.text.secondary}>
{item.themeTypeDisplay}
</Text>
{themeNamePart}
{itemWithExtras.themeTypeDisplay ? (
<>
{' '}
<Text color={theme.text.secondary}>
{itemWithExtras.themeTypeDisplay}
</Text>
</>
) : null}
{itemWithExtras.themeMatch && (
<Text color={theme.status.success}>
{itemWithExtras.themeMatch}
@@ -52,6 +52,7 @@ describe('ToolConfirmationQueue', () => {
getModel: () => 'gemini-pro',
getDebugMode: () => false,
getTargetDir: () => '/mock/target/dir',
getProjectRoot: () => '/mock/project/root',
getFileSystemService: () => ({
readFile: vi.fn().mockResolvedValue('Plan content'),
}),
@@ -16,7 +16,7 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ 12. ANSI Light (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -32,7 +32,7 @@ exports[`Initial Theme Selection > should default to a light theme when terminal
│ ▲ ┌─────────────────────────────────────────────────┐ │
│ 1. ANSI Light │ │ │
│ 2. Ayu Light │ 1 # function │ │
│ ● 3. Default Light │ 2 def fibonacci(n): │ │
│ ● 3. Default Light (Matches terminal) │ 2 def fibonacci(n): │ │
│ 4. GitHub Light │ 3 a, b = 0, 1 │ │
│ 5. GitHub Light Colorblind Light (Mat… │ 4 for _ in range(n): │ │
│ 6. Google Code Light │ 5 a, b = b, a + b │ │
@@ -66,7 +66,7 @@ exports[`Initial Theme Selection > should use the theme from settings even if te
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ 12. ANSI Light (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -105,7 +105,7 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ 12. ANSI Light (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -130,7 +130,7 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ 12. ANSI Light (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
│ ╭─────────────────────────────────────────────────╮ │
│ │ DEVELOPER TOOLS (Not visible to users) │ │
@@ -0,0 +1,120 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type React from 'react';
import { theme } from '../../semantic-colors.js';
import type { HistoryItemGemmaStatus } from '../../types.js';
type GemmaStatusProps = Omit<HistoryItemGemmaStatus, 'id' | 'type'>;
const StatusDot: React.FC<{ ok: boolean }> = ({ ok }) => (
<Text color={ok ? theme.status.success : theme.status.error}>
{ok ? '\u25CF' : '\u25CB'}
</Text>
);
export const GemmaStatus: React.FC<GemmaStatusProps> = ({
binaryInstalled,
binaryPath,
modelName,
modelDownloaded,
serverRunning,
serverPid,
serverPort,
settingsEnabled,
allPassing,
}) => (
<Box flexDirection="column">
<Text bold>Gemma Local Model Routing</Text>
<Box height={1} />
<Box>
<StatusDot ok={binaryInstalled} />
<Text>
{' '}
<Text bold>Binary: </Text>
{binaryInstalled ? (
<Text color={theme.text.secondary}>{binaryPath}</Text>
) : (
<Text color={theme.status.error}>Not installed</Text>
)}
</Text>
</Box>
<Box>
<StatusDot ok={modelDownloaded} />
<Text>
{' '}
<Text bold>Model: </Text>
{modelDownloaded ? (
<Text>{modelName}</Text>
) : (
<Text color={theme.status.error}>{modelName} not found</Text>
)}
</Text>
</Box>
<Box>
<StatusDot ok={serverRunning} />
<Text>
{' '}
<Text bold>Server: </Text>
{serverRunning ? (
<Text>
port {serverPort}
{serverPid ? (
<Text color={theme.text.secondary}> (PID {serverPid})</Text>
) : null}
</Text>
) : (
<Text color={theme.status.error}>
not running on port {serverPort}
</Text>
)}
</Text>
</Box>
<Box>
<StatusDot ok={settingsEnabled} />
<Text>
{' '}
<Text bold>Settings: </Text>
{settingsEnabled ? (
<Text>enabled</Text>
) : (
<Text color={theme.status.error}>not enabled</Text>
)}
</Text>
</Box>
<Box marginTop={1}>
<Text bold>Active for: </Text>
{allPassing ? (
<Text color={theme.status.success}>[routing]</Text>
) : (
<Text color={theme.text.secondary}>none</Text>
)}
</Box>
<Box marginTop={1}>
{allPassing ? (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
Simple requests route to Flash, complex requests to Pro.
</Text>
<Text color={theme.text.secondary}>
This happens automatically on every request.
</Text>
</Box>
) : (
<Text color={theme.status.warning}>
Run &quot;gemini gemma setup&quot; to install and configure.
</Text>
)}
</Box>
</Box>
);
@@ -553,6 +553,38 @@ describe('useAtCompletion', () => {
]);
});
it('should pass enableFileWatcher flag into FileSearchFactory options', async () => {
const structure: FileSystemStructure = {
src: {
'index.ts': '',
},
};
testRootDir = await createTmpDir(structure);
const createSpy = vi.spyOn(FileSearchFactory, 'create');
const configWithWatcher = {
getFileFilteringOptions: vi.fn(() => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
enableFileWatcher: true,
})),
getEnableRecursiveFileSearch: () => true,
getFileFilteringEnableFuzzySearch: () => true,
} as unknown as Config;
const { result } = await renderHook(() =>
useTestHarnessForAtCompletion(true, '', configWithWatcher, testRootDir),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
expect(createSpy).toHaveBeenCalled();
const firstCallArg = createSpy.mock.calls[0]?.[0];
expect(firstCallArg?.enableFileWatcher).toBe(true);
});
it('should reset and re-initialize when the cwd changes', async () => {
const structure1: FileSystemStructure = { 'file1.txt': '' };
const rootDir1 = await createTmpDir(structure1);
+31 -5
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useReducer, useRef } from 'react';
import { useCallback, useEffect, useReducer, useRef } from 'react';
import { setTimeout as setTimeoutPromise } from 'node:timers/promises';
import * as path from 'node:path';
import {
@@ -224,15 +224,28 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
setIsLoadingSuggestions(state.isLoading);
}, [state.isLoading, setIsLoadingSuggestions]);
const resetFileSearchState = () => {
const disposeFileSearchers = useCallback(async () => {
const searchers = [...fileSearchMap.current.values()];
fileSearchMap.current.clear();
initEpoch.current += 1;
const closePromises: Array<Promise<void>> = [];
for (const searcher of searchers) {
if (searcher.close) {
closePromises.push(searcher.close());
}
}
await Promise.all(closePromises);
}, []);
const resetFileSearchState = useCallback(() => {
void disposeFileSearchers();
dispatch({ type: 'RESET' });
};
}, [disposeFileSearchers]);
useEffect(() => {
resetFileSearchState();
}, [cwd, config]);
}, [cwd, config, resetFileSearchState]);
useEffect(() => {
const workspaceContext = config?.getWorkspaceContext?.();
@@ -242,7 +255,18 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
workspaceContext.onDirectoriesChanged(resetFileSearchState);
return unsubscribe;
}, [config]);
}, [config, resetFileSearchState]);
useEffect(
() => () => {
void disposeFileSearchers();
searchAbortController.current?.abort();
if (slowSearchTimer.current) {
clearTimeout(slowSearchTimer.current);
}
},
[disposeFileSearchers],
);
// Reacts to user input (`pattern`) ONLY.
useEffect(() => {
@@ -295,6 +319,8 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
),
cache: true,
cacheTtl: 30,
enableFileWatcher:
config?.getFileFilteringOptions()?.enableFileWatcher ?? false,
enableRecursiveFileSearch:
config?.getEnableRecursiveFileSearch() ?? true,
enableFuzzySearch:
@@ -16,7 +16,7 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { NoopSandboxManager } from '@google/gemini-cli-core';
import { NoopSandboxManager, escapeShellArg } from '@google/gemini-cli-core';
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
@@ -76,7 +76,21 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
isBinary: mockIsBinary,
};
});
vi.mock('node:fs');
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const mockFs = {
...actual,
existsSync: vi.fn(),
mkdtempSync: vi.fn(),
unlinkSync: vi.fn(),
readFileSync: vi.fn(),
rmSync: vi.fn(),
};
return {
...mockFs,
default: mockFs,
};
});
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
const mocked = {
@@ -154,6 +168,7 @@ describe('useExecutionLifecycle', () => {
);
mockIsBinary.mockReturnValue(false);
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.mkdtempSync).mockReturnValue('/tmp/gemini-shell-abcdef');
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
mockShellOutputCallback = callback;
@@ -239,8 +254,9 @@ describe('useExecutionLifecycle', () => {
}),
],
});
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
const wrappedCommand = `{ ls -l; }; __code=$?; pwd > "${tmpFile}"; exit $__code`;
const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
const escapedTmpFile = escapeShellArg(tmpFile, 'bash');
const wrappedCommand = `{\nls -l\n}\n__code=$?; pwd > ${escapedTmpFile}; exit $__code`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
'/test/dir',
@@ -349,11 +365,9 @@ describe('useExecutionLifecycle', () => {
);
});
// Verify it's using the non-pty shell
const wrappedCommand = `{ stream; }; __code=$?; pwd > "${path.join(
os.tmpdir(),
'shell_pwd_abcdef.tmp',
)}"; exit $__code`;
const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
const escapedTmpFile = escapeShellArg(tmpFile, 'bash');
const wrappedCommand = `{\nstream\n}\n__code=$?; pwd > ${escapedTmpFile}; exit $__code`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
'/test/dir',
@@ -644,7 +658,7 @@ describe('useExecutionLifecycle', () => {
type: 'error',
text: 'An unexpected error occurred: Synchronous spawn error',
});
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
// Verify that the temporary file was cleaned up
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
@@ -652,7 +666,7 @@ describe('useExecutionLifecycle', () => {
describe('Directory Change Warning', () => {
it('should show a warning if the working directory changes', async () => {
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('/test/dir/new'); // A different directory
@@ -20,12 +20,12 @@ import {
ShellExecutionService,
ExecutionLifecycleService,
CoreToolCallStatus,
escapeShellArg,
} from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { SHELL_COMMAND_NAME } from '../constants.js';
import { formatBytes } from '../utils/formatters.js';
import crypto from 'node:crypto';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
@@ -362,18 +362,6 @@ export const useExecutionLifecycle = (
let commandToExecute = rawQuery;
let pwdFilePath: string | undefined;
// On non-windows, wrap the command to capture the final working directory.
if (!isWindows) {
let command = rawQuery.trim();
const pwdFileName = `shell_pwd_${crypto.randomBytes(6).toString('hex')}.tmp`;
pwdFilePath = path.join(os.tmpdir(), pwdFileName);
// Ensure command ends with a separator before adding our own.
if (!command.endsWith(';') && !command.endsWith('&')) {
command += ';';
}
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
}
const executeCommand = async () => {
let cumulativeStdout: string | AnsiOutput = '';
let isBinaryStream = false;
@@ -403,9 +391,23 @@ export const useExecutionLifecycle = (
};
abortSignal.addEventListener('abort', abortHandler, { once: true });
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
try {
// On non-windows, wrap the command to capture the final working directory.
if (!isWindows) {
let command = rawQuery.trim();
if (command.endsWith('\\')) {
command += ' ';
}
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-shell-'),
);
pwdFilePath = path.join(tmpDir, 'pwd.tmp');
const escapedPwdFilePath = escapeShellArg(pwdFilePath, 'bash');
commandToExecute = `{\n${command}\n}\n__code=$?; pwd > ${escapedPwdFilePath}; exit $__code`;
}
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
const activeTheme = themeManager.getActiveTheme();
const shellExecutionConfig = {
...config.getShellExecutionConfig(),
@@ -630,8 +632,18 @@ export const useExecutionLifecycle = (
);
} finally {
abortSignal.removeEventListener('abort', abortHandler);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
if (pwdFilePath) {
const tmpDir = path.dirname(pwdFilePath);
try {
if (fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
}
if (fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
} catch {
// Ignore cleanup errors
}
}
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
+15
View File
@@ -355,6 +355,19 @@ export interface JsonMcpResource {
description?: string;
}
export type HistoryItemGemmaStatus = HistoryItemBase & {
type: 'gemma_status';
binaryInstalled: boolean;
binaryPath: string | null;
modelName: string;
modelDownloaded: boolean;
serverRunning: boolean;
serverPid: number | null;
serverPort: number;
settingsEnabled: boolean;
allPassing: boolean;
};
export type HistoryItemMcpStatus = HistoryItemBase & {
type: 'mcp_status';
servers: Record<string, MCPServerConfig>;
@@ -404,6 +417,7 @@ export type HistoryItemWithoutId =
| HistoryItemSkillsList
| HistoryItemAgentsList
| HistoryItemMcpStatus
| HistoryItemGemmaStatus
| HistoryItemChatList
| HistoryItemThinking
| HistoryItemHint
@@ -430,6 +444,7 @@ export enum MessageType {
SKILLS_LIST = 'skills_list',
AGENTS_LIST = 'agents_list',
MCP_STATUS = 'mcp_status',
GEMMA_STATUS = 'gemma_status',
CHAT_LIST = 'chat_list',
HINT = 'hint',
}
+21
View File
@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
debugLogger,
startMemoryService,
type Config,
} from '@google/gemini-cli-core';
export function startAutoMemoryIfEnabled(config: Config): void {
if (!config.isAutoMemoryEnabled()) {
return;
}
startMemoryService(config).catch((e) => {
debugLogger.error('Failed to start memory service:', e);
});
}
+1
View File
@@ -56,6 +56,7 @@
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"chokidar": "^5.0.0",
"diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
+47
View File
@@ -538,5 +538,52 @@ describe('a2aUtils', () => {
expect(output).toContain('Artifact (Data):');
expect(output).not.toContain('Answer from history');
});
it('should return message log as activity items', () => {
const reassembler = new A2AResultReassembler();
reassembler.update({
kind: 'status-update',
taskId: 't1',
contextId: 'ctx1',
status: {
state: 'working',
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: 'Message 1' }],
} as Message,
},
} as unknown as SendMessageResult);
reassembler.update({
kind: 'status-update',
taskId: 't1',
contextId: 'ctx1',
status: {
state: 'working',
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: 'Message 2' }],
} as Message,
},
} as unknown as SendMessageResult);
const items = reassembler.toActivityItems();
expect(items).toHaveLength(2);
expect(items[0]).toEqual({
id: 'msg-0',
type: 'thought',
content: 'Message 1',
status: 'completed',
});
expect(items[1]).toEqual({
id: 'msg-1',
type: 'thought',
content: 'Message 2',
status: 'completed',
});
});
});
});
+31 -15
View File
@@ -124,6 +124,7 @@ export class A2AResultReassembler {
private pushMessage(message: Message | undefined) {
if (!message) return;
if (message.role === 'user') return; // Skip user messages reflected by server
const text = extractPartsText(message.parts, '');
if (text && this.messageLog[this.messageLog.length - 1] !== text) {
this.messageLog.push(text);
@@ -135,21 +136,36 @@ export class A2AResultReassembler {
*/
toActivityItems(): SubagentActivityItem[] {
const isAuthRequired = this.messageLog.includes(AUTH_REQUIRED_MSG);
return [
isAuthRequired
? {
id: 'auth-required',
type: 'thought',
content: AUTH_REQUIRED_MSG,
status: 'running',
}
: {
id: 'pending',
type: 'thought',
content: 'Working...',
status: 'running',
},
];
const items: SubagentActivityItem[] = [];
if (isAuthRequired) {
items.push({
id: 'auth-required',
type: 'thought',
content: AUTH_REQUIRED_MSG,
status: 'running',
});
}
this.messageLog.forEach((msg, index) => {
items.push({
id: `msg-${index}`,
type: 'thought',
content: msg.trim(),
status: 'completed',
});
});
if (items.length === 0 && !isAuthRequired) {
items.push({
id: 'pending',
type: 'thought',
content: 'Working...',
status: 'running',
});
}
return items;
}
/**
+1
View File
@@ -194,6 +194,7 @@ class DelegateInvocation extends BaseToolInvocation<
{
operation: GeminiCliOperation.AgentCall,
logPrompts: this.context.config.getTelemetryLogPromptsEnabled(),
tracesEnabled: this.context.config.getTelemetryTracesEnabled(),
sessionId: this.context.config.getSessionId(),
attributes: {
[GEN_AI_AGENT_NAME]: this.definition.name,
@@ -95,8 +95,8 @@ Test System Prompt`;
});
// Trigger the refresh action that follows reloading
// @ts-expect-error accessing private method for testing
await config.onAgentsRefreshed();
await config.getAgentRegistry().reload();
// 4. Verify the agent is UNREGISTERED
const finalAgents = agentRegistry.getAllDefinitions().map((d) => d.name);
@@ -237,8 +237,8 @@ Test System Prompt`;
});
// Trigger the refresh action that follows reloading
// @ts-expect-error accessing private method for testing
await config.onAgentsRefreshed();
await config.getAgentRegistry().reload();
expect(agentRegistry.getAllDefinitions().map((d) => d.name)).toContain(
agentName,
+33
View File
@@ -836,12 +836,37 @@ describe('Server Config (config.ts)', () => {
undefined,
undefined,
undefined,
undefined,
);
// Verify that contentGeneratorConfig is updated
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
expect(GeminiClient).toHaveBeenCalledWith(config);
});
it('should pass Vertex AI routing settings when refreshing auth', async () => {
const vertexAiRouting = {
requestType: 'shared' as const,
sharedRequestType: 'priority' as const,
};
const config = new Config({
...baseParams,
vertexAiRouting,
});
vi.mocked(createContentGeneratorConfig).mockResolvedValue({});
await config.refreshAuth(AuthType.USE_VERTEX_AI);
expect(createContentGeneratorConfig).toHaveBeenCalledWith(
config,
AuthType.USE_VERTEX_AI,
undefined,
undefined,
undefined,
vertexAiRouting,
);
});
it('should reset model availability status', async () => {
const config = new Config(baseParams);
const service = config.getModelAvailabilityService();
@@ -1975,6 +2000,8 @@ describe('GemmaModelRouterSettings', () => {
const config = new Config(baseParams);
const settings = config.getGemmaModelRouterSettings();
expect(settings.enabled).toBe(false);
expect(settings.autoStartServer).toBe(true);
expect(settings.binaryPath).toBe('');
expect(settings.classifier?.host).toBe('http://localhost:9379');
expect(settings.classifier?.model).toBe('gemma3-1b-gpu-custom');
});
@@ -1984,6 +2011,8 @@ describe('GemmaModelRouterSettings', () => {
...baseParams,
gemmaModelRouter: {
enabled: true,
autoStartServer: false,
binaryPath: '/custom/lit',
classifier: {
host: 'http://custom:1234',
model: 'custom-gemma',
@@ -1993,6 +2022,8 @@ describe('GemmaModelRouterSettings', () => {
const config = new Config(params);
const settings = config.getGemmaModelRouterSettings();
expect(settings.enabled).toBe(true);
expect(settings.autoStartServer).toBe(false);
expect(settings.binaryPath).toBe('/custom/lit');
expect(settings.classifier?.host).toBe('http://custom:1234');
expect(settings.classifier?.model).toBe('custom-gemma');
});
@@ -2007,6 +2038,8 @@ describe('GemmaModelRouterSettings', () => {
const config = new Config(params);
const settings = config.getGemmaModelRouterSettings();
expect(settings.enabled).toBe(true);
expect(settings.autoStartServer).toBe(true);
expect(settings.binaryPath).toBe('');
expect(settings.classifier?.host).toBe('http://localhost:9379');
expect(settings.classifier?.model).toBe('gemma3-1b-gpu-custom');
});
+22 -2
View File
@@ -23,6 +23,7 @@ import {
createContentGeneratorConfig,
type ContentGenerator,
type ContentGeneratorConfig,
type VertexAiRoutingConfig,
} from '../core/contentGenerator.js';
import type { OverageStrategy } from '../billing/billing.js';
import { PromptRegistry } from '../prompts/prompt-registry.js';
@@ -204,6 +205,7 @@ export interface PlanSettings {
export interface TelemetrySettings {
enabled?: boolean;
traces?: boolean;
target?: TelemetryTarget;
otlpEndpoint?: string;
otlpProtocol?: 'grpc' | 'http';
@@ -219,6 +221,8 @@ export interface OutputSettings {
export interface GemmaModelRouterSettings {
enabled?: boolean;
autoStartServer?: boolean;
binaryPath?: string;
classifier?: {
host?: string;
model?: string;
@@ -612,6 +616,7 @@ export interface ConfigParameters {
fileFiltering?: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
enableFileWatcher?: boolean;
enableRecursiveFileSearch?: boolean;
enableFuzzySearch?: boolean;
maxFileCount?: number;
@@ -729,6 +734,7 @@ export interface ConfigParameters {
billing?: {
overageStrategy?: OverageStrategy;
};
vertexAiRouting?: VertexAiRoutingConfig;
}
export class Config implements McpContext, AgentLoopContext {
@@ -794,6 +800,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly fileFiltering: {
respectGitIgnore: boolean;
respectGeminiIgnore: boolean;
enableFileWatcher: boolean;
enableRecursiveFileSearch: boolean;
enableFuzzySearch: boolean;
maxFileCount: number;
@@ -934,6 +941,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly billing: {
overageStrategy: OverageStrategy;
};
private readonly vertexAiRouting: VertexAiRoutingConfig | undefined;
private readonly enableAgents: boolean;
private agents: AgentSettings;
@@ -1056,6 +1064,7 @@ export class Config implements McpContext, AgentLoopContext {
this.accessibility = params.accessibility ?? {};
this.telemetrySettings = {
enabled: params.telemetry?.enabled ?? false,
traces: params.telemetry?.traces ?? false,
target: params.telemetry?.target ?? DEFAULT_TELEMETRY_TARGET,
otlpEndpoint: params.telemetry?.otlpEndpoint ?? DEFAULT_OTLP_ENDPOINT,
otlpProtocol: params.telemetry?.otlpProtocol,
@@ -1073,6 +1082,10 @@ export class Config implements McpContext, AgentLoopContext {
respectGeminiIgnore:
params.fileFiltering?.respectGeminiIgnore ??
DEFAULT_FILE_FILTERING_OPTIONS.respectGeminiIgnore,
enableFileWatcher:
params.fileFiltering?.enableFileWatcher ??
DEFAULT_FILE_FILTERING_OPTIONS.enableFileWatcher ??
true,
enableRecursiveFileSearch:
params.fileFiltering?.enableRecursiveFileSearch ?? true,
enableFuzzySearch: params.fileFiltering?.enableFuzzySearch ?? true,
@@ -1323,6 +1336,8 @@ export class Config implements McpContext, AgentLoopContext {
};
this.gemmaModelRouter = {
enabled: params.gemmaModelRouter?.enabled ?? false,
autoStartServer: params.gemmaModelRouter?.autoStartServer ?? true,
binaryPath: params.gemmaModelRouter?.binaryPath ?? '',
classifier: {
host:
params.gemmaModelRouter?.classifier?.host ?? 'http://localhost:9379',
@@ -1358,6 +1373,7 @@ export class Config implements McpContext, AgentLoopContext {
this.billing = {
overageStrategy: params.billing?.overageStrategy ?? 'ask',
};
this.vertexAiRouting = params.vertexAiRouting;
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
@@ -1545,6 +1561,7 @@ export class Config implements McpContext, AgentLoopContext {
apiKey,
baseUrl,
customHeaders,
this.vertexAiRouting,
);
this.contentGenerator = await createContentGenerator(
newContentGeneratorConfig,
@@ -2723,6 +2740,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.telemetrySettings.enabled ?? false;
}
getTelemetryTracesEnabled(): boolean {
return this.telemetrySettings.traces ?? false;
}
getTelemetryLogPromptsEnabled(): boolean {
return this.telemetrySettings.logPrompts ?? true;
}
@@ -2816,6 +2837,7 @@ export class Config implements McpContext, AgentLoopContext {
return {
respectGitIgnore: this.fileFiltering.respectGitIgnore,
respectGeminiIgnore: this.fileFiltering.respectGeminiIgnore,
enableFileWatcher: this.fileFiltering.enableFileWatcher,
maxFileCount: this.fileFiltering.maxFileCount,
searchTimeout: this.fileFiltering.searchTimeout,
customIgnoreFilePaths: this.fileFiltering.customIgnoreFilePaths,
@@ -3807,8 +3829,6 @@ export class Config implements McpContext, AgentLoopContext {
}
private onAgentsRefreshed = async () => {
await this.agentRegistry.initialize();
// Propagate updates to the active chat session
const client = this.geminiClient;
if (client?.isInitialized()) {
+3
View File
@@ -7,6 +7,7 @@
export interface FileFilteringOptions {
respectGitIgnore: boolean;
respectGeminiIgnore: boolean;
enableFileWatcher?: boolean;
maxFileCount?: number;
searchTimeout?: number;
customIgnoreFilePaths: string[];
@@ -16,6 +17,7 @@ export interface FileFilteringOptions {
export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
respectGitIgnore: false,
respectGeminiIgnore: true,
enableFileWatcher: false,
maxFileCount: 20000,
searchTimeout: 5000,
customIgnoreFilePaths: [],
@@ -25,6 +27,7 @@ export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
export const DEFAULT_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
respectGitIgnore: true,
respectGeminiIgnore: true,
enableFileWatcher: false,
maxFileCount: 20000,
searchTimeout: 5000,
customIgnoreFilePaths: [],
@@ -95,7 +95,7 @@ For example:
# Active Approval Mode: Plan
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/plans/\` and get user approval before editing source code.
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`../plans/\` and get user approval before editing source code.
## Available Tools
The following tools are available in Plan Mode:
@@ -111,8 +111,8 @@ The following tools are available in Plan Mode:
</available_tools>
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`../plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`../plans/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
@@ -136,7 +136,7 @@ The depth of your consultation should be proportional to the task's complexity.
**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan.
### 3. Draft
Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task:
Write the implementation plan to \`../plans/\`. The plan's structure adapts to the task:
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
@@ -275,7 +275,7 @@ For example:
# Active Approval Mode: Plan
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/plans/\` and get user approval before editing source code.
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`../plans/\` and get user approval before editing source code.
## Available Tools
The following tools are available in Plan Mode:
@@ -291,8 +291,8 @@ The following tools are available in Plan Mode:
</available_tools>
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`../plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`../plans/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
@@ -316,7 +316,7 @@ The depth of your consultation should be proportional to the task's complexity.
**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan.
### 3. Draft
Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task:
Write the implementation plan to \`../plans/\`. The plan's structure adapts to the task:
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
@@ -326,7 +326,7 @@ Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to
ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval.
## Approved Plan
An approved plan is available for this task at \`/tmp/plans/feature-x.md\`.
An approved plan is available for this task at \`../plans/feature-x.md\`.
- **Read First:** You MUST read this file using the \`read_file\` tool before proposing any changes or starting discovery.
- **Iterate:** Default to refining the existing approved plan.
- **New Plan:** Only create a new plan file if the user explicitly asks for a "new plan".
@@ -576,7 +576,7 @@ For example:
# Active Approval Mode: Plan
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/project-temp/plans/\` and get user approval before editing source code.
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`plans/\` and get user approval before editing source code.
## Available Tools
The following tools are available in Plan Mode:
@@ -592,8 +592,8 @@ The following tools are available in Plan Mode:
</available_tools>
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/project-temp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/project-temp/plans/\`. They cannot modify source code.
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`plans/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
@@ -617,7 +617,7 @@ The depth of your consultation should be proportional to the task's complexity.
**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan.
### 3. Draft
Write the implementation plan to \`/tmp/project-temp/plans/\`. The plan's structure adapts to the task:
Write the implementation plan to \`plans/\`. The plan's structure adapts to the task:
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
@@ -385,6 +385,44 @@ describe('createContentGenerator', () => {
);
});
it('should include Vertex AI routing headers for Vertex AI requests', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
await createContentGenerator(
{
apiKey: 'test-api-key',
vertexai: true,
authType: AuthType.USE_VERTEX_AI,
vertexAiRouting: {
requestType: 'shared',
sharedRequestType: 'priority',
},
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'X-Vertex-AI-LLM-Request-Type': 'shared',
'X-Vertex-AI-LLM-Shared-Request-Type': 'priority',
}),
}),
}),
);
});
it('should pass api key as Authorization Header when GEMINI_API_KEY_AUTH_MECHANISM is set to bearer', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
@@ -887,6 +925,25 @@ describe('createContentGeneratorConfig', () => {
expect(config.vertexai).toBe(true);
});
it('should include Vertex AI routing settings in content generator config', async () => {
vi.stubEnv('GOOGLE_API_KEY', 'env-google-key');
const vertexAiRouting = {
requestType: 'shared' as const,
sharedRequestType: 'priority' as const,
};
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_VERTEX_AI,
undefined,
undefined,
undefined,
vertexAiRouting,
);
expect(config.vertexAiRouting).toEqual(vertexAiRouting);
});
it('should configure for Vertex AI using GCP project and location when set', async () => {
vi.stubEnv('GOOGLE_API_KEY', undefined);
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'env-gcp-project');
@@ -99,9 +99,21 @@ export type ContentGeneratorConfig = {
proxy?: string;
baseUrl?: string;
customHeaders?: Record<string, string>;
vertexAiRouting?: VertexAiRoutingConfig;
};
export type VertexAiRequestType = 'dedicated' | 'shared';
export type VertexAiSharedRequestType = 'priority' | 'flex';
export interface VertexAiRoutingConfig {
requestType?: VertexAiRequestType;
sharedRequestType?: VertexAiSharedRequestType;
}
const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
const VERTEX_AI_REQUEST_TYPE_HEADER = 'X-Vertex-AI-LLM-Request-Type';
const VERTEX_AI_SHARED_REQUEST_TYPE_HEADER =
'X-Vertex-AI-LLM-Shared-Request-Type';
function validateBaseUrl(baseUrl: string): void {
let url: URL;
@@ -122,6 +134,7 @@ export async function createContentGeneratorConfig(
apiKey?: string,
baseUrl?: string,
customHeaders?: Record<string, string>,
vertexAiRouting?: VertexAiRoutingConfig,
): Promise<ContentGeneratorConfig> {
const geminiApiKey =
apiKey ||
@@ -140,6 +153,7 @@ export async function createContentGeneratorConfig(
proxy: config?.getProxy(),
baseUrl,
customHeaders,
vertexAiRouting,
};
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now
@@ -280,6 +294,21 @@ export async function createContentGenerator(
if (config.customHeaders) {
headers = { ...headers, ...config.customHeaders };
}
if (
config.authType === AuthType.USE_VERTEX_AI &&
config.vertexAiRouting
) {
const { requestType, sharedRequestType } = config.vertexAiRouting;
headers = {
...headers,
...(requestType
? { [VERTEX_AI_REQUEST_TYPE_HEADER]: requestType }
: {}),
...(sharedRequestType
? { [VERTEX_AI_SHARED_REQUEST_TYPE_HEADER]: sharedRequestType }
: {}),
};
}
if (gcConfig?.getUsageStatisticsEnabled()) {
const installationManager = new InstallationManager();
const installationId = installationManager.getInstallationId();
@@ -153,6 +153,7 @@ describe('GeminiChat', () => {
promptId: 'test-session-id',
getSessionId: () => 'test-session-id',
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getContentGeneratorConfig: vi.fn().mockImplementation(() => ({
@@ -96,6 +96,7 @@ describe('GeminiChat Network Retries', () => {
promptId: 'test-session-id',
getSessionId: () => 'test-session-id',
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getContentGeneratorConfig: vi.fn().mockReturnValue({
@@ -7,6 +7,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LocalLiteRtLmClient } from './localLiteRtLmClient.js';
import type { Config } from '../config/config.js';
import { GoogleGenAI } from '@google/genai';
const mockGenerateContent = vi.fn();
vi.mock('@google/genai', () => {
@@ -44,6 +46,14 @@ describe('LocalLiteRtLmClient', () => {
const result = await client.generateJson([], 'test-instruction');
expect(result).toEqual({ key: 'value' });
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiVersion: 'v1beta',
httpOptions: expect.objectContaining({
baseUrl: 'http://test-host:1234',
}),
}),
);
expect(mockGenerateContent).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gemma:latest',
@@ -25,6 +25,8 @@ export class LocalLiteRtLmClient {
this.client = new GoogleGenAI({
// The LiteRT-LM server does not require an API key, but the SDK requires one to be set even for local endpoints. This is a dummy value and is not used for authentication.
apiKey: 'no-api-key-needed',
apiVersion: 'v1beta',
vertexai: false,
httpOptions: {
baseUrl: this.host,
// If the LiteRT-LM server is started but the wrong port is set, there will be a lengthy TCP timeout (here fixed to be 10 seconds).
@@ -73,6 +73,7 @@ describe('LoggingContentGenerator', () => {
authType: 'API_KEY',
}),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(true),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
refreshUserQuotaIfStale: vi.fn().mockResolvedValue(undefined),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Config;
@@ -361,6 +361,7 @@ export class LoggingContentGenerator implements ContentGenerator {
{
operation: GeminiCliOperation.LLMCall,
logPrompts: this.config.getTelemetryLogPromptsEnabled(),
tracesEnabled: this.config.getTelemetryTracesEnabled(),
sessionId: this.config.getSessionId(),
attributes: {
[GEN_AI_REQUEST_MODEL]: req.model,
@@ -452,6 +453,7 @@ export class LoggingContentGenerator implements ContentGenerator {
{
operation: GeminiCliOperation.LLMCall,
logPrompts: this.config.getTelemetryLogPromptsEnabled(),
tracesEnabled: this.config.getTelemetryTracesEnabled(),
sessionId: this.config.getSessionId(),
attributes: {
[GEN_AI_REQUEST_MODEL]: req.model,
@@ -607,6 +609,7 @@ export class LoggingContentGenerator implements ContentGenerator {
{
operation: GeminiCliOperation.LLMCall,
logPrompts: this.config.getTelemetryLogPromptsEnabled(),
tracesEnabled: this.config.getTelemetryTracesEnabled(),
sessionId: this.config.getSessionId(),
attributes: {
[GEN_AI_REQUEST_MODEL]: req.model,
+1
View File
@@ -93,6 +93,7 @@ describe('Core System Prompt (prompts.ts)', () => {
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
getProjectRoot: vi.fn().mockReturnValue('/tmp/project-temp'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
@@ -7,6 +7,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PromptProvider } from './promptProvider.js';
import type { Config } from '../config/config.js';
import { makeRelative } from '../utils/paths.js';
import {
getAllGeminiMdFilenames,
DEFAULT_CONTEXT_FILENAME,
@@ -58,6 +59,7 @@ describe('PromptProvider', () => {
).getToolRegistry?.() as unknown as ToolRegistry;
},
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getProjectRoot: vi.fn().mockReturnValue('/tmp/project-temp'),
topicState: new TopicState(),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
@@ -236,7 +238,14 @@ describe('PromptProvider', () => {
expect(prompt).toContain(
'`write_file` and `replace` may ONLY be used to write .md plan files',
);
expect(prompt).toContain('/tmp/project-temp/plans/');
const expectedRelativePath = makeRelative(
mockConfig.storage.getPlansDir(),
mockConfig.getProjectRoot(),
).replaceAll('\\', '/');
expect(prompt).toContain(
`write .md plan files to \`${expectedRelativePath}/\``,
);
});
});
+14 -3
View File
@@ -8,7 +8,7 @@ import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import type { HierarchicalMemory } from '../config/memory.js';
import { GEMINI_DIR } from '../utils/paths.js';
import { GEMINI_DIR, makeRelative } from '../utils/paths.js';
import { ApprovalMode } from '../policy/types.js';
import * as snippets from './snippets.js';
import * as legacySnippets from './snippets.legacy.js';
@@ -199,8 +199,19 @@ export class PromptProvider {
() => ({
interactive: interactiveMode,
planModeToolsList,
plansDir: context.config.storage.getPlansDir(),
approvedPlanPath: context.config.getApprovedPlanPath(),
plansDir: makeRelative(
context.config.storage.getPlansDir(),
context.config.getProjectRoot(),
).replaceAll('\\', '/'),
approvedPlanPath: (() => {
const approvedPath = context.config.getApprovedPlanPath();
return approvedPath
? makeRelative(
approvedPath,
context.config.getProjectRoot(),
).replaceAll('\\', '/')
: undefined;
})(),
}),
isPlanMode,
),
+50 -2
View File
@@ -239,7 +239,7 @@ describe('policy.ts', () => {
});
describe('updatePolicy', () => {
it('should set AUTO_EDIT mode for auto-edit transition tools', async () => {
it('should set AUTO_EDIT mode for auto-edit transition tools and publish policy update', async () => {
const mockConfig = {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
setApprovalMode: vi.fn(),
@@ -266,7 +266,54 @@ describe('policy.ts', () => {
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(mockMessageBus.publish).not.toHaveBeenCalled();
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'replace',
persist: false,
}),
);
});
it('should preserve the original mode set when a session allow triggers AUTO_EDIT', async () => {
let currentMode = ApprovalMode.DEFAULT;
const mockConfig = {
getApprovalMode: vi.fn(() => currentMode),
setApprovalMode: vi.fn((mode: ApprovalMode) => {
currentMode = mode;
}),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = { name: 'replace' } as AnyDeclarativeTool;
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlways,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'replace',
persist: false,
modes: [
ApprovalMode.DEFAULT,
ApprovalMode.AUTO_EDIT,
ApprovalMode.YOLO,
],
}),
);
});
it('should handle standard policy updates (persist=false)', async () => {
@@ -858,6 +905,7 @@ describe('Plan Mode Denial Consistency', () => {
getEnableHooks: vi.fn().mockReturnValue(false),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.PLAN), // Key: Plan Mode
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
+2 -2
View File
@@ -119,16 +119,16 @@ export async function updatePolicy(
messageBus: MessageBus,
toolInvocation?: AnyToolInvocation,
): Promise<void> {
const currentMode = context.config.getApprovalMode();
// Mode Transitions (AUTO_EDIT)
if (isAutoEditTransition(tool, outcome)) {
context.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
return;
}
// Determine persist scope if we are persisting.
let persistScope: 'workspace' | 'user' | undefined;
let modes: ApprovalMode[] | undefined;
const currentMode = context.config.getApprovalMode();
// If this is an 'Always Allow' selection, we restrict it to the current mode
// and more permissive modes.
@@ -178,6 +178,7 @@ describe('Scheduler (Orchestrator)', () => {
setApprovalMode: vi.fn(),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Mocked<Config>;
@@ -1517,6 +1518,7 @@ describe('Scheduler MCP Progress', () => {
setApprovalMode: vi.fn(),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Mocked<Config>;
+1
View File
@@ -196,6 +196,7 @@ export class Scheduler {
{
operation: GeminiCliOperation.ScheduleToolCalls,
logPrompts: this.context.config.getTelemetryLogPromptsEnabled(),
tracesEnabled: this.context.config.getTelemetryTracesEnabled(),
sessionId: this.context.config.getSessionId(),
},
async ({ metadata: spanMetadata }) => {
@@ -71,6 +71,7 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
getEnableHooks: () => true,
getExperiments: () => {},
getTelemetryLogPromptsEnabled: () => false,
getTelemetryTracesEnabled: () => false,
getPolicyEngine: () =>
({
check: async () => ({ decision: 'allow' }),
@@ -218,6 +218,7 @@ describe('Scheduler Parallel Execution', () => {
setApprovalMode: vi.fn(),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Mocked<Config>;
@@ -84,6 +84,7 @@ export class ToolExecutor {
{
operation: GeminiCliOperation.ToolCall,
logPrompts: this.config.getTelemetryLogPromptsEnabled(),
tracesEnabled: this.config.getTelemetryTracesEnabled(),
sessionId: this.config.getSessionId(),
attributes: {
[GEN_AI_TOOL_NAME]: toolName,
@@ -531,12 +531,18 @@ export class ShellExecutionService {
cwd: finalCwd,
} = prepared;
// Bun's child_process does not properly call setsid() for detached
// processes, leaving children in the parent's session without a
// controlling terminal. They receive SIGHUP immediately. Disable
// detached mode in Bun; killProcessGroup already falls back to
// direct-pid kill when the group kill fails.
const isBun = 'bun' in process.versions;
const child = cpSpawn(finalExecutable, finalArgs, {
cwd: finalCwd,
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: isWindows ? false : undefined,
shell: false,
detached: !isWindows,
detached: !isWindows && !isBun,
env: finalEnv,
});
+5
View File
@@ -60,6 +60,10 @@ export async function resolveTelemetrySettings(options: {
parseBooleanEnvFlag(env['GEMINI_TELEMETRY_ENABLED']) ??
settings.enabled;
const traces =
parseBooleanEnvFlag(env['GEMINI_TELEMETRY_TRACES_ENABLED']) ??
settings.traces;
const rawTarget =
argv.telemetryTarget ??
env['GEMINI_TELEMETRY_TARGET'] ??
@@ -110,6 +114,7 @@ export async function resolveTelemetrySettings(options: {
return {
enabled,
traces,
target,
otlpEndpoint,
otlpProtocol,
@@ -37,6 +37,7 @@ describe('conseca-logger', () => {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(true),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
+115 -5
View File
@@ -216,6 +216,7 @@ describe('loggers', () => {
getTelemetryEnabled: () => true,
getUsageStatisticsEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
getFileFilteringRespectGitIgnore: () => true,
getFileFilteringAllowBuildArtifacts: () => false,
getDebugMode: () => true,
@@ -313,6 +314,7 @@ describe('loggers', () => {
getSessionId: () => 'test-session-id',
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
getUsageStatisticsEnabled: () => true,
isInteractive: () => false,
getExperiments: () => undefined,
@@ -352,6 +354,7 @@ describe('loggers', () => {
getSessionId: () => 'test-session-id',
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => false,
getTelemetryTracesEnabled: () => false,
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
isInteractive: () => false,
@@ -392,6 +395,7 @@ describe('loggers', () => {
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => true,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
@@ -493,10 +497,10 @@ describe('loggers', () => {
'gen_ai.output.messages':
'[{"finish_reason":"stop","role":"system","parts":[{"type":"text","content":"candidate 1"}]}]',
'gen_ai.response.finish_reasons': ['stop'],
'gen_ai.operation.name': 'generate_content',
'gen_ai.response.model': 'test-model',
'gen_ai.usage.input_tokens': 17,
'gen_ai.usage.output_tokens': 50,
'gen_ai.operation.name': 'generate_content',
'gen_ai.output.type': 'text',
'gen_ai.request.choice.count': 1,
'gen_ai.request.seed': 678,
@@ -564,6 +568,57 @@ describe('loggers', () => {
});
});
it('should not log input and output messages when traces are disabled', () => {
const mockConfigNoTraces = {
getSessionId: () => 'test-session-id',
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false, // Disabled
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
const event = new ApiResponseEvent(
'test-model',
100,
{ prompt_id: 'prompt-id-1', contents: [] },
{ candidates: [] },
AuthType.LOGIN_WITH_GOOGLE,
undefined,
'test-response',
);
logApiResponse(mockConfigNoTraces, event);
expect(mockLogger.emit).toHaveBeenCalledWith(
expect.objectContaining({
body: 'GenAI operation details from test-model. Status: 200. Duration: 100ms.',
attributes: expect.objectContaining({
'event.name': 'gen_ai.client.inference.operation.details',
'gen_ai.operation.name': 'generate_content',
}),
}),
);
const emitCalls = mockLogger.emit.mock.calls;
const detailsCall = emitCalls.find(
(call) =>
call[0].attributes &&
call[0].attributes['event.name'] ===
'gen_ai.client.inference.operation.details',
);
expect(
detailsCall![0].attributes['gen_ai.input.messages'],
).toBeUndefined();
expect(
detailsCall![0].attributes['gen_ai.output.messages'],
).toBeUndefined();
});
it('should log an API response with a role', () => {
const event = new ApiResponseEvent(
'test-model',
@@ -596,6 +651,7 @@ describe('loggers', () => {
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => true,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
@@ -674,8 +730,6 @@ describe('loggers', () => {
'gen_ai.request.temperature': 1,
'gen_ai.request.top_p': 2,
'gen_ai.request.top_k': 3,
'gen_ai.input.messages':
'[{"role":"user","parts":[{"type":"text","content":"Hello"}]}]',
'gen_ai.operation.name': 'generate_content',
'gen_ai.output.type': 'text',
'gen_ai.request.choice.count': 1,
@@ -683,6 +737,8 @@ describe('loggers', () => {
'gen_ai.request.frequency_penalty': 10,
'gen_ai.request.presence_penalty': 6,
'gen_ai.request.max_tokens': 8000,
'gen_ai.input.messages':
'[{"role":"user","parts":[{"type":"text","content":"Hello"}]}]',
'server.address': 'foo.com',
'server.port': 8080,
'gen_ai.request.stop_sequences': ['stop', 'please stop'],
@@ -724,6 +780,52 @@ describe('loggers', () => {
});
});
it('should not log input messages when traces are disabled', () => {
const mockConfigNoTraces = {
getSessionId: () => 'test-session-id',
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false, // Disabled
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
const event = new ApiErrorEvent(
'test-model',
'error',
100,
{ prompt_id: 'prompt-id-1', contents: [] },
AuthType.LOGIN_WITH_GOOGLE,
'ApiError',
500,
);
logApiError(mockConfigNoTraces, event);
expect(mockLogger.emit).toHaveBeenCalledWith(
expect.objectContaining({
attributes: expect.objectContaining({
'event.name': 'gen_ai.client.inference.operation.details',
}),
}),
);
const emitCalls = mockLogger.emit.mock.calls;
const detailsCall = emitCalls.find(
(call) =>
call[0].attributes &&
call[0].attributes['event.name'] ===
'gen_ai.client.inference.operation.details',
);
expect(
detailsCall![0].attributes['gen_ai.input.messages'],
).toBeUndefined();
});
it('should log an API error with a role', () => {
const event = new ApiErrorEvent(
'test-model',
@@ -756,6 +858,7 @@ describe('loggers', () => {
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
@@ -833,7 +936,8 @@ describe('loggers', () => {
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true, // Enabled
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => true, // Enabled
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
@@ -922,7 +1026,8 @@ describe('loggers', () => {
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => false, // Disabled
getTelemetryLogPromptsEnabled: () => false,
getTelemetryTracesEnabled: () => false, // Disabled
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
@@ -978,6 +1083,7 @@ describe('loggers', () => {
getSessionId: () => 'test-session-id',
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
@@ -1140,6 +1246,7 @@ describe('loggers', () => {
getCoreTools: () => ['ls', 'read-file'],
getApprovalMode: () => 'default',
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
getFileFilteringRespectGitIgnore: () => true,
getFileFilteringAllowBuildArtifacts: () => false,
getDebugMode: () => true,
@@ -1170,6 +1277,7 @@ describe('loggers', () => {
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
@@ -1829,6 +1937,7 @@ describe('loggers', () => {
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
@@ -2423,6 +2532,7 @@ describe('loggers', () => {
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
getTelemetryLogPromptsEnabled: () => false,
getTelemetryTracesEnabled: () => false,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
+50 -10
View File
@@ -115,7 +115,11 @@ describe('runInDevTraceSpan', () => {
const fn = vi.fn(async () => 'result');
const result = await runInDevTraceSpan(
{ operation: GeminiCliOperation.LLMCall, sessionId: 'test-session-id' },
{
operation: GeminiCliOperation.LLMCall,
sessionId: 'test-session-id',
tracesEnabled: true,
},
fn,
);
@@ -123,14 +127,22 @@ describe('runInDevTraceSpan', () => {
expect(trace.getTracer).toHaveBeenCalled();
expect(mockTracer.startActiveSpan).toHaveBeenCalledWith(
GeminiCliOperation.LLMCall,
{},
{
attributes: {
[GEN_AI_CONVERSATION_ID]: 'test-session-id',
},
},
expect.any(Function),
);
});
it('should set default attributes on the span metadata', async () => {
await runInDevTraceSpan(
{ operation: GeminiCliOperation.LLMCall, sessionId: 'test-session-id' },
{
operation: GeminiCliOperation.LLMCall,
sessionId: 'test-session-id',
tracesEnabled: true,
},
async ({ metadata }) => {
expect(metadata.attributes[GEN_AI_OPERATION_NAME]).toBe(
GeminiCliOperation.LLMCall,
@@ -148,7 +160,11 @@ describe('runInDevTraceSpan', () => {
it('should set span attributes from metadata on completion', async () => {
await runInDevTraceSpan(
{ operation: GeminiCliOperation.LLMCall, sessionId: 'test-session-id' },
{
operation: GeminiCliOperation.LLMCall,
sessionId: 'test-session-id',
tracesEnabled: true,
},
async ({ metadata }) => {
metadata.input = { query: 'hello' };
metadata.output = { response: 'world' };
@@ -175,7 +191,11 @@ describe('runInDevTraceSpan', () => {
const error = new Error('test error');
await expect(
runInDevTraceSpan(
{ operation: GeminiCliOperation.LLMCall, sessionId: 'test-session-id' },
{
operation: GeminiCliOperation.LLMCall,
sessionId: 'test-session-id',
tracesEnabled: true,
},
async () => {
throw error;
},
@@ -197,7 +217,11 @@ describe('runInDevTraceSpan', () => {
}
const resultStream = await runInDevTraceSpan(
{ operation: GeminiCliOperation.LLMCall, sessionId: 'test-session-id' },
{
operation: GeminiCliOperation.LLMCall,
sessionId: 'test-session-id',
tracesEnabled: true,
},
async () => testStream(),
);
@@ -219,7 +243,11 @@ describe('runInDevTraceSpan', () => {
}
const resultStream = await runInDevTraceSpan(
{ operation: GeminiCliOperation.LLMCall, sessionId: 'test-session-id' },
{
operation: GeminiCliOperation.LLMCall,
sessionId: 'test-session-id',
tracesEnabled: true,
},
async () => testStream(),
);
@@ -233,7 +261,11 @@ describe('runInDevTraceSpan', () => {
}
const resultStream = await runInDevTraceSpan(
{ operation: GeminiCliOperation.LLMCall, sessionId: 'test-session-id' },
{
operation: GeminiCliOperation.LLMCall,
sessionId: 'test-session-id',
tracesEnabled: true,
},
async () => testStream(),
);
@@ -259,7 +291,11 @@ describe('runInDevTraceSpan', () => {
}
const resultStream = await runInDevTraceSpan(
{ operation: GeminiCliOperation.LLMCall, sessionId: 'test-session-id' },
{
operation: GeminiCliOperation.LLMCall,
sessionId: 'test-session-id',
tracesEnabled: true,
},
async () => errorStream(),
);
@@ -278,7 +314,11 @@ describe('runInDevTraceSpan', () => {
});
await runInDevTraceSpan(
{ operation: GeminiCliOperation.LLMCall, sessionId: 'test-session-id' },
{
operation: GeminiCliOperation.LLMCall,
sessionId: 'test-session-id',
tracesEnabled: true,
},
async ({ metadata }) => {
metadata.input = 'trigger error';
},
+38 -14
View File
@@ -125,10 +125,17 @@ export async function runInDevTraceSpan<R>(
operation: GeminiCliOperation;
logPrompts?: boolean;
sessionId: string;
tracesEnabled?: boolean;
},
fn: ({ metadata }: { metadata: SpanMetadata }) => Promise<R>,
): Promise<R> {
const { operation, logPrompts, sessionId, ...restOfSpanOpts } = opts;
const { operation, logPrompts, sessionId, tracesEnabled, ...restOfSpanOpts } =
opts;
restOfSpanOpts.attributes = {
...restOfSpanOpts.attributes,
[GEN_AI_CONVERSATION_ID]: sessionId,
};
const tracer = trace.getTracer(TRACER_NAME, TRACER_VERSION);
return tracer.startActiveSpan(operation, restOfSpanOpts, async (span) => {
@@ -148,24 +155,41 @@ export async function runInDevTraceSpan<R>(
}
spanEnded = true;
try {
if (logPrompts !== false) {
if (meta.input !== undefined) {
const truncated = truncateForTelemetry(meta.input);
if (truncated !== undefined) {
span.setAttribute(GEN_AI_INPUT_MESSAGES, truncated);
if (tracesEnabled) {
if (logPrompts !== false) {
if (meta.input !== undefined) {
const truncated = truncateForTelemetry(meta.input);
if (truncated !== undefined) {
span.setAttribute(GEN_AI_INPUT_MESSAGES, truncated);
}
}
if (meta.output !== undefined) {
const truncated = truncateForTelemetry(meta.output);
if (truncated !== undefined) {
span.setAttribute(GEN_AI_OUTPUT_MESSAGES, truncated);
}
}
}
if (meta.output !== undefined) {
const truncated = truncateForTelemetry(meta.output);
for (const [key, value] of Object.entries(meta.attributes)) {
const truncated = truncateForTelemetry(value);
if (truncated !== undefined) {
span.setAttribute(GEN_AI_OUTPUT_MESSAGES, truncated);
span.setAttribute(key, truncated);
}
}
}
for (const [key, value] of Object.entries(meta.attributes)) {
const truncated = truncateForTelemetry(value);
if (truncated !== undefined) {
span.setAttribute(key, truncated);
} else {
// Add basic attributes even when traces are disabled
for (const [key, value] of Object.entries(meta.attributes)) {
if (
key === GEN_AI_OPERATION_NAME ||
key === GEN_AI_AGENT_NAME ||
key === GEN_AI_AGENT_DESCRIPTION ||
key === GEN_AI_CONVERSATION_ID
) {
const truncated = truncateForTelemetry(value);
if (truncated !== undefined) {
span.setAttribute(key, truncated);
}
}
}
}
if (meta.error) {
+17 -6
View File
@@ -387,6 +387,13 @@ export class ToolCallEvent implements BaseTelemetryEvent {
}
export const EVENT_API_REQUEST = 'gemini_cli.api_request';
function shouldIncludePayloads(config: Config): boolean {
return (
config.getTelemetryTracesEnabled() && config.getTelemetryLogPromptsEnabled()
);
}
export class ApiRequestEvent implements BaseTelemetryEvent {
'event.name': 'api_request';
'event.timestamp': string;
@@ -443,7 +450,7 @@ export class ApiRequestEvent implements BaseTelemetryEvent {
attributes['server.port'] = this.prompt.server.port;
}
if (config.getTelemetryLogPromptsEnabled() && this.prompt.contents) {
if (shouldIncludePayloads(config) && this.prompt.contents) {
attributes['gen_ai.input.messages'] = JSON.stringify(
toInputMessages(this.prompt.contents),
);
@@ -540,7 +547,7 @@ export class ApiErrorEvent implements BaseTelemetryEvent {
attributes['server.port'] = this.prompt.server.port;
}
if (config.getTelemetryLogPromptsEnabled() && this.prompt.contents) {
if (shouldIncludePayloads(config) && this.prompt.contents) {
attributes['gen_ai.input.messages'] = JSON.stringify(
toInputMessages(this.prompt.contents),
);
@@ -707,9 +714,13 @@ export class ApiResponseEvent implements BaseTelemetryEvent {
'event.timestamp': this['event.timestamp'],
'gen_ai.response.id': this.response.response_id,
'gen_ai.response.finish_reasons': this.finish_reasons,
'gen_ai.output.messages': JSON.stringify(
toOutputMessages(this.response.candidates),
),
...(shouldIncludePayloads(config)
? {
'gen_ai.output.messages': JSON.stringify(
toOutputMessages(this.response.candidates),
),
}
: {}),
...toGenerateContentConfigAttributes(this.prompt.generate_content_config),
...getConventionAttributes(this),
};
@@ -719,7 +730,7 @@ export class ApiResponseEvent implements BaseTelemetryEvent {
attributes['server.port'] = this.prompt.server.port;
}
if (config.getTelemetryLogPromptsEnabled() && this.prompt.contents) {
if (shouldIncludePayloads(config) && this.prompt.contents) {
attributes['gen_ai.input.messages'] = JSON.stringify(
toInputMessages(this.prompt.contents),
);
+3 -2
View File
@@ -107,6 +107,7 @@ describe('EditTool', () => {
getGeminiClient: vi.fn().mockReturnValue(geminiClient),
getBaseLlmClient: vi.fn().mockReturnValue(baseLlmClient),
getTargetDir: () => rootDir,
getProjectRoot: () => rootDir,
getApprovalMode: vi.fn(),
setApprovalMode: vi.fn(),
getWorkspaceContext: () => createMockWorkspaceContext(rootDir),
@@ -1336,8 +1337,8 @@ function doIt() {
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const filePath = path.join(rootDir, 'test-file.txt');
const planFilePath = path.join(plansDir, 'test-file.txt');
const filePath = 'test-file.txt';
const planFilePath = path.join(plansDir, filePath);
const initialContent = 'some initial content';
fs.writeFileSync(planFilePath, initialContent, 'utf8');
+27 -6
View File
@@ -58,6 +58,7 @@ import { EDIT_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
import { discoverJitContext, appendJitContext } from './jit-context.js';
import { resolveAndValidatePlanPath } from '../utils/planUtils.js';
const ENABLE_FUZZY_MATCH_RECOVERY = true;
const FUZZY_MATCH_THRESHOLD = 0.1; // Allow up to 10% weighted difference
@@ -465,11 +466,21 @@ class EditToolInvocation
() => this.config.getApprovalMode(),
);
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
try {
this.resolvedPath = resolveAndValidatePlanPath(
this.params.file_path,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
} catch (e) {
debugLogger.error(
'Failed to resolve plan path during EditTool invocation setup',
e,
);
// Validation fails, set resolvedPath to something that will fail validation downstream or just the raw path.
// It's safer to store it so validation in execute() or getConfirmationDetails() catches it.
this.resolvedPath = this.params.file_path;
}
} else if (!path.isAbsolute(this.params.file_path)) {
const result = correctPath(this.params.file_path, this.config);
if (result.success) {
@@ -1054,7 +1065,17 @@ export class EditTool
}
let resolvedPath: string;
if (!path.isAbsolute(params.file_path)) {
if (this.config.isPlanMode()) {
try {
resolvedPath = resolveAndValidatePlanPath(
params.file_path,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
} else if (!path.isAbsolute(params.file_path)) {
const result = correctPath(params.file_path, this.config);
if (result.success) {
resolvedPath = result.correctedPath;
+10 -3
View File
@@ -42,6 +42,7 @@ describe('ExitPlanModeTool', () => {
mockConfig = {
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
getProjectRoot: vi.fn().mockReturnValue(tempRootDir),
setApprovalMode: vi.fn(),
setApprovedPlanPath: vi.fn(),
storage: {
@@ -72,8 +73,10 @@ describe('ExitPlanModeTool', () => {
const createPlanFile = (name: string, content: string) => {
const filePath = path.join(mockPlansDir, name);
// Ensure parent directory exists for nested tests
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
return path.join('plans', name);
return name;
};
describe('shouldConfirmExecute', () => {
@@ -482,7 +485,11 @@ Ask the user for specific feedback on how to improve the plan.`,
});
it('should reject non-existent plan file', async () => {
const result = await validatePlanPath('ghost.md', mockPlansDir);
const result = await validatePlanPath(
'ghost.md',
mockPlansDir,
tempRootDir,
);
expect(result).toContain('Plan file does not exist');
});
@@ -497,7 +504,7 @@ Ask the user for specific feedback on how to improve the plan.`,
});
expect(result).toBe(
`Access denied: plan path (${path.join(mockPlansDir, 'malicious.md')}) must be within the designated plans directory (${mockPlansDir}).`,
`Access denied: plan path (malicious.md) must be within the designated plans directory (${mockPlansDir}).`,
);
});
+19 -16
View File
@@ -19,9 +19,12 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import path from 'node:path';
import type { Config } from '../config/config.js';
import { EXIT_PLAN_MODE_TOOL_NAME } from './tool-names.js';
import { validatePlanPath, validatePlanContent } from '../utils/planUtils.js';
import {
validatePlanPath,
validatePlanContent,
resolveAndValidatePlanPath,
} from '../utils/planUtils.js';
import { ApprovalMode } from '../policy/types.js';
import { resolveToRealPath, isSubpath } from '../utils/paths.js';
import { logPlanExecution } from '../telemetry/loggers.js';
import { PlanExecutionEvent } from '../telemetry/types.js';
import { getExitPlanModeDefinition } from './definitions/coreTools.js';
@@ -59,18 +62,14 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
if (!params.plan_filename || params.plan_filename.trim() === '') {
return 'plan_filename is required.';
}
const safeFilename = path.basename(params.plan_filename);
const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
const resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
const realPath = resolveToRealPath(resolvedPath);
if (!isSubpath(plansDir, realPath)) {
return `Access denied: plan path (${resolvedPath}) must be within the designated plans directory (${plansDir}).`;
try {
resolveAndValidatePlanPath(
params.plan_filename,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
} catch (e) {
return e instanceof Error ? e.message : String(e);
}
return null;
@@ -122,6 +121,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
const pathError = await validatePlanPath(
this.params.plan_filename,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
if (pathError) {
this.planValidationError = pathError;
@@ -179,8 +179,11 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
* Note: Validation is done in validateToolParamValues, so this assumes the path is valid.
*/
private getResolvedPlanPath(): string {
const safeFilename = path.basename(this.params.plan_filename);
return path.join(this.config.storage.getPlansDir(), safeFilename);
return resolveAndValidatePlanPath(
this.params.plan_filename,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
}
async execute({ abortSignal: _signal }: ExecuteOptions): Promise<ToolResult> {
+59 -33
View File
@@ -96,6 +96,7 @@ describe('ShellTool', () => {
let mockShellOutputCallback: (event: ShellOutputEvent) => void;
let resolveExecutionPromise: (result: ShellExecutionResult) => void;
let tempRootDir: string;
let extractedTmpFile: string;
beforeEach(() => {
vi.clearAllMocks();
@@ -197,16 +198,28 @@ describe('ShellTool', () => {
process.env['ComSpec'] =
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
extractedTmpFile = '';
// Capture the output callback to simulate streaming events from the service
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
mockShellOutputCallback = callback;
return {
pid: 12345,
result: new Promise((resolve) => {
resolveExecutionPromise = resolve;
}),
};
});
mockShellExecutionService.mockImplementation(
(
cmd: string,
_cwd: string,
callback: (event: ShellOutputEvent) => void,
) => {
mockShellOutputCallback = callback;
const match = cmd.match(/pgrep -g 0 >([^ ]+)/);
if (match) {
extractedTmpFile = match[1].replace(/['"]/g, ''); // remove any quotes if present
}
return {
pid: 12345,
result: new Promise((resolve) => {
resolveExecutionPromise = resolve;
}),
};
},
);
mockShellBackground.mockImplementation(() => {
resolveExecutionPromise({
@@ -293,17 +306,16 @@ describe('ShellTool', () => {
it('should wrap command on linux and parse pgrep output', async () => {
const invocation = shellTool.build({ command: 'my-command &' });
const promise = invocation.execute({ abortSignal: mockAbortSignal });
resolveShellExecution({ pid: 54321 });
// Simulate pgrep output file creation by the shell command
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
fs.writeFileSync(tmpFile, `54321${os.EOL}54322${os.EOL}`);
fs.writeFileSync(extractedTmpFile, `54321${os.EOL}54322${os.EOL}`);
resolveShellExecution({ pid: 54321 });
const result = await promise;
const wrappedCommand = `(\n${'my-command &'}\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
expect.stringMatching(/pgrep -g 0 >.*gemini-shell-.*[/\\]pgrep\.tmp/),
tempRootDir,
expect.any(Function),
expect.any(AbortSignal),
@@ -316,7 +328,7 @@ describe('ShellTool', () => {
);
expect(result.llmContent).toContain('Background PIDs: 54322');
// The file should be deleted by the tool
expect(fs.existsSync(tmpFile)).toBe(false);
expect(fs.existsSync(extractedTmpFile)).toBe(false);
});
it('should add a space when command ends with a backslash to prevent escaping newline', async () => {
@@ -325,10 +337,8 @@ describe('ShellTool', () => {
resolveShellExecution();
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `(\nls\\ \n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
expect.stringMatching(/pgrep -g 0 >.*gemini-shell-.*[/\\]pgrep\.tmp/),
tempRootDir,
expect.any(Function),
expect.any(AbortSignal),
@@ -343,10 +353,8 @@ describe('ShellTool', () => {
resolveShellExecution();
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `(\nls # comment\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
expect.stringMatching(/pgrep -g 0 >.*gemini-shell-.*[/\\]pgrep\.tmp/),
tempRootDir,
expect.any(Function),
expect.any(AbortSignal),
@@ -365,10 +373,8 @@ describe('ShellTool', () => {
resolveShellExecution();
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `(\n${'ls'}\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
expect.stringMatching(/pgrep -g 0 >.*gemini-shell-.*[/\\]pgrep\.tmp/),
subdir,
expect.any(Function),
expect.any(AbortSignal),
@@ -390,10 +396,8 @@ describe('ShellTool', () => {
resolveShellExecution();
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `(\n${'ls'}\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
expect.stringMatching(/pgrep -g 0 >.*gemini-shell-.*[/\\]pgrep\.tmp/),
path.join(tempRootDir, 'subdir'),
expect.any(Function),
expect.any(AbortSignal),
@@ -462,6 +466,26 @@ describe('ShellTool', () => {
20000,
);
it('should correctly wrap heredoc commands', async () => {
const command = `cat << 'EOF'
hello world
EOF`;
const invocation = shellTool.build({ command });
const promise = invocation.execute({ abortSignal: mockAbortSignal });
resolveShellExecution();
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringMatching(/pgrep -g 0 >.*gemini-shell-.*[/\\]pgrep\.tmp/),
tempRootDir,
expect.any(Function),
expect.any(AbortSignal),
false,
expect.any(Object),
);
expect(mockShellExecutionService.mock.calls[0][0]).toMatch(/\nEOF\n\)\n/);
});
it('should format error messages correctly', async () => {
const error = new Error('wrapped command failed');
const invocation = shellTool.build({ command: 'user-command' });
@@ -562,10 +586,13 @@ describe('ShellTool', () => {
it('should clean up the temp file on synchronous execution error', async () => {
const error = new Error('sync spawn error');
mockShellExecutionService.mockImplementation(() => {
// Create the temp file before throwing to simulate it being left behind
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
fs.writeFileSync(tmpFile, '');
mockShellExecutionService.mockImplementation((cmd: string) => {
const match = cmd.match(/pgrep -g 0 >([^ ]+)/);
if (match) {
extractedTmpFile = match[1].replace(/['"]/g, ''); // remove any quotes if present
// Create the temp file before throwing to simulate it being left behind
fs.writeFileSync(extractedTmpFile, '');
}
throw error;
});
@@ -574,8 +601,7 @@ describe('ShellTool', () => {
invocation.execute({ abortSignal: mockAbortSignal }),
).rejects.toThrow(error);
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
expect(fs.existsSync(tmpFile)).toBe(false);
expect(fs.existsSync(extractedTmpFile)).toBe(false);
});
it('should not log "missing pgrep output" when process is backgrounded', async () => {
+25 -12
View File
@@ -8,7 +8,6 @@ import fsPromises from 'node:fs/promises';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { debugLogger } from '../index.js';
import { type SandboxPermissions } from '../services/sandboxManager.js';
import { ToolErrorType } from './tool-error.js';
@@ -42,6 +41,7 @@ import {
parseCommandDetails,
hasRedirection,
normalizeCommand,
escapeShellArg,
} from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME } from './tool-names.js';
import { PARAM_ADDITIONAL_PERMISSIONS } from './definitions/base-declarations.js';
@@ -111,7 +111,8 @@ export class ShellToolInvocation extends BaseToolInvocation<
if (trimmed.endsWith('\\')) {
trimmed += ' ';
}
return `(\n${trimmed}\n); __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
const escapedTempFilePath = escapeShellArg(tempFilePath, 'bash');
return `(\n${trimmed}\n)\n__code=$?; pgrep -g 0 >${escapedTempFilePath} 2>&1; exit $__code;`;
}
private getContextualDetails(): string {
@@ -450,10 +451,8 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
const isWindows = os.platform() === 'win32';
const tempFileName = `shell_pgrep_${crypto
.randomBytes(6)
.toString('hex')}.tmp`;
const tempFilePath = path.join(os.tmpdir(), tempFileName);
let tempFilePath = '';
let tempDir = '';
const timeoutMs = this.context.config.getShellToolInactivityTimeout();
const timeoutController = new AbortController();
@@ -463,8 +462,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
const combinedController = new AbortController();
const onAbort = () => combinedController.abort();
try {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-shell-'));
tempFilePath = path.join(tempDir, 'pgrep.tmp');
// pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = this.wrapCommandForPgrep(
strippedCommand,
@@ -638,7 +639,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
if (tempFileExists) {
const pgrepContent = await fsPromises.readFile(tempFilePath, 'utf8');
const pgrepLines = pgrepContent.split(os.EOL).filter(Boolean);
const pgrepLines = pgrepContent
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (const line of pgrepLines) {
if (!/^\d+$/.test(line)) {
if (
@@ -935,10 +939,19 @@ export class ShellToolInvocation extends BaseToolInvocation<
if (timeoutTimer) clearTimeout(timeoutTimer);
signal.removeEventListener('abort', onAbort);
timeoutController.signal.removeEventListener('abort', onAbort);
try {
await fsPromises.unlink(tempFilePath);
} catch {
// Ignore errors during unlink
if (tempFilePath) {
try {
await fsPromises.unlink(tempFilePath);
} catch {
// Ignore errors during unlink
}
}
if (tempDir) {
try {
await fsPromises.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore errors during rm
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More