mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 16:20:57 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90fda1400e | |||
| 4ebc43bc66 | |||
| 34b4f1c6e4 | |||
| e77b22e638 | |||
| 1b3e7d674f | |||
| e7f8d9cf1a |
@@ -28,7 +28,6 @@ This skill guides the agent in conducting professional and thorough code reviews
|
||||
npm run preflight
|
||||
```
|
||||
3. **Context**: Read the PR description and any existing comments to understand the goal and history.
|
||||
4. **Local Verification**: If reviewing a fix or feature for the Gemini CLI itself, activate the `debug-cli` skill to build and interactively test the changes locally using `tmux`.
|
||||
|
||||
#### For Local Changes:
|
||||
1. **Identify Changes**:
|
||||
@@ -46,7 +45,6 @@ Analyze the code changes based on the following pillars:
|
||||
* **Security**: Are there any potential security vulnerabilities or insecure coding practices?
|
||||
* **Edge Cases and Error Handling**: Does the code appropriately handle edge cases and potential errors?
|
||||
* **Testability**: Is the new or modified code adequately covered by tests (even if preflight checks pass)? Suggest additional test cases that would improve coverage or robustness.
|
||||
* **Interactive Verification**: If the changes affect the Gemini CLI behavior or TUI, use the `debug-cli` skill to manually verify the UI/UX or behavioral changes.
|
||||
|
||||
### 4. Provide Feedback
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
name: debug-cli
|
||||
description: Instructions for running and debugging the Gemini CLI locally from source. Use this when asked to diagnose issues or run the gemini cli application.
|
||||
---
|
||||
|
||||
# Debug CLI
|
||||
|
||||
This skill provides instructions for the agent to run the Gemini CLI locally built from source to diagnose issues. **Always reproduce the issue interactively first** — before analyzing source code, running unit tests, or attempting fixes.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Understand the Issue
|
||||
|
||||
- **Gather Context:** Fetch and read the GitHub issue description (if applicable) or the user's report. Identify the delta between expected and observed behavior.
|
||||
- **Consult Documentation:** Use the `cli_help` subagent to clarify intended CLI behavior, command usage, or configuration schemas. For example: `@cli_help "how does proxy configuration work?"`.
|
||||
- **Deep Dive:** If the documentation isn't enough, use the `codebase_investigator` subagent to analyze the underlying logic, identify relevant files, and map dependencies related to the issue.
|
||||
- **Live Exploration:** Depending on the exact problem you're debugging, you might have to play with the `/settings` command or other application commands in a live session.
|
||||
- **Smart Agent Advantage:** Since the Gemini CLI is a smart agent, you can always ask the CLI itself for help figuring out how it works or how to use a specific feature!
|
||||
|
||||
### 2. Build from Source
|
||||
|
||||
Always start by cleaning, building, and bundling the source code to ensure you are running the latest version:
|
||||
|
||||
```bash
|
||||
npm run clean && npm run build && npm run bundle
|
||||
```
|
||||
|
||||
The application can then be started via `./bundle/gemini.js`.
|
||||
|
||||
### 3. Setup Test Directory
|
||||
|
||||
Before running the CLI, create a temporary test directory and configure it with the appropriate files. This ensures the CLI runs with the correct settings for the feature being tested.
|
||||
|
||||
```bash
|
||||
mkdir -p /path/to/test-dir/.gemini
|
||||
```
|
||||
|
||||
Create a `.gemini/settings.json` in that directory with any settings needed for the test. For example, to allowlist domains for the browser agent:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"browser": {
|
||||
"allowedDomains": ["*.example.com", "example.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You may also need `.gemini/keybindings.json`, `GEMINI.md`, and any test project files depending on what you're testing.
|
||||
|
||||
### 4. Launch with tmux
|
||||
|
||||
Always run the Gemini CLI application using `tmux`. This is essential because the application is a complex TUI (Terminal User Interface). `tmux` commands make it easy to read the screen state, send programmatic inputs, and interact with the TUI in a robust way.
|
||||
|
||||
Launch the CLI from within the test directory so it picks up the local settings:
|
||||
|
||||
```bash
|
||||
cd /path/to/test-dir
|
||||
/absolute/path/to/bundle/gemini.js [flags]
|
||||
```
|
||||
|
||||
### 5. Interact and Reproduce
|
||||
|
||||
Use `tmux send-keys` to send text and keypresses, and `tmux capture-pane` to read the screen.
|
||||
|
||||
#### Submitting Prompts (Paste-Injection Protection)
|
||||
|
||||
The CLI has paste-injection protection. You **must** split text entry and the Enter keypress into separate steps:
|
||||
|
||||
1. **Send the prompt text** (without pressing Enter).
|
||||
2. **Wait briefly** (~1 second) to let the CLI process the pasted text.
|
||||
3. **Send only the Enter key** to submit.
|
||||
|
||||
Sending text and Enter together in one command will be rejected by the paste-injection guard.
|
||||
|
||||
#### Authentication
|
||||
|
||||
If the app presents an authentication screen:
|
||||
- Select the option to authenticate with a **Gemini API key**.
|
||||
- The key should be prepopulated automatically. Otherwise if a `.env` file is available copy it inside the test directory.
|
||||
|
||||
### 6. Document Results
|
||||
|
||||
- Record observed vs. expected behavior.
|
||||
- Note any error messages or visual glitches.
|
||||
|
||||
### 7. Fix and Verify (if applicable)
|
||||
|
||||
- Only after confirming the repro, investigate source code and implement a fix.
|
||||
- Rebuild (`npm run build && npm run bundle`), re-launch, and verify through the interactive flow (Steps 4–5).
|
||||
|
||||
### 8. Cleanup
|
||||
|
||||
- Kill remaining background processes (tmux sessions, etc.).
|
||||
- Remove the tmp test directory if no longer needed.
|
||||
@@ -0,0 +1,33 @@
|
||||
name: 'Memory Tests: Nightly'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # Runs at 2 AM every day
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
|
||||
jobs:
|
||||
memory-test:
|
||||
name: 'Run Memory Usage Tests'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Build project'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'Run Memory Tests'
|
||||
run: 'npm run test:memory'
|
||||
@@ -44,6 +44,8 @@ powerful tool for developers.
|
||||
- **Test Commands:**
|
||||
- **Unit (All):** `npm run test`
|
||||
- **Integration (E2E):** `npm run test:e2e`
|
||||
- **Memory (Nightly):** `npm run test:memory` (Runs memory regression tests
|
||||
against baselines. Excluded from `preflight`, run nightly.)
|
||||
- **Workspace-Specific:** `npm test -w <pkg> -- <path>` (Note: `<path>` must
|
||||
be relative to the workspace root, e.g.,
|
||||
`-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`)
|
||||
|
||||
@@ -117,6 +117,46 @@ npm run test:integration:sandbox:docker
|
||||
npm run test:integration:sandbox:podman
|
||||
```
|
||||
|
||||
## Memory regression tests
|
||||
|
||||
Memory regression tests are designed to detect heap growth and leaks across key
|
||||
CLI scenarios. They are located in the `memory-tests` directory.
|
||||
|
||||
These tests are distinct from standard integration tests because they measure
|
||||
memory usage and compare it against committed baselines.
|
||||
|
||||
### Running memory tests
|
||||
|
||||
Memory tests are not run as part of the default `npm run test` or
|
||||
`npm run test:e2e` commands. They are run nightly in CI but can be run manually:
|
||||
|
||||
```bash
|
||||
npm run test:memory
|
||||
```
|
||||
|
||||
### Updating baselines
|
||||
|
||||
If you intentionally change behavior that affects memory usage, you may need to
|
||||
update the baselines. Set the `UPDATE_MEMORY_BASELINES` environment variable to
|
||||
`true`:
|
||||
|
||||
```bash
|
||||
UPDATE_MEMORY_BASELINES=true npm run test:memory
|
||||
```
|
||||
|
||||
This will run the tests, take median snapshots, and overwrite
|
||||
`memory-tests/baselines.json`. You should review the changes and commit the
|
||||
updated baseline file.
|
||||
|
||||
### How it works
|
||||
|
||||
The harness (`MemoryTestHarness` in `packages/test-utils`):
|
||||
|
||||
- Forces garbage collection multiple times to reduce noise.
|
||||
- Takes median snapshots to filter spikes.
|
||||
- Compares against baselines with a 10% tolerance.
|
||||
- Can analyze sustained leaks across 3 snapshots using `analyzeSnapshots()`.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
The integration test runner provides several options for diagnostics to help
|
||||
|
||||
@@ -290,7 +290,7 @@ When connecting to an OAuth-enabled server:
|
||||
> OAuth authentication requires that your local machine can:
|
||||
>
|
||||
> - Open a web browser for authentication
|
||||
> - Receive redirects on `http://localhost:7777/oauth/callback`
|
||||
> - Receive redirects on `http://localhost:<random-port>/oauth/callback` (or a specific port if configured via `redirectUri`)
|
||||
|
||||
This feature will not work in:
|
||||
|
||||
@@ -323,8 +323,8 @@ Use the `/mcp auth` command to manage OAuth authentication:
|
||||
if omitted)
|
||||
- **`tokenUrl`** (string): OAuth token endpoint (auto-discovered if omitted)
|
||||
- **`scopes`** (string[]): Required OAuth scopes
|
||||
- **`redirectUri`** (string): Custom redirect URI (defaults to
|
||||
`http://localhost:7777/oauth/callback`)
|
||||
- **`redirectUri`** (string): Custom redirect URI (defaults to an OS-assigned
|
||||
random port, e.g., `http://localhost:<random-port>/oauth/callback`)
|
||||
- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs
|
||||
- **`audiences`** (string[]): Audiences the token is valid for
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll launch two browser agents concurrently to check both repositories."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and get the page title"}}},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":15,"totalTokenCount":165}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":15,"totalTokenCount":165}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Page title is Example Domain."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Page title is Example Domain."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Both browser agents completed successfully. Agent 1 and Agent 2 both navigated to their respective pages and confirmed the page titles."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":40,"totalTokenCount":340}}]}
|
||||
@@ -307,4 +307,48 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => {
|
||||
|
||||
await run.expectText('successfully written', 15000);
|
||||
});
|
||||
|
||||
it('should handle concurrent browser agents with isolated session mode', async () => {
|
||||
rig.setup('browser-concurrent', {
|
||||
fakeResponsesPath: join(__dirname, 'browser-agent.concurrent.responses'),
|
||||
settings: {
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: true,
|
||||
// Isolated mode supports concurrent browser agents.
|
||||
// Persistent/existing modes reject concurrent calls to prevent
|
||||
// Chrome profile lock conflicts.
|
||||
sessionMode: 'isolated',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await rig.run({
|
||||
args: 'Launch two browser agents concurrently to check example.com',
|
||||
});
|
||||
|
||||
assertModelHasOutput(result);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const browserCalls = toolLogs.filter(
|
||||
(t) => t.toolRequest.name === 'browser_agent',
|
||||
);
|
||||
|
||||
// Both browser_agent invocations should have been called
|
||||
expect(browserCalls.length).toBe(2);
|
||||
|
||||
// Both should complete successfully (no errors)
|
||||
for (const call of browserCalls) {
|
||||
expect(
|
||||
call.toolRequest.success,
|
||||
`browser_agent call failed: ${JSON.stringify(call.toolRequest)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-04-08T01:21:58.770Z",
|
||||
"scenarios": {
|
||||
"multi-turn-conversation": {
|
||||
"heapUsedBytes": 120082704,
|
||||
"heapTotalBytes": 177586176,
|
||||
"rssBytes": 269172736,
|
||||
"timestamp": "2026-04-08T01:21:57.127Z"
|
||||
},
|
||||
"multi-function-call-repo-search": {
|
||||
"heapUsedBytes": 104644984,
|
||||
"heapTotalBytes": 111575040,
|
||||
"rssBytes": 204079104,
|
||||
"timestamp": "2026-04-08T01:21:58.770Z"
|
||||
},
|
||||
"idle-session-startup": {
|
||||
"heapUsedBytes": 119813672,
|
||||
"heapTotalBytes": 177061888,
|
||||
"rssBytes": 267943936,
|
||||
"timestamp": "2026-04-08T01:21:53.855Z"
|
||||
},
|
||||
"simple-prompt-response": {
|
||||
"heapUsedBytes": 119722064,
|
||||
"heapTotalBytes": 177324032,
|
||||
"rssBytes": 268812288,
|
||||
"timestamp": "2026-04-08T01:21:55.491Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
const memoryTestsDir = join(rootDir, '.memory-tests');
|
||||
let runDir = '';
|
||||
|
||||
export async function setup() {
|
||||
runDir = join(memoryTestsDir, `${Date.now()}`);
|
||||
await mkdir(runDir, { recursive: true });
|
||||
|
||||
// Set the home directory to the test run directory to avoid conflicts
|
||||
// with the user's local config.
|
||||
process.env['HOME'] = runDir;
|
||||
if (process.platform === 'win32') {
|
||||
process.env['USERPROFILE'] = runDir;
|
||||
}
|
||||
process.env['GEMINI_CONFIG_DIR'] = join(runDir, '.gemini');
|
||||
|
||||
// Download ripgrep to avoid race conditions
|
||||
const available = await canUseRipgrep();
|
||||
if (!available) {
|
||||
throw new Error('Failed to download ripgrep binary');
|
||||
}
|
||||
|
||||
// Clean up old test runs, keeping the latest few for debugging
|
||||
try {
|
||||
const testRuns = await readdir(memoryTestsDir);
|
||||
if (testRuns.length > 3) {
|
||||
const oldRuns = testRuns.sort().slice(0, testRuns.length - 3);
|
||||
await Promise.all(
|
||||
oldRuns.map((oldRun) =>
|
||||
rm(join(memoryTestsDir, oldRun), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error cleaning up old memory test runs:', e);
|
||||
}
|
||||
|
||||
process.env['INTEGRATION_TEST_FILE_DIR'] = runDir;
|
||||
process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true';
|
||||
process.env['GEMINI_FORCE_FILE_STORAGE'] = 'true';
|
||||
process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log');
|
||||
process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false';
|
||||
|
||||
console.log(`\nMemory test output directory: ${runDir}`);
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
// Cleanup unless KEEP_OUTPUT is set
|
||||
if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) {
|
||||
try {
|
||||
await rm(runDir, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.warn('Failed to clean up memory test directory:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { TestRig, MemoryTestHarness } from '@google/gemini-cli-test-utils';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINES_PATH = join(__dirname, 'baselines.json');
|
||||
const UPDATE_BASELINES = process.env['UPDATE_MEMORY_BASELINES'] === 'true';
|
||||
const TOLERANCE_PERCENT = 10;
|
||||
|
||||
// Fake API key for tests using fake responses
|
||||
const TEST_ENV = { GEMINI_API_KEY: 'fake-memory-test-key' };
|
||||
|
||||
describe('Memory Usage Tests', () => {
|
||||
let harness: MemoryTestHarness;
|
||||
let rig: TestRig;
|
||||
|
||||
beforeAll(() => {
|
||||
harness = new MemoryTestHarness({
|
||||
baselinesPath: BASELINES_PATH,
|
||||
defaultTolerancePercent: TOLERANCE_PERCENT,
|
||||
gcCycles: 3,
|
||||
gcDelayMs: 100,
|
||||
sampleCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Generate the summary report after all tests
|
||||
await harness.generateReport();
|
||||
});
|
||||
|
||||
it('idle-session-startup: memory usage within baseline', async () => {
|
||||
rig = new TestRig();
|
||||
rig.setup('memory-idle-startup', {
|
||||
fakeResponsesPath: join(__dirname, 'memory.idle-startup.responses'),
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
'idle-session-startup',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
args: ['hello'],
|
||||
timeout: 120000,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
await recordSnapshot('after-startup');
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
console.log(
|
||||
`Updated baseline for idle-session-startup: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('simple-prompt-response: memory usage within baseline', async () => {
|
||||
rig = new TestRig();
|
||||
rig.setup('memory-simple-prompt', {
|
||||
fakeResponsesPath: join(__dirname, 'memory.simple-prompt.responses'),
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
'simple-prompt-response',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
args: ['What is the capital of France?'],
|
||||
timeout: 120000,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
await recordSnapshot('after-response');
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
console.log(
|
||||
`Updated baseline for simple-prompt-response: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('multi-turn-conversation: memory remains stable over turns', async () => {
|
||||
rig = new TestRig();
|
||||
rig.setup('memory-multi-turn', {
|
||||
fakeResponsesPath: join(__dirname, 'memory.multi-turn.responses'),
|
||||
});
|
||||
|
||||
const prompts = [
|
||||
'Hello, what can you help me with?',
|
||||
'Tell me about JavaScript',
|
||||
'How is TypeScript different?',
|
||||
'Can you write a simple TypeScript function?',
|
||||
'What are some TypeScript best practices?',
|
||||
];
|
||||
|
||||
const result = await harness.runScenario(
|
||||
'multi-turn-conversation',
|
||||
async (recordSnapshot) => {
|
||||
// Run through all turns as a piped sequence
|
||||
const stdinContent = prompts.join('\n');
|
||||
await rig.run({
|
||||
stdin: stdinContent,
|
||||
timeout: 120000,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
// Take snapshots after the conversation completes
|
||||
await recordSnapshot('after-all-turns');
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
console.log(
|
||||
`Updated baseline for multi-turn-conversation: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('multi-function-call-repo-search: memory after tool use', async () => {
|
||||
rig = new TestRig();
|
||||
rig.setup('memory-multi-func-call', {
|
||||
fakeResponsesPath: join(
|
||||
__dirname,
|
||||
'memory.multi-function-call.responses',
|
||||
),
|
||||
});
|
||||
|
||||
// Create directories first, then files in the workspace so the tools have targets
|
||||
rig.mkdir('packages/core/src/telemetry');
|
||||
rig.createFile(
|
||||
'packages/core/src/telemetry/memory-monitor.ts',
|
||||
'export class MemoryMonitor { constructor() {} }',
|
||||
);
|
||||
rig.createFile(
|
||||
'packages/core/src/telemetry/metrics.ts',
|
||||
'export function recordMemoryUsage() {}',
|
||||
);
|
||||
|
||||
const result = await harness.runScenario(
|
||||
'multi-function-call-repo-search',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
args: [
|
||||
'Search this repository for MemoryMonitor and tell me what it does',
|
||||
],
|
||||
timeout: 120000,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
await recordSnapshot('after-tool-calls');
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
console.log(
|
||||
`Updated baseline for multi-function-call-repo-search: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to help. What would you like to work on?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":12,"totalTokenCount":17,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll search for MemoryMonitor in the repository and analyze what it does."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":30,"candidatesTokenCount":15,"totalTokenCount":45,"promptTokensDetails":[{"modality":"TEXT","tokenCount":30}]}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"grep_search","args":{"pattern":"MemoryMonitor","path":".","include_pattern":"*.ts"}}},{"functionCall":{"name":"list_directory","args":{"path":"packages/core/src/telemetry"}}},{"functionCall":{"name":"read_file","args":{"file_path":"packages/core/src/telemetry/memory-monitor.ts"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":30,"candidatesTokenCount":80,"totalTokenCount":110,"promptTokensDetails":[{"modality":"TEXT","tokenCount":30}]}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I found the memory monitoring code. Here's a summary:\n\nThe `MemoryMonitor` class in `packages/core/src/telemetry/memory-monitor.ts` provides:\n\n1. **Continuous monitoring** via `start()`/`stop()` with configurable intervals\n2. **V8 heap snapshots** using `v8.getHeapStatistics()` and `process.memoryUsage()`\n3. **High-water mark tracking** to detect significant memory growth\n4. **Rate-limited recording** to avoid metric flood\n5. **Activity detection** — only records when user is active\n\nThe class uses a singleton pattern via `initializeMemoryMonitor()` for global access."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":500,"candidatesTokenCount":120,"totalTokenCount":620,"promptTokensDetails":[{"modality":"TEXT","tokenCount":500}]}}]}
|
||||
@@ -0,0 +1,10 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to help you with your coding tasks. What would you like to work on today?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":18,"totalTokenCount":23,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"JavaScript is a high-level, interpreted programming language. It was originally designed for adding interactivity to web pages."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":25,"candidatesTokenCount":60,"totalTokenCount":85,"promptTokensDetails":[{"modality":"TEXT","tokenCount":25}]}}]}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"TypeScript is a typed superset of JavaScript developed by Microsoft. The main differences from JavaScript are static typing and better tooling."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":45,"candidatesTokenCount":80,"totalTokenCount":125,"promptTokensDetails":[{"modality":"TEXT","tokenCount":45}]}}]}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here is a simple TypeScript function:\n\nfunction greet(name: string): string { return `Hello, ${name}!`; }"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":60,"candidatesTokenCount":55,"totalTokenCount":115,"promptTokensDetails":[{"modality":"TEXT","tokenCount":60}]}}]}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are 5 key TypeScript best practices: Enable strict mode, prefer interfaces, use union types, leverage type inference, and use readonly."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":75,"candidatesTokenCount":70,"totalTokenCount":145,"promptTokensDetails":[{"modality":"TEXT","tokenCount":75}]}}]}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The capital of France is Paris. It has been the capital since the 10th century and is known for iconic landmarks like the Eiffel Tower, the Louvre Museum, and Notre-Dame Cathedral. Paris is also the most populous city in France, with a metropolitan area population of over 12 million people."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":55,"totalTokenCount":62,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../packages/core" },
|
||||
{ "path": "../packages/test-utils" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
testTimeout: 600000, // 10 minutes — memory profiling is slow
|
||||
globalSetup: './globalSetup.ts',
|
||||
reporters: ['default'],
|
||||
include: ['**/*.test.ts'],
|
||||
retry: 0, // No retries for memory tests — noise is handled by tolerance
|
||||
fileParallelism: false, // Must run serially to avoid memory interference
|
||||
pool: 'forks', // Use forks pool for --expose-gc support
|
||||
poolOptions: {
|
||||
forks: {
|
||||
singleFork: true, // Single process for accurate per-test memory readings
|
||||
execArgv: ['--expose-gc'], // Enable global.gc() for forced GC
|
||||
},
|
||||
},
|
||||
env: {
|
||||
GEMINI_TEST_TYPE: 'memory',
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+38
-3
@@ -446,7 +446,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",
|
||||
@@ -1449,6 +1450,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"
|
||||
@@ -2155,6 +2157,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",
|
||||
@@ -2335,6 +2338,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"
|
||||
}
|
||||
@@ -2384,6 +2388,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"
|
||||
},
|
||||
@@ -2758,6 +2763,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"
|
||||
@@ -2791,6 +2797,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"
|
||||
@@ -2845,6 +2852,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",
|
||||
@@ -4081,6 +4089,7 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4355,6 +4364,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",
|
||||
@@ -5228,6 +5238,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"
|
||||
},
|
||||
@@ -5569,6 +5580,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asciichart": {
|
||||
"version": "1.5.25",
|
||||
"resolved": "https://registry.npmjs.org/asciichart/-/asciichart-1.5.25.tgz",
|
||||
"integrity": "sha512-PNxzXIPPOtWq8T7bgzBtk9cI2lgS4SJZthUHEiQ1aoIc3lNzGfUvIvo9LiAnq26TACo9t1/4qP6KTGAUbzX9Xg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -7362,7 +7379,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",
|
||||
@@ -7946,6 +7964,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8463,6 +8482,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",
|
||||
@@ -9775,6 +9795,7 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -10053,6 +10074,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.7.tgz",
|
||||
"integrity": "sha512-bDzQLpLzK/dn9Ur/Ku88ZZR9totVcMGrGYAgPHidsAAbe9NKztU1fggj/iu0wRp5g1kBeALb3cfagFGdDxAU1w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"ansi-styles": "^6.2.3",
|
||||
@@ -13826,6 +13848,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"
|
||||
}
|
||||
@@ -13836,6 +13859,7 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -15985,6 +16009,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16207,7 +16232,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",
|
||||
@@ -16215,6 +16241,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"
|
||||
@@ -16380,6 +16407,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16602,6 +16630,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16715,6 +16744,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16727,6 +16757,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",
|
||||
@@ -17374,6 +17405,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"
|
||||
}
|
||||
@@ -17817,6 +17849,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"
|
||||
@@ -17920,6 +17953,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17979,6 +18013,7 @@
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"asciichart": "^1.5.25",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
"test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman",
|
||||
"test:integration:flaky": "cross-env RUN_FLAKY_INTEGRATION=1 npm run test:integration:sandbox:none",
|
||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:memory": "vitest run --root ./memory-tests",
|
||||
"test:memory:update-baselines": "cross-env UPDATE_MEMORY_BASELINES=true vitest run --root ./memory-tests",
|
||||
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --cache --max-warnings 0",
|
||||
|
||||
@@ -1009,7 +1009,6 @@ export async function loadCliConfig(
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
shellBackgroundCompletionBehavior: settings.tools?.shell
|
||||
?.backgroundCompletionBehavior as string | undefined,
|
||||
interactiveShellMode: settings.tools?.shell?.interactiveShellMode,
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
enableShellOutputEfficiency:
|
||||
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
||||
|
||||
@@ -520,8 +520,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
const readOnlyToolRule = rules.find(
|
||||
(r) => r.toolName === 'glob' && !r.subagent,
|
||||
);
|
||||
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
|
||||
expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5);
|
||||
// Priority 50 in default tier → 1.05 (Overriding Plan Mode Deny)
|
||||
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
|
||||
|
||||
// Verify the engine applies these priorities correctly
|
||||
expect(
|
||||
@@ -677,8 +677,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier)
|
||||
|
||||
const globRule = rules.find((r) => r.toolName === 'glob' && !r.subagent);
|
||||
// Priority 70 in default tier → 1.07
|
||||
expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only
|
||||
// Priority 50 in default tier → 1.05
|
||||
expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only
|
||||
|
||||
// The PolicyEngine will sort these by priority when it's created
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
@@ -1512,26 +1512,6 @@ const SETTINGS_SCHEMA = {
|
||||
{ label: 'Notify', value: 'notify' },
|
||||
],
|
||||
},
|
||||
interactiveShellMode: {
|
||||
type: 'enum',
|
||||
label: 'Interactive Shell Mode',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as 'human' | 'ai' | 'off' | undefined,
|
||||
description: oneLine`
|
||||
Controls who can interact with backgrounded shell processes.
|
||||
"human": user can Tab-focus and type into shells (default).
|
||||
"ai": model gets write_to_shell/read_shell tools for TUI interaction.
|
||||
"off": no interactive shell.
|
||||
When set, overrides enableInteractiveShell.
|
||||
`,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'human', label: 'Human (Tab to focus)' },
|
||||
{ value: 'ai', label: 'AI (model-driven tools)' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
],
|
||||
},
|
||||
pager: {
|
||||
type: 'string',
|
||||
label: 'Pager',
|
||||
|
||||
@@ -92,23 +92,7 @@ export function shellReducer(
|
||||
nextTasks.delete(action.pid);
|
||||
}
|
||||
nextTasks.set(action.pid, updatedTask);
|
||||
|
||||
// Auto-hide panel when all tasks have exited
|
||||
let nextVisible = state.isBackgroundTaskVisible;
|
||||
if (action.update.status === 'exited') {
|
||||
const hasRunning = Array.from(nextTasks.values()).some(
|
||||
(s) => s.status === 'running',
|
||||
);
|
||||
if (!hasRunning) {
|
||||
nextVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
backgroundTasks: nextTasks,
|
||||
isBackgroundTaskVisible: nextVisible,
|
||||
};
|
||||
return { ...state, backgroundTasks: nextTasks };
|
||||
}
|
||||
case 'APPEND_TASK_OUTPUT': {
|
||||
const task = state.backgroundTasks.get(action.pid);
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { type BackgroundTask } from './shellReducer.js';
|
||||
|
||||
export interface BackgroundShellManagerProps {
|
||||
backgroundTasks: Map<number, BackgroundTask>;
|
||||
backgroundTaskCount: number;
|
||||
isBackgroundTaskVisible: boolean;
|
||||
activePtyId: number | null | undefined;
|
||||
embeddedShellFocused: boolean;
|
||||
setEmbeddedShellFocused: (focused: boolean) => void;
|
||||
terminalHeight: number;
|
||||
}
|
||||
|
||||
export function useBackgroundShellManager({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
}: BackgroundShellManagerProps) {
|
||||
const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] =
|
||||
useState(false);
|
||||
const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const prevShellCountRef = useRef(backgroundTaskCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (backgroundTasks.size === 0) {
|
||||
if (activeBackgroundShellPid !== null) {
|
||||
setActiveBackgroundShellPid(null);
|
||||
}
|
||||
if (isBackgroundShellListOpen) {
|
||||
setIsBackgroundShellListOpen(false);
|
||||
}
|
||||
} else if (
|
||||
activeBackgroundShellPid === null ||
|
||||
!backgroundTasks.has(activeBackgroundShellPid)
|
||||
) {
|
||||
// If active shell is closed or none selected, select the first one
|
||||
setActiveBackgroundShellPid(backgroundTasks.keys().next().value ?? null);
|
||||
} else if (backgroundTaskCount > prevShellCountRef.current) {
|
||||
// A new shell was added — auto-switch to the newest one (last in the map)
|
||||
const pids = Array.from(backgroundTasks.keys());
|
||||
const newestPid = pids[pids.length - 1];
|
||||
if (newestPid !== undefined && newestPid !== activeBackgroundShellPid) {
|
||||
setActiveBackgroundShellPid(newestPid);
|
||||
}
|
||||
}
|
||||
prevShellCountRef.current = backgroundTaskCount;
|
||||
}, [
|
||||
backgroundTasks,
|
||||
activeBackgroundShellPid,
|
||||
backgroundTaskCount,
|
||||
isBackgroundShellListOpen,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (embeddedShellFocused) {
|
||||
const hasActiveForegroundShell = !!activePtyId;
|
||||
const hasVisibleBackgroundShell =
|
||||
isBackgroundTaskVisible && backgroundTasks.size > 0;
|
||||
|
||||
if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isBackgroundTaskVisible,
|
||||
backgroundTasks,
|
||||
embeddedShellFocused,
|
||||
backgroundTaskCount,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
]);
|
||||
|
||||
const backgroundShellHeight = useMemo(
|
||||
() =>
|
||||
isBackgroundTaskVisible && backgroundTasks.size > 0
|
||||
? Math.max(Math.floor(terminalHeight * 0.3), 5)
|
||||
: 0,
|
||||
[isBackgroundTaskVisible, backgroundTasks.size, terminalHeight],
|
||||
);
|
||||
|
||||
return {
|
||||
isBackgroundShellListOpen,
|
||||
setIsBackgroundShellListOpen,
|
||||
activeBackgroundShellPid,
|
||||
setActiveBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
};
|
||||
}
|
||||
@@ -661,10 +661,6 @@ export const useExecutionLifecycle = (
|
||||
(s: BackgroundTask) => s.status === 'running',
|
||||
).length;
|
||||
|
||||
const showBackgroundShell = useCallback(() => {
|
||||
dispatch({ type: 'SET_VISIBILITY', visible: true });
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
handleShellCommand,
|
||||
activeShellPtyId: state.activeShellPtyId,
|
||||
@@ -672,7 +668,6 @@ export const useExecutionLifecycle = (
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible: state.isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
showBackgroundShell,
|
||||
backgroundCurrentExecution,
|
||||
registerBackgroundTask,
|
||||
dismissBackgroundTask,
|
||||
|
||||
@@ -390,7 +390,6 @@ export const useGeminiStream = (
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
showBackgroundShell,
|
||||
backgroundCurrentExecution,
|
||||
registerBackgroundTask,
|
||||
dismissBackgroundTask,
|
||||
@@ -1918,7 +1917,6 @@ export const useGeminiStream = (
|
||||
backgroundedTool.command,
|
||||
backgroundedTool.initialOutput,
|
||||
);
|
||||
showBackgroundShell();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2058,7 +2056,6 @@ export const useGeminiStream = (
|
||||
modelSwitchedFromQuotaError,
|
||||
addItem,
|
||||
registerBackgroundTask,
|
||||
showBackgroundShell,
|
||||
consumeUserHint,
|
||||
isLowErrorVerbosity,
|
||||
maybeAddSuppressedToolErrorNote,
|
||||
|
||||
@@ -38,6 +38,8 @@ const mockBrowserManager = {
|
||||
]),
|
||||
callTool: vi.fn().mockResolvedValue({ content: [] }),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
@@ -81,207 +81,218 @@ export async function createBrowserAgentDefinition(
|
||||
|
||||
// Get or create browser manager singleton for this session mode/profile
|
||||
const browserManager = BrowserManager.getInstance(config);
|
||||
await browserManager.ensureConnection();
|
||||
browserManager.acquire();
|
||||
|
||||
debugLogger.log('Browser connected with isolated MCP client.');
|
||||
try {
|
||||
await browserManager.ensureConnection();
|
||||
|
||||
// Determine if input blocker should be active (non-headless + enabled)
|
||||
const shouldDisableInput = config.shouldDisableBrowserUserInput();
|
||||
// Inject automation overlay and input blocker if not in headless mode
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
if (!browserConfig?.customConfig?.headless) {
|
||||
debugLogger.log('Injecting automation overlay...');
|
||||
await injectAutomationOverlay(browserManager);
|
||||
if (shouldDisableInput) {
|
||||
debugLogger.log('Injecting input blocker...');
|
||||
await injectInputBlocker(browserManager);
|
||||
}
|
||||
}
|
||||
debugLogger.log('Browser connected with isolated MCP client.');
|
||||
|
||||
// Create declarative tools from dynamically discovered MCP tools
|
||||
// These tools dispatch to browserManager's isolated client
|
||||
const mcpTools = await createMcpDeclarativeTools(
|
||||
browserManager,
|
||||
messageBus,
|
||||
shouldDisableInput,
|
||||
browserConfig.customConfig.blockFileUploads,
|
||||
);
|
||||
const availableToolNames = mcpTools.map((t) => t.name);
|
||||
|
||||
// Register high-priority policy rules for sensitive actions which is not
|
||||
// able to be overwrite by YOLO mode.
|
||||
const policyEngine = config.getPolicyEngine();
|
||||
|
||||
if (policyEngine) {
|
||||
const existingRules = policyEngine.getRules();
|
||||
|
||||
const restrictedTools = ['fill', 'fill_form'];
|
||||
|
||||
// ASK_USER for upload_file and evaluate_script when sensitive action
|
||||
// need confirmation.
|
||||
if (browserConfig.customConfig.confirmSensitiveActions) {
|
||||
restrictedTools.push('upload_file', 'evaluate_script');
|
||||
}
|
||||
|
||||
for (const toolName of restrictedTools) {
|
||||
const rule = generateAskUserRules(toolName);
|
||||
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
|
||||
policyEngine.addRule(rule);
|
||||
// Determine if input blocker should be active (non-headless + enabled)
|
||||
const shouldDisableInput = config.shouldDisableBrowserUserInput();
|
||||
// Inject automation overlay and input blocker if not in headless mode
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
if (!browserConfig?.customConfig?.headless) {
|
||||
debugLogger.log('Injecting automation overlay...');
|
||||
await injectAutomationOverlay(browserManager);
|
||||
if (shouldDisableInput) {
|
||||
debugLogger.log('Injecting input blocker...');
|
||||
await injectInputBlocker(browserManager);
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce noise for read-only tools in default mode
|
||||
const readOnlyTools = (await browserManager.getDiscoveredTools())
|
||||
.filter((t) => !!t.annotations?.readOnlyHint)
|
||||
.map((t) => t.name);
|
||||
const allowlistedReadonlyTools = ['take_snapshot', 'take_screenshot'];
|
||||
// Create declarative tools from dynamically discovered MCP tools
|
||||
// These tools dispatch to browserManager's isolated client
|
||||
const mcpTools = await createMcpDeclarativeTools(
|
||||
browserManager,
|
||||
messageBus,
|
||||
shouldDisableInput,
|
||||
browserConfig.customConfig.blockFileUploads,
|
||||
);
|
||||
const availableToolNames = mcpTools.map((t) => t.name);
|
||||
|
||||
for (const toolName of [...readOnlyTools, ...allowlistedReadonlyTools]) {
|
||||
if (availableToolNames.includes(toolName)) {
|
||||
const rule = generateAllowRules(toolName);
|
||||
// Register high-priority policy rules for sensitive actions which is not
|
||||
// able to be overwrite by YOLO mode.
|
||||
const policyEngine = config.getPolicyEngine();
|
||||
|
||||
if (policyEngine) {
|
||||
const existingRules = policyEngine.getRules();
|
||||
|
||||
const restrictedTools = ['fill', 'fill_form'];
|
||||
|
||||
// ASK_USER for upload_file and evaluate_script when sensitive action
|
||||
// need confirmation.
|
||||
if (browserConfig.customConfig.confirmSensitiveActions) {
|
||||
restrictedTools.push('upload_file', 'evaluate_script');
|
||||
}
|
||||
|
||||
for (const toolName of restrictedTools) {
|
||||
const rule = generateAskUserRules(toolName);
|
||||
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
|
||||
policyEngine.addRule(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateAskUserRules(toolName: string): PolicyRule {
|
||||
return {
|
||||
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: 999,
|
||||
source: 'BrowserAgent (Sensitive Actions)',
|
||||
mcpName: BROWSER_AGENT_NAME,
|
||||
// Reduce noise for read-only tools in default mode
|
||||
const readOnlyTools = (await browserManager.getDiscoveredTools())
|
||||
.filter((t) => !!t.annotations?.readOnlyHint)
|
||||
.map((t) => t.name);
|
||||
const allowlistedReadonlyTools = ['take_snapshot', 'take_screenshot'];
|
||||
|
||||
for (const toolName of [...readOnlyTools, ...allowlistedReadonlyTools]) {
|
||||
if (availableToolNames.includes(toolName)) {
|
||||
const rule = generateAllowRules(toolName);
|
||||
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
|
||||
policyEngine.addRule(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateAskUserRules(toolName: string): PolicyRule {
|
||||
return {
|
||||
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: 999,
|
||||
source: 'BrowserAgent (Sensitive Actions)',
|
||||
mcpName: BROWSER_AGENT_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
function generateAllowRules(toolName: string): PolicyRule {
|
||||
return {
|
||||
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
source: 'BrowserAgent (Read-Only)',
|
||||
mcpName: BROWSER_AGENT_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if policy rule the same in all the attributes that we care about
|
||||
function isRuleEqual(rule1: PolicyRule, rule2: PolicyRule) {
|
||||
return (
|
||||
rule1.toolName === rule2.toolName &&
|
||||
rule1.decision === rule2.decision &&
|
||||
rule1.priority === rule2.priority &&
|
||||
rule1.mcpName === rule2.mcpName
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required semantic tools are available
|
||||
const requiredSemanticTools = [
|
||||
'click',
|
||||
'fill',
|
||||
'navigate_page',
|
||||
'take_snapshot',
|
||||
];
|
||||
const missingSemanticTools = requiredSemanticTools.filter(
|
||||
(t) => !availableToolNames.includes(t),
|
||||
);
|
||||
|
||||
const rawSessionMode = browserConfig?.customConfig?.sessionMode;
|
||||
const sessionMode =
|
||||
rawSessionMode === 'isolated' || rawSessionMode === 'existing'
|
||||
? rawSessionMode
|
||||
: 'persistent';
|
||||
|
||||
recordBrowserAgentToolDiscovery(
|
||||
config,
|
||||
mcpTools.length,
|
||||
missingSemanticTools,
|
||||
sessionMode,
|
||||
);
|
||||
|
||||
if (missingSemanticTools.length > 0) {
|
||||
debugLogger.warn(
|
||||
`Semantic tools missing (${missingSemanticTools.join(', ')}). ` +
|
||||
'Some browser interactions may not work correctly.',
|
||||
);
|
||||
}
|
||||
|
||||
// Only click_at is strictly required — text input can use press_key or fill.
|
||||
const requiredVisualTools = ['click_at'];
|
||||
const missingVisualTools = requiredVisualTools.filter(
|
||||
(t) => !availableToolNames.includes(t),
|
||||
);
|
||||
|
||||
// Check whether vision can be enabled; returns structured type with code and message.
|
||||
function getVisionDisabledReason(): VisionDisabledReason {
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
if (!browserConfig.customConfig.visualModel) {
|
||||
return {
|
||||
code: 'no_visual_model',
|
||||
message: 'No visualModel configured.',
|
||||
};
|
||||
}
|
||||
if (missingVisualTools.length > 0) {
|
||||
return {
|
||||
code: 'missing_visual_tools',
|
||||
message:
|
||||
`Visual tools missing (${missingVisualTools.join(', ')}). ` +
|
||||
`The installed chrome-devtools-mcp version may be too old.`,
|
||||
};
|
||||
}
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
const blockedAuthTypes = new Set([
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
AuthType.LEGACY_CLOUD_SHELL,
|
||||
AuthType.COMPUTE_ADC,
|
||||
]);
|
||||
if (authType && blockedAuthTypes.has(authType)) {
|
||||
return {
|
||||
code: 'blocked_auth_type',
|
||||
message: 'Visual agent model not available for current auth type.',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allTools: AnyDeclarativeTool[] = [...mcpTools];
|
||||
const visionDisabledReason = getVisionDisabledReason();
|
||||
|
||||
logBrowserAgentVisionStatus(config, {
|
||||
enabled: !visionDisabledReason,
|
||||
disabled_reason: visionDisabledReason?.code,
|
||||
});
|
||||
|
||||
if (visionDisabledReason) {
|
||||
debugLogger.log(`Vision disabled: ${visionDisabledReason.message}`);
|
||||
} else {
|
||||
allTools.push(
|
||||
createAnalyzeScreenshotTool(browserManager, config, messageBus),
|
||||
);
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`Created ${allTools.length} tools for browser agent: ` +
|
||||
allTools.map((t) => t.name).join(', '),
|
||||
);
|
||||
|
||||
// Create configured definition with tools
|
||||
// BrowserAgentDefinition is a factory function - call it with config
|
||||
const baseDefinition = BrowserAgentDefinition(
|
||||
config,
|
||||
!visionDisabledReason,
|
||||
);
|
||||
const definition: LocalAgentDefinition<typeof BrowserTaskResultSchema> = {
|
||||
...baseDefinition,
|
||||
toolConfig: {
|
||||
tools: allTools,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateAllowRules(toolName: string): PolicyRule {
|
||||
return {
|
||||
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
source: 'BrowserAgent (Read-Only)',
|
||||
mcpName: BROWSER_AGENT_NAME,
|
||||
definition,
|
||||
browserManager,
|
||||
visionEnabled: !visionDisabledReason,
|
||||
sessionMode,
|
||||
};
|
||||
} catch (error) {
|
||||
// Release the browser manager if setup fails, so concurrent tasks can try again.
|
||||
browserManager.release();
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if policy rule the same in all the attributes that we care about
|
||||
function isRuleEqual(rule1: PolicyRule, rule2: PolicyRule) {
|
||||
return (
|
||||
rule1.toolName === rule2.toolName &&
|
||||
rule1.decision === rule2.decision &&
|
||||
rule1.priority === rule2.priority &&
|
||||
rule1.mcpName === rule2.mcpName
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required semantic tools are available
|
||||
const requiredSemanticTools = [
|
||||
'click',
|
||||
'fill',
|
||||
'navigate_page',
|
||||
'take_snapshot',
|
||||
];
|
||||
const missingSemanticTools = requiredSemanticTools.filter(
|
||||
(t) => !availableToolNames.includes(t),
|
||||
);
|
||||
|
||||
const rawSessionMode = browserConfig?.customConfig?.sessionMode;
|
||||
const sessionMode =
|
||||
rawSessionMode === 'isolated' || rawSessionMode === 'existing'
|
||||
? rawSessionMode
|
||||
: 'persistent';
|
||||
|
||||
recordBrowserAgentToolDiscovery(
|
||||
config,
|
||||
mcpTools.length,
|
||||
missingSemanticTools,
|
||||
sessionMode,
|
||||
);
|
||||
|
||||
if (missingSemanticTools.length > 0) {
|
||||
debugLogger.warn(
|
||||
`Semantic tools missing (${missingSemanticTools.join(', ')}). ` +
|
||||
'Some browser interactions may not work correctly.',
|
||||
);
|
||||
}
|
||||
|
||||
// Only click_at is strictly required — text input can use press_key or fill.
|
||||
const requiredVisualTools = ['click_at'];
|
||||
const missingVisualTools = requiredVisualTools.filter(
|
||||
(t) => !availableToolNames.includes(t),
|
||||
);
|
||||
|
||||
// Check whether vision can be enabled; returns structured type with code and message.
|
||||
function getVisionDisabledReason(): VisionDisabledReason {
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
if (!browserConfig.customConfig.visualModel) {
|
||||
return {
|
||||
code: 'no_visual_model',
|
||||
message: 'No visualModel configured.',
|
||||
};
|
||||
}
|
||||
if (missingVisualTools.length > 0) {
|
||||
return {
|
||||
code: 'missing_visual_tools',
|
||||
message:
|
||||
`Visual tools missing (${missingVisualTools.join(', ')}). ` +
|
||||
`The installed chrome-devtools-mcp version may be too old.`,
|
||||
};
|
||||
}
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
const blockedAuthTypes = new Set([
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
AuthType.LEGACY_CLOUD_SHELL,
|
||||
AuthType.COMPUTE_ADC,
|
||||
]);
|
||||
if (authType && blockedAuthTypes.has(authType)) {
|
||||
return {
|
||||
code: 'blocked_auth_type',
|
||||
message: 'Visual agent model not available for current auth type.',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allTools: AnyDeclarativeTool[] = [...mcpTools];
|
||||
const visionDisabledReason = getVisionDisabledReason();
|
||||
|
||||
logBrowserAgentVisionStatus(config, {
|
||||
enabled: !visionDisabledReason,
|
||||
disabled_reason: visionDisabledReason?.code,
|
||||
});
|
||||
|
||||
if (visionDisabledReason) {
|
||||
debugLogger.log(`Vision disabled: ${visionDisabledReason.message}`);
|
||||
} else {
|
||||
allTools.push(
|
||||
createAnalyzeScreenshotTool(browserManager, config, messageBus),
|
||||
);
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`Created ${allTools.length} tools for browser agent: ` +
|
||||
allTools.map((t) => t.name).join(', '),
|
||||
);
|
||||
|
||||
// Create configured definition with tools
|
||||
// BrowserAgentDefinition is a factory function - call it with config
|
||||
const baseDefinition = BrowserAgentDefinition(config, !visionDisabledReason);
|
||||
const definition: LocalAgentDefinition<typeof BrowserTaskResultSchema> = {
|
||||
...baseDefinition,
|
||||
toolConfig: {
|
||||
tools: allTools,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
definition,
|
||||
browserManager,
|
||||
visionEnabled: !visionDisabledReason,
|
||||
sessionMode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -192,7 +192,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
promptConfig: { query: '', systemPrompt: '' },
|
||||
toolConfig: { tools: ['analyze_screenshot', 'click'] },
|
||||
},
|
||||
browserManager: {} as never,
|
||||
browserManager: {
|
||||
release: vi.fn(),
|
||||
callTool: vi.fn().mockResolvedValue({ content: [] }),
|
||||
} as never,
|
||||
visionEnabled: true,
|
||||
sessionMode: 'persistent',
|
||||
});
|
||||
@@ -766,6 +769,7 @@ describe('BrowserAgentInvocation', () => {
|
||||
}
|
||||
return { isError: false };
|
||||
}),
|
||||
release: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(createBrowserAgentDefinition).mockResolvedValue({
|
||||
|
||||
@@ -440,6 +440,8 @@ ${output.result}`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors for removing the overlays.
|
||||
} finally {
|
||||
browserManager.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,6 +873,122 @@ describe('BrowserManager', () => {
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
|
||||
it('should throw when acquired instance is requested in persistent mode', () => {
|
||||
// mockConfig defaults to persistent mode
|
||||
const instance1 = BrowserManager.getInstance(mockConfig);
|
||||
instance1.acquire();
|
||||
|
||||
expect(() => BrowserManager.getInstance(mockConfig)).toThrow(
|
||||
/Cannot launch a concurrent browser agent in "persistent" session mode/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when acquired instance is requested in existing mode', () => {
|
||||
const existingConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { sessionMode: 'existing' },
|
||||
},
|
||||
});
|
||||
|
||||
const instance1 = BrowserManager.getInstance(existingConfig);
|
||||
instance1.acquire();
|
||||
|
||||
expect(() => BrowserManager.getInstance(existingConfig)).toThrow(
|
||||
/Cannot launch a concurrent browser agent in "existing" session mode/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a different instance when the primary is acquired in isolated mode', () => {
|
||||
const isolatedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { sessionMode: 'isolated' },
|
||||
},
|
||||
});
|
||||
|
||||
const instance1 = BrowserManager.getInstance(isolatedConfig);
|
||||
instance1.acquire();
|
||||
|
||||
const instance2 = BrowserManager.getInstance(isolatedConfig);
|
||||
|
||||
expect(instance2).not.toBe(instance1);
|
||||
expect(instance1.isAcquired()).toBe(true);
|
||||
expect(instance2.isAcquired()).toBe(false);
|
||||
});
|
||||
|
||||
it('should reuse the primary when it has been released', () => {
|
||||
const instance1 = BrowserManager.getInstance(mockConfig);
|
||||
instance1.acquire();
|
||||
instance1.release();
|
||||
|
||||
const instance2 = BrowserManager.getInstance(mockConfig);
|
||||
|
||||
expect(instance2).toBe(instance1);
|
||||
expect(instance1.isAcquired()).toBe(false);
|
||||
});
|
||||
|
||||
it('should reuse a released parallel instance in isolated mode', () => {
|
||||
const isolatedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { sessionMode: 'isolated' },
|
||||
},
|
||||
});
|
||||
|
||||
const instance1 = BrowserManager.getInstance(isolatedConfig);
|
||||
instance1.acquire();
|
||||
|
||||
const instance2 = BrowserManager.getInstance(isolatedConfig);
|
||||
instance2.acquire();
|
||||
instance2.release();
|
||||
|
||||
// Primary is still acquired, parallel is released — should reuse parallel
|
||||
const instance3 = BrowserManager.getInstance(isolatedConfig);
|
||||
expect(instance3).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should create multiple parallel instances in isolated mode', () => {
|
||||
const isolatedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { sessionMode: 'isolated' },
|
||||
},
|
||||
});
|
||||
|
||||
const instance1 = BrowserManager.getInstance(isolatedConfig);
|
||||
instance1.acquire();
|
||||
|
||||
const instance2 = BrowserManager.getInstance(isolatedConfig);
|
||||
instance2.acquire();
|
||||
|
||||
const instance3 = BrowserManager.getInstance(isolatedConfig);
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
expect(instance2).not.toBe(instance3);
|
||||
expect(instance1).not.toBe(instance3);
|
||||
});
|
||||
|
||||
it('should throw when MAX_PARALLEL_INSTANCES is reached in isolated mode', () => {
|
||||
const isolatedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { sessionMode: 'isolated' },
|
||||
},
|
||||
});
|
||||
|
||||
// Acquire MAX_PARALLEL_INSTANCES instances
|
||||
for (let i = 0; i < BrowserManager.MAX_PARALLEL_INSTANCES; i++) {
|
||||
const instance = BrowserManager.getInstance(isolatedConfig);
|
||||
instance.acquire();
|
||||
}
|
||||
|
||||
// Next call should throw
|
||||
expect(() => BrowserManager.getInstance(isolatedConfig)).toThrow(
|
||||
/Maximum number of parallel browser instances/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetAll', () => {
|
||||
|
||||
@@ -114,6 +114,12 @@ export class BrowserManager {
|
||||
// --- Static singleton management ---
|
||||
private static instances = new Map<string, BrowserManager>();
|
||||
|
||||
/**
|
||||
* Maximum number of parallel browser instances allowed in isolated mode.
|
||||
* Prevents unbounded resource consumption from concurrent browser_agent calls.
|
||||
*/
|
||||
static readonly MAX_PARALLEL_INSTANCES = 5;
|
||||
|
||||
/**
|
||||
* Returns the cache key for a given config.
|
||||
* Uses `sessionMode:profilePath` so different profiles get separate instances.
|
||||
@@ -128,14 +134,64 @@ export class BrowserManager {
|
||||
/**
|
||||
* Returns an existing BrowserManager for the current config's session mode
|
||||
* and profile, or creates a new one.
|
||||
*
|
||||
* Concurrency rules:
|
||||
* - **persistent / existing mode**: Only one instance is allowed at a time.
|
||||
* If the instance is already in-use, an error is thrown instructing the
|
||||
* caller to run browser tasks sequentially.
|
||||
* - **isolated mode**: Parallel instances are allowed up to
|
||||
* MAX_PARALLEL_INSTANCES. Each isolated instance gets its own temp profile.
|
||||
*/
|
||||
static getInstance(config: Config): BrowserManager {
|
||||
const key = BrowserManager.getInstanceKey(config);
|
||||
const sessionMode =
|
||||
config.getBrowserAgentConfig().customConfig.sessionMode ?? 'persistent';
|
||||
let instance = BrowserManager.instances.get(key);
|
||||
if (!instance) {
|
||||
instance = new BrowserManager(config);
|
||||
BrowserManager.instances.set(key, instance);
|
||||
debugLogger.log(`Created new BrowserManager singleton (key: ${key})`);
|
||||
} else if (instance.inUse) {
|
||||
// Persistent and existing modes share a browser profile directory.
|
||||
// Chrome prevents multiple instances from using the same profile, so
|
||||
// concurrent usage would cause "profile locked" errors.
|
||||
if (sessionMode === 'persistent' || sessionMode === 'existing') {
|
||||
throw new Error(
|
||||
`Cannot launch a concurrent browser agent in "${sessionMode}" session mode. ` +
|
||||
`The browser instance is already in use by another task. ` +
|
||||
`Please run browser tasks sequentially, or switch to "isolated" session mode for concurrent browser usage.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Isolated mode: allow parallel instances up to the limit.
|
||||
let inUseCount = 1; // primary is already in-use
|
||||
let suffix = 1;
|
||||
let parallelKey = `${key}:${suffix}`;
|
||||
let parallel = BrowserManager.instances.get(parallelKey);
|
||||
while (parallel?.inUse) {
|
||||
inUseCount++;
|
||||
if (inUseCount >= BrowserManager.MAX_PARALLEL_INSTANCES) {
|
||||
throw new Error(
|
||||
`Maximum number of parallel browser instances (${BrowserManager.MAX_PARALLEL_INSTANCES}) reached. ` +
|
||||
`Please wait for an existing browser task to complete before starting a new one.`,
|
||||
);
|
||||
}
|
||||
suffix++;
|
||||
parallelKey = `${key}:${suffix}`;
|
||||
parallel = BrowserManager.instances.get(parallelKey);
|
||||
}
|
||||
if (!parallel) {
|
||||
parallel = new BrowserManager(config);
|
||||
BrowserManager.instances.set(parallelKey, parallel);
|
||||
debugLogger.log(
|
||||
`Created parallel BrowserManager (key: ${parallelKey})`,
|
||||
);
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`Reusing released parallel BrowserManager (key: ${parallelKey})`,
|
||||
);
|
||||
}
|
||||
instance = parallel;
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`Reusing existing BrowserManager singleton (key: ${key})`,
|
||||
@@ -180,6 +236,36 @@ export class BrowserManager {
|
||||
private isClosing = false;
|
||||
private connectionPromise: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
* Whether this instance is currently acquired by an active invocation.
|
||||
* Used by getInstance() to avoid handing the same browser to concurrent
|
||||
* browser_agent calls.
|
||||
*/
|
||||
private inUse = false;
|
||||
|
||||
/**
|
||||
* Marks this instance as in-use. Call this when starting a browser agent
|
||||
* invocation so concurrent calls get a separate instance.
|
||||
*/
|
||||
acquire(): void {
|
||||
this.inUse = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this instance as available for reuse. Call this in the finally
|
||||
* block of a browser agent invocation.
|
||||
*/
|
||||
release(): void {
|
||||
this.inUse = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this instance is currently acquired by an active invocation.
|
||||
*/
|
||||
isAcquired(): boolean {
|
||||
return this.inUse;
|
||||
}
|
||||
|
||||
/** State for action rate limiting */
|
||||
private actionCounter = 0;
|
||||
private readonly maxActionsPerTask: number;
|
||||
|
||||
@@ -1075,7 +1075,7 @@ describe('AgentRegistry', () => {
|
||||
expect.objectContaining({
|
||||
toolName: 'PolicyTestAgent',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 1.05,
|
||||
priority: 1.03,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1102,7 +1102,7 @@ describe('AgentRegistry', () => {
|
||||
expect.objectContaining({
|
||||
toolName: 'RemotePolicyAgent',
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: 1.05,
|
||||
priority: 1.03,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,8 +36,6 @@ import { GlobTool } from '../tools/glob.js';
|
||||
import { ActivateSkillTool } from '../tools/activate-skill.js';
|
||||
import { EditTool } from '../tools/edit.js';
|
||||
import { ShellTool } from '../tools/shell.js';
|
||||
import { WriteToShellTool } from '../tools/write-to-shell.js';
|
||||
import { ReadShellTool } from '../tools/read-shell.js';
|
||||
import { WriteFileTool } from '../tools/write-file.js';
|
||||
import { WebFetchTool } from '../tools/web-fetch.js';
|
||||
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
|
||||
@@ -658,7 +656,6 @@ export interface ConfigParameters {
|
||||
useRipgrep?: boolean;
|
||||
enableInteractiveShell?: boolean;
|
||||
shellBackgroundCompletionBehavior?: string;
|
||||
interactiveShellMode?: 'human' | 'ai' | 'off';
|
||||
skipNextSpeakerCheck?: boolean;
|
||||
shellExecutionConfig?: ShellExecutionConfig;
|
||||
extensionManagement?: boolean;
|
||||
@@ -871,7 +868,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
| 'inject'
|
||||
| 'notify'
|
||||
| 'silent';
|
||||
private readonly interactiveShellMode: 'human' | 'ai' | 'off';
|
||||
private readonly skipNextSpeakerCheck: boolean;
|
||||
private readonly useBackgroundColor: boolean;
|
||||
private readonly useAlternateBuffer: boolean;
|
||||
@@ -1239,14 +1235,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.shellBackgroundCompletionBehavior = 'silent';
|
||||
}
|
||||
|
||||
// interactiveShellMode takes precedence over enableInteractiveShell.
|
||||
// If not set, derive from enableInteractiveShell for backward compat.
|
||||
if (params.interactiveShellMode) {
|
||||
this.interactiveShellMode = params.interactiveShellMode;
|
||||
} else {
|
||||
this.interactiveShellMode = this.enableInteractiveShell ? 'human' : 'off';
|
||||
}
|
||||
|
||||
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
|
||||
this.shellExecutionConfig = {
|
||||
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,
|
||||
@@ -3223,14 +3211,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return (
|
||||
this.interactive &&
|
||||
this.ptyInfo !== 'child_process' &&
|
||||
this.interactiveShellMode !== 'off'
|
||||
this.enableInteractiveShell
|
||||
);
|
||||
}
|
||||
|
||||
getInteractiveShellMode(): 'human' | 'ai' | 'off' {
|
||||
return this.interactiveShellMode;
|
||||
}
|
||||
|
||||
isSkillsSupportEnabled(): boolean {
|
||||
return this.skillsSupport;
|
||||
}
|
||||
@@ -3591,15 +3575,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
new ReadBackgroundOutputTool(this, this.messageBus),
|
||||
),
|
||||
);
|
||||
// Register AI-driven interactive shell tools when mode is 'ai'
|
||||
if (this.getInteractiveShellMode() === 'ai') {
|
||||
maybeRegister(WriteToShellTool, () =>
|
||||
registry.registerTool(new WriteToShellTool(this.messageBus)),
|
||||
);
|
||||
maybeRegister(ReadShellTool, () =>
|
||||
registry.registerTool(new ReadShellTool(this.messageBus)),
|
||||
);
|
||||
}
|
||||
if (!this.isMemoryManagerEnabled()) {
|
||||
maybeRegister(MemoryTool, () =>
|
||||
registry.registerTool(new MemoryTool(this.messageBus, this.storage)),
|
||||
|
||||
@@ -398,9 +398,10 @@ export async function createPolicyEngineConfig(
|
||||
// TOML policy priorities (before transformation):
|
||||
// 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
|
||||
// 15: Auto-edit tool override (becomes 1.015 in default tier)
|
||||
// 30: Unknown subagents (blocked by Plan Mode's 40)
|
||||
// 40: Plan mode catch-all DENY override (becomes 1.040 in default tier)
|
||||
// 50: Read-only tools (becomes 1.050 in default tier)
|
||||
// 60: Plan mode catch-all DENY override (becomes 1.060 in default tier)
|
||||
// 70: Plan mode explicit ALLOW override (becomes 1.070 in default tier)
|
||||
// 70: Mode transition overrides (becomes 1.070 in default tier)
|
||||
// 999: YOLO mode allow-all (becomes 1.999 in default tier)
|
||||
|
||||
// MCP servers that are explicitly excluded in settings.mcp.excluded
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
#
|
||||
# TOML policy priorities (before transformation):
|
||||
# 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
|
||||
# 60: Plan mode catch-all DENY override (becomes 1.060 in default tier)
|
||||
# 70: Plan mode explicit ALLOW override (becomes 1.070 in default tier)
|
||||
# 30: Unknown subagents (blocked by Plan Mode's 40)
|
||||
# 40: Plan mode catch-all DENY override (becomes 1.040 in default tier)
|
||||
# 50: Read-only tools / Plan mode explicit ALLOW (becomes 1.050 in default tier)
|
||||
# 70: Mode transition overrides (into/out of Plan Mode)
|
||||
# 999: YOLO mode allow-all (becomes 1.999 in default tier)
|
||||
|
||||
# Mode Transitions (into/out of Plan Mode)
|
||||
@@ -59,6 +61,7 @@ interactive = true
|
||||
toolName = "exit_plan_mode"
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
interactive = false
|
||||
|
||||
[[rule]]
|
||||
@@ -73,18 +76,23 @@ denyMessage = "You are not currently in Plan Mode. Use enter_plan_mode first to
|
||||
[[rule]]
|
||||
toolName = "*"
|
||||
decision = "deny"
|
||||
priority = 60
|
||||
priority = 40
|
||||
modes = ["plan"]
|
||||
denyMessage = "You are in Plan Mode with access to read-only tools. Execution of scripts (including those from skills) is blocked."
|
||||
|
||||
# Explicitly Allow Read-Only Tools in Plan mode.
|
||||
[[rule]]
|
||||
toolName = ["activate_skill"]
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
modes = ["plan"]
|
||||
|
||||
[[rule]]
|
||||
toolName = "*"
|
||||
mcpName = "*"
|
||||
toolAnnotations = { readOnlyHint = true }
|
||||
decision = "ask_user"
|
||||
priority = 70
|
||||
priority = 50
|
||||
modes = ["plan"]
|
||||
interactive = true
|
||||
|
||||
@@ -93,45 +101,21 @@ toolName = "*"
|
||||
mcpName = "*"
|
||||
toolAnnotations = { readOnlyHint = true }
|
||||
decision = "deny"
|
||||
priority = 70
|
||||
priority = 50
|
||||
modes = ["plan"]
|
||||
interactive = false
|
||||
|
||||
[[rule]]
|
||||
toolName = [
|
||||
"glob",
|
||||
"grep_search",
|
||||
"list_directory",
|
||||
"read_file",
|
||||
"google_web_search",
|
||||
"activate_skill",
|
||||
"codebase_investigator",
|
||||
"cli_help",
|
||||
"get_internal_docs",
|
||||
"complete_task"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
|
||||
# Topic grouping tool is innocuous and used for UI organization.
|
||||
[[rule]]
|
||||
toolName = "update_topic"
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
|
||||
[[rule]]
|
||||
toolName = ["ask_user", "save_memory", "web_fetch"]
|
||||
decision = "ask_user"
|
||||
priority = 70
|
||||
priority = 50
|
||||
modes = ["plan"]
|
||||
interactive = true
|
||||
|
||||
[[rule]]
|
||||
toolName = ["ask_user", "save_memory", "web_fetch"]
|
||||
decision = "deny"
|
||||
priority = 70
|
||||
priority = 50
|
||||
modes = ["plan"]
|
||||
interactive = false
|
||||
|
||||
|
||||
@@ -28,43 +28,26 @@
|
||||
# 999: YOLO mode allow-all (becomes 1.999 in default tier)
|
||||
|
||||
[[rule]]
|
||||
toolName = "glob"
|
||||
toolName = [
|
||||
"glob",
|
||||
"grep_search",
|
||||
"list_directory",
|
||||
"read_file",
|
||||
"google_web_search",
|
||||
"codebase_investigator",
|
||||
"cli_help",
|
||||
"get_internal_docs",
|
||||
# Tracker tools for task management (safe as they only modify internal state)
|
||||
"tracker_create_task",
|
||||
"tracker_update_task",
|
||||
"tracker_get_task",
|
||||
"tracker_list_tasks",
|
||||
"tracker_add_dependency",
|
||||
"tracker_visualize",
|
||||
# Topic grouping tool is innocuous and used for UI organization.
|
||||
"update_topic",
|
||||
# Core agent lifecycle tool
|
||||
"complete_task"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
[[rule]]
|
||||
toolName = "grep_search"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
[[rule]]
|
||||
toolName = "list_directory"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
[[rule]]
|
||||
toolName = "read_file"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
[[rule]]
|
||||
toolName = "google_web_search"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
[[rule]]
|
||||
toolName = ["codebase_investigator", "cli_help", "get_internal_docs"]
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
# Topic grouping tool is innocuous and used for UI organization.
|
||||
[[rule]]
|
||||
toolName = "update_topic"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
# Core agent lifecycle tool
|
||||
[[rule]]
|
||||
toolName = "complete_task"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
@@ -1,34 +0,0 @@
|
||||
# Priority system for policy rules:
|
||||
# - Higher priority numbers win over lower priority numbers
|
||||
# - When multiple rules match, the highest priority rule is applied
|
||||
# - Rules are evaluated in order of priority (highest first)
|
||||
#
|
||||
# Priority bands (tiers):
|
||||
# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
|
||||
# - Extension policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
|
||||
# - Workspace policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
|
||||
# - User policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
|
||||
# - Admin policies (TOML): 5 + priority/1000 (e.g., priority 100 → 5.100)
|
||||
#
|
||||
# Settings-based and dynamic rules (all in user tier 4.x):
|
||||
# 4.95: Tools that the user has selected as "Always Allow" in the interactive UI
|
||||
# 4.9: MCP servers excluded list (security: persistent server blocks)
|
||||
# 4.4: Command line flag --exclude-tools (explicit temporary blocks)
|
||||
# 4.3: Command line flag --allowed-tools (explicit temporary allows)
|
||||
# 4.2: MCP servers with trust=true (persistent trusted servers)
|
||||
# 4.1: MCP servers allowed list (persistent general server allows)
|
||||
|
||||
# Allow tracker tools to execute without asking the user.
|
||||
# These tools are only registered when the tracker feature is enabled,
|
||||
# so this rule is a no-op when the feature is disabled.
|
||||
[[rule]]
|
||||
toolName = [
|
||||
"tracker_create_task",
|
||||
"tracker_update_task",
|
||||
"tracker_get_task",
|
||||
"tracker_list_tasks",
|
||||
"tracker_add_dependency",
|
||||
"tracker_visualize"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
@@ -1715,13 +1715,13 @@ describe('PolicyEngine', () => {
|
||||
|
||||
describe('Plan Mode vs Subagent Priority (Regression)', () => {
|
||||
it('should DENY subagents in Plan Mode despite dynamic allow rules', async () => {
|
||||
// Plan Mode Deny (1.06) > Subagent Allow (1.05)
|
||||
// Plan Mode Deny (1.04) > Subagent Allow (1.03)
|
||||
|
||||
const fixedRules: PolicyRule[] = [
|
||||
{
|
||||
toolName: '*',
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: 1.06,
|
||||
priority: 1.04,
|
||||
modes: [ApprovalMode.PLAN],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -890,8 +890,8 @@ priority = 100
|
||||
readOnlyHint: true,
|
||||
});
|
||||
expect(annotationRule!.decision).toBe(PolicyDecision.ASK_USER);
|
||||
// Priority 70 in tier 1 => 1.070
|
||||
expect(annotationRule!.priority).toBe(1.07);
|
||||
// Priority 50 in tier 1 => 1.050
|
||||
expect(annotationRule!.priority).toBe(1.05);
|
||||
|
||||
// Verify deny rule was loaded correctly
|
||||
const denyRule = result.rules.find(
|
||||
@@ -904,8 +904,8 @@ priority = 100
|
||||
denyRule,
|
||||
'Should have loaded the catch-all deny rule',
|
||||
).toBeDefined();
|
||||
// Priority 60 in tier 1 => 1.060
|
||||
expect(denyRule!.priority).toBe(1.06);
|
||||
// Priority 40 in tier 1 => 1.040
|
||||
expect(denyRule!.priority).toBe(1.04);
|
||||
|
||||
// 2. Initialize Policy Engine in Plan Mode
|
||||
const engine = new PolicyEngine({
|
||||
@@ -974,12 +974,23 @@ priority = 100
|
||||
|
||||
it('should override default subagent rules when in Plan Mode for unknown subagents', async () => {
|
||||
const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml');
|
||||
const fileContent = await fs.readFile(planTomlPath, 'utf-8');
|
||||
const readOnlyTomlPath = path.resolve(
|
||||
__dirname,
|
||||
'policies',
|
||||
'read-only.toml',
|
||||
);
|
||||
const planContent = await fs.readFile(planTomlPath, 'utf-8');
|
||||
const readOnlyContent = await fs.readFile(readOnlyTomlPath, 'utf-8');
|
||||
|
||||
const tempPolicyDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'plan-policy-test-'),
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(path.join(tempPolicyDir, 'plan.toml'), fileContent);
|
||||
await fs.writeFile(path.join(tempPolicyDir, 'plan.toml'), planContent);
|
||||
await fs.writeFile(
|
||||
path.join(tempPolicyDir, 'read-only.toml'),
|
||||
readOnlyContent,
|
||||
);
|
||||
const getPolicyTier = () => 1; // Default tier
|
||||
|
||||
// 1. Load the actual Plan Mode policies
|
||||
@@ -1004,6 +1015,7 @@ priority = 100
|
||||
|
||||
// 4. Verify Behavior:
|
||||
// The Plan Mode "Catch-All Deny" (from plan.toml) should override the Subagent Allow
|
||||
// Plan Mode Deny (1.04) > Subagent Allow (1.03)
|
||||
const checkResult = await engine.check(
|
||||
{ name: 'unknown_subagent' },
|
||||
undefined,
|
||||
@@ -1015,7 +1027,7 @@ priority = 100
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// 5. Verify Explicit Allows still work
|
||||
// e.g. 'read_file' should be allowed because its priority in plan.toml (70) is higher than the deny (60)
|
||||
// e.g. 'read_file' should be allowed because its priority in read-only.toml (50) is higher than the deny (40)
|
||||
const readResult = await engine.check({ name: 'read_file' }, undefined);
|
||||
expect(
|
||||
readResult.decision,
|
||||
@@ -1023,6 +1035,7 @@ priority = 100
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// 6. Verify Built-in Research Subagents are ALLOWED
|
||||
// codebase_investigator is priority 50 in read-only.toml
|
||||
const codebaseResult = await engine.check(
|
||||
{ name: 'codebase_investigator' },
|
||||
undefined,
|
||||
|
||||
@@ -354,9 +354,11 @@ export interface CheckResult {
|
||||
|
||||
/**
|
||||
* Priority for subagent tools (registered dynamically).
|
||||
* Effective priority matching Tier 1 (Default) read-only tools.
|
||||
* Effective priority matching Tier 1 (Default) at priority 30.
|
||||
* This ensures they are blocked by Plan Mode (priority 40) while
|
||||
* remaining above directive write tools (priority 10).
|
||||
*/
|
||||
export const PRIORITY_SUBAGENT_TOOL = 1.05;
|
||||
export const PRIORITY_SUBAGENT_TOOL = 1.03;
|
||||
|
||||
/**
|
||||
* The fractional priority of "Always allow" rules (e.g., 950/1000).
|
||||
|
||||
@@ -200,7 +200,6 @@ export class PromptProvider {
|
||||
enableShellEfficiency:
|
||||
context.config.getEnableShellOutputEfficiency(),
|
||||
interactiveShellEnabled: context.config.isInteractiveShellEnabled(),
|
||||
interactiveShellMode: context.config.getInteractiveShellMode(),
|
||||
topicUpdateNarration:
|
||||
context.config.isTopicUpdateNarrationEnabled(),
|
||||
memoryManagerEnabled: context.config.isMemoryManagerEnabled(),
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
MEMORY_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
WRITE_TO_SHELL_TOOL_NAME,
|
||||
READ_SHELL_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
WRITE_TODOS_TOOL_NAME,
|
||||
GREP_PARAM_TOTAL_MAX_MATCHES,
|
||||
@@ -83,7 +81,6 @@ export interface PrimaryWorkflowsOptions {
|
||||
export interface OperationalGuidelinesOptions {
|
||||
interactive: boolean;
|
||||
interactiveShellEnabled: boolean;
|
||||
interactiveShellMode?: 'human' | 'ai' | 'off';
|
||||
topicUpdateNarration: boolean;
|
||||
memoryManagerEnabled: boolean;
|
||||
}
|
||||
@@ -394,7 +391,7 @@ export function renderOperationalGuidelines(
|
||||
- **Command Execution:** Use the ${formatToolName(SHELL_TOOL_NAME)} tool for running shell commands, remembering the safety rule to explain modifying commands first.${toolUsageInteractive(
|
||||
options.interactive,
|
||||
options.interactiveShellEnabled,
|
||||
)}${toolUsageRememberingFacts(options)}${toolUsageAiShell(options)}
|
||||
)}${toolUsageRememberingFacts(options)}
|
||||
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
|
||||
|
||||
## Interaction Details
|
||||
@@ -803,16 +800,6 @@ function toolUsageInteractive(
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).`;
|
||||
}
|
||||
|
||||
function toolUsageAiShell(options: OperationalGuidelinesOptions): string {
|
||||
if (options.interactiveShellMode !== 'ai') return '';
|
||||
return `
|
||||
- **AI-Driven Interactive Shell:** Commands auto-promote to background if they stall for 3 seconds. Once promoted, use ${formatToolName(READ_SHELL_TOOL_NAME)} to see the terminal screen, then ${formatToolName(WRITE_TO_SHELL_TOOL_NAME)} to send text input and/or special keys (arrows, Enter, Ctrl-C, etc.).
|
||||
- **Always read the screen before writing input.** The screen state tells you what the process is waiting for.
|
||||
- When waiting for a command to finish (e.g. npm install), use ${formatToolName(READ_SHELL_TOOL_NAME)} with \`wait_seconds\` to delay before reading. Do NOT poll in a tight loop.
|
||||
- **Clean up when done:** when your task is complete, kill background processes with ${formatToolName(WRITE_TO_SHELL_TOOL_NAME)} sending Ctrl-C, or note the PID for the user to clean up.
|
||||
- You are the sole operator of promoted shells — the user cannot type into them.`;
|
||||
}
|
||||
|
||||
function toolUsageRememberingFacts(
|
||||
options: OperationalGuidelinesOptions,
|
||||
): string {
|
||||
|
||||
@@ -105,7 +105,6 @@ export interface ShellExecutionConfig {
|
||||
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
|
||||
originalCommand?: string;
|
||||
sessionId?: string;
|
||||
autoPromoteTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -890,21 +889,6 @@ export class ShellExecutionService {
|
||||
sessionId: shellExecutionConfig.sessionId,
|
||||
});
|
||||
|
||||
let autoPromoteTimer: NodeJS.Timeout | undefined;
|
||||
const resetAutoPromoteTimer = () => {
|
||||
if (shellExecutionConfig.autoPromoteTimeoutMs !== undefined) {
|
||||
if (autoPromoteTimer) clearTimeout(autoPromoteTimer);
|
||||
autoPromoteTimer = setTimeout(() => {
|
||||
ShellExecutionService.background(
|
||||
ptyPid,
|
||||
shellExecutionConfig.sessionId,
|
||||
);
|
||||
}, shellExecutionConfig.autoPromoteTimeoutMs);
|
||||
}
|
||||
};
|
||||
|
||||
resetAutoPromoteTimer();
|
||||
|
||||
const result = ExecutionLifecycleService.attachExecution(ptyPid, {
|
||||
executionMethod: ptyInfo?.name ?? 'node-pty',
|
||||
writeInput: (input) => {
|
||||
@@ -1082,7 +1066,6 @@ export class ShellExecutionService {
|
||||
});
|
||||
|
||||
const handleOutput = (data: Buffer) => {
|
||||
resetAutoPromoteTimer();
|
||||
processingChain = processingChain.then(
|
||||
() =>
|
||||
new Promise<void>((resolveChunk) => {
|
||||
@@ -1152,7 +1135,6 @@ export class ShellExecutionService {
|
||||
|
||||
ptyProcess.onExit(
|
||||
({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
||||
if (autoPromoteTimer) clearTimeout(autoPromoteTimer);
|
||||
exited = true;
|
||||
abortSignal.removeEventListener('abort', abortHandler);
|
||||
// Attempt to destroy the PTY to ensure FD is closed
|
||||
@@ -1238,7 +1220,6 @@ export class ShellExecutionService {
|
||||
);
|
||||
|
||||
const abortHandler = async () => {
|
||||
if (autoPromoteTimer) clearTimeout(autoPromoteTimer);
|
||||
if (ptyProcess.pid && !exited) {
|
||||
await killProcessGroup({
|
||||
pid: ptyPid,
|
||||
@@ -1341,11 +1322,6 @@ export class ShellExecutionService {
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
|
||||
// If the process has already exited, there is no need to background it.
|
||||
if (!activePty && !activeChild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedSessionId =
|
||||
sessionId ?? activePty?.sessionId ?? activeChild?.sessionId;
|
||||
const resolvedCommand =
|
||||
@@ -1422,28 +1398,6 @@ export class ShellExecutionService {
|
||||
return ExecutionLifecycleService.subscribe(pid, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current rendered screen state of a running process.
|
||||
* Returns the full terminal buffer text for PTY processes,
|
||||
* or the accumulated output for child processes.
|
||||
*
|
||||
* @param pid The process ID of the target process.
|
||||
* @returns The screen text, or null if the process is not found.
|
||||
*/
|
||||
static readScreen(pid: number): string | null {
|
||||
const activePty = this.activePtys.get(pid);
|
||||
if (activePty) {
|
||||
return getFullBufferText(activePty.headlessTerminal);
|
||||
}
|
||||
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
if (activeChild) {
|
||||
return activeChild.state.output;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes the pseudo-terminal (PTY) of a running process.
|
||||
*
|
||||
|
||||
@@ -57,17 +57,6 @@ export const SHELL_TOOL_NAME = 'run_shell_command';
|
||||
export const SHELL_PARAM_COMMAND = 'command';
|
||||
export const SHELL_PARAM_IS_BACKGROUND = 'is_background';
|
||||
|
||||
// -- write_to_shell --
|
||||
export const WRITE_TO_SHELL_TOOL_NAME = 'write_to_shell';
|
||||
export const WRITE_TO_SHELL_PARAM_PID = 'pid';
|
||||
export const WRITE_TO_SHELL_PARAM_INPUT = 'input';
|
||||
export const WRITE_TO_SHELL_PARAM_SPECIAL_KEYS = 'special_keys';
|
||||
|
||||
// -- read_shell --
|
||||
export const READ_SHELL_TOOL_NAME = 'read_shell';
|
||||
export const READ_SHELL_PARAM_PID = 'pid';
|
||||
export const READ_SHELL_PARAM_WAIT_SECONDS = 'wait_seconds';
|
||||
|
||||
// -- write_file --
|
||||
export const WRITE_FILE_TOOL_NAME = 'write_file';
|
||||
export const WRITE_FILE_PARAM_CONTENT = 'content';
|
||||
|
||||
@@ -27,8 +27,6 @@ export {
|
||||
LS_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
WRITE_TO_SHELL_TOOL_NAME,
|
||||
READ_SHELL_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
@@ -75,11 +73,6 @@ export {
|
||||
LS_PARAM_IGNORE,
|
||||
SHELL_PARAM_COMMAND,
|
||||
SHELL_PARAM_IS_BACKGROUND,
|
||||
WRITE_TO_SHELL_PARAM_PID,
|
||||
WRITE_TO_SHELL_PARAM_INPUT,
|
||||
WRITE_TO_SHELL_PARAM_SPECIAL_KEYS,
|
||||
READ_SHELL_PARAM_PID,
|
||||
READ_SHELL_PARAM_WAIT_SECONDS,
|
||||
WEB_SEARCH_PARAM_QUERY,
|
||||
WEB_FETCH_PARAM_PROMPT,
|
||||
READ_MANY_PARAM_INCLUDE,
|
||||
@@ -256,21 +249,18 @@ export function getShellDefinition(
|
||||
enableInteractiveShell: boolean,
|
||||
enableEfficiency: boolean,
|
||||
enableToolSandboxing: boolean = false,
|
||||
interactiveShellMode?: string,
|
||||
): ToolDefinition {
|
||||
return {
|
||||
base: getShellDeclaration(
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
enableToolSandboxing,
|
||||
interactiveShellMode,
|
||||
),
|
||||
overrides: (modelId) =>
|
||||
getToolSet(modelId).run_shell_command(
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
enableToolSandboxing,
|
||||
interactiveShellMode,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,9 +36,7 @@ import {
|
||||
export function getShellToolDescription(
|
||||
enableInteractiveShell: boolean,
|
||||
enableEfficiency: boolean,
|
||||
interactiveShellMode?: string,
|
||||
): string {
|
||||
const isAiMode = interactiveShellMode === 'ai';
|
||||
const efficiencyGuidelines = enableEfficiency
|
||||
? `
|
||||
|
||||
@@ -58,11 +56,6 @@ export function getShellToolDescription(
|
||||
Background PIDs: Only included if background processes were started.
|
||||
Process Group PGID: Only included if available.`;
|
||||
|
||||
if (isAiMode) {
|
||||
const autoPromoteInstructions = `Commands that do not complete within 3 seconds are automatically promoted to background. Once promoted, use \`write_to_shell\` and \`read_shell\` to interact with the process. Do NOT use \`&\` to background commands.`;
|
||||
return `This tool executes a given shell command as \`bash -c <command>\`. ${autoPromoteInstructions} Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${efficiencyGuidelines}${returnedInfo}`;
|
||||
}
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
const backgroundInstructions = enableInteractiveShell
|
||||
? `To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true. Do NOT use PowerShell background constructs.`
|
||||
@@ -93,27 +86,12 @@ export function getShellDeclaration(
|
||||
enableInteractiveShell: boolean,
|
||||
enableEfficiency: boolean,
|
||||
enableToolSandboxing: boolean = false,
|
||||
interactiveShellMode?: string,
|
||||
): FunctionDeclaration {
|
||||
const isAiMode = interactiveShellMode === 'ai';
|
||||
|
||||
// In AI mode, auto-promotion is enabled by default, no background param needed
|
||||
const backgroundParam = isAiMode
|
||||
? {}
|
||||
: {
|
||||
[SHELL_PARAM_IS_BACKGROUND]: {
|
||||
type: 'boolean' as const,
|
||||
description:
|
||||
'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
name: SHELL_TOOL_NAME,
|
||||
description: getShellToolDescription(
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
interactiveShellMode,
|
||||
),
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
@@ -142,7 +120,6 @@ export function getShellDeclaration(
|
||||
description:
|
||||
'Optional. Delay in milliseconds to wait after starting the process in the background. Useful to allow the process to start and generate initial output before returning.',
|
||||
},
|
||||
...backgroundParam,
|
||||
...(enableToolSandboxing
|
||||
? {
|
||||
[PARAM_ADDITIONAL_PERMISSIONS]: {
|
||||
|
||||
@@ -337,13 +337,11 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = {
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
enableToolSandboxing,
|
||||
interactiveShellMode,
|
||||
) =>
|
||||
getShellDeclaration(
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
enableToolSandboxing,
|
||||
interactiveShellMode,
|
||||
),
|
||||
|
||||
replace: {
|
||||
|
||||
@@ -344,13 +344,11 @@ export const GEMINI_3_SET: CoreToolSet = {
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
enableToolSandboxing,
|
||||
interactiveShellMode,
|
||||
) =>
|
||||
getShellDeclaration(
|
||||
enableInteractiveShell,
|
||||
enableEfficiency,
|
||||
enableToolSandboxing,
|
||||
interactiveShellMode,
|
||||
),
|
||||
|
||||
replace: {
|
||||
|
||||
@@ -38,7 +38,6 @@ export interface CoreToolSet {
|
||||
enableInteractiveShell: boolean,
|
||||
enableEfficiency: boolean,
|
||||
enableToolSandboxing: boolean,
|
||||
interactiveShellMode?: string,
|
||||
) => FunctionDeclaration;
|
||||
replace: FunctionDeclaration;
|
||||
google_web_search: FunctionDeclaration;
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolInvocation,
|
||||
type ToolResult,
|
||||
} from './tools.js';
|
||||
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||||
import {
|
||||
READ_SHELL_TOOL_NAME,
|
||||
READ_SHELL_PARAM_PID,
|
||||
READ_SHELL_PARAM_WAIT_SECONDS,
|
||||
} from './tool-names.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
|
||||
export interface ReadShellParams {
|
||||
pid: number;
|
||||
wait_seconds?: number;
|
||||
}
|
||||
|
||||
export class ReadShellToolInvocation extends BaseToolInvocation<
|
||||
ReadShellParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
params: ReadShellParams,
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
) {
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const waitPart =
|
||||
this.params.wait_seconds !== undefined
|
||||
? ` (after ${this.params.wait_seconds}s)`
|
||||
: '';
|
||||
return `read shell screen PID ${this.params.pid}${waitPart}`;
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
const { pid, wait_seconds } = this.params;
|
||||
|
||||
// Wait before reading if requested
|
||||
if (wait_seconds !== undefined && wait_seconds > 0) {
|
||||
const waitMs = Math.min(wait_seconds, 30) * 1000; // Cap at 30s
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, waitMs);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Validate the PID is active
|
||||
if (!ShellExecutionService.isPtyActive(pid)) {
|
||||
return {
|
||||
llmContent: `Error: No active process found with PID ${pid}. The process may have exited.`,
|
||||
returnDisplay: `No active process with PID ${pid}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const screen = ShellExecutionService.readScreen(pid);
|
||||
if (screen === null) {
|
||||
return {
|
||||
llmContent: `Error: Could not read screen for PID ${pid}. The process may have exited.`,
|
||||
returnDisplay: `Could not read screen for PID ${pid}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: screen,
|
||||
returnDisplay: `Screen read from PID ${pid} (${screen.split('\n').length} lines).`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ReadShellTool extends BaseDeclarativeTool<
|
||||
ReadShellParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = READ_SHELL_TOOL_NAME;
|
||||
|
||||
constructor(messageBus: MessageBus) {
|
||||
super(
|
||||
ReadShellTool.Name,
|
||||
'ReadShell',
|
||||
'Reads the current screen state of a running background shell process. Returns the rendered terminal screen as text, preserving the visual layout. Use after write_to_shell to see updated output, or to check progress of a running command.',
|
||||
Kind.Read,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
[READ_SHELL_PARAM_PID]: {
|
||||
type: 'number',
|
||||
description:
|
||||
'The PID of the background process to read from. Obtained from a previous run_shell_command call that was auto-promoted to background or started with is_background=true.',
|
||||
},
|
||||
[READ_SHELL_PARAM_WAIT_SECONDS]: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Seconds to wait before reading the screen. Use this to let the process run for a while before checking output (e.g. wait for a build to finish). Max 30 seconds.',
|
||||
},
|
||||
},
|
||||
required: [READ_SHELL_PARAM_PID],
|
||||
},
|
||||
messageBus,
|
||||
false, // output is not markdown
|
||||
);
|
||||
}
|
||||
|
||||
protected override validateToolParamValues(
|
||||
params: ReadShellParams,
|
||||
): string | null {
|
||||
if (!params.pid || params.pid <= 0) {
|
||||
return 'PID must be a positive number.';
|
||||
}
|
||||
if (
|
||||
params.wait_seconds !== undefined &&
|
||||
(params.wait_seconds < 0 || params.wait_seconds > 30)
|
||||
) {
|
||||
return 'wait_seconds must be between 0 and 30.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: ReadShellParams,
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
): ToolInvocation<ReadShellParams, ToolResult> {
|
||||
return new ReadShellToolInvocation(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -149,8 +149,6 @@ describe('ShellTool', () => {
|
||||
getShellBackgroundCompletionBehavior: vi.fn().mockReturnValue('silent'),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
getInteractiveShellMode: vi.fn().mockReturnValue('off'),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
sanitizationConfig: {},
|
||||
get sandboxManager() {
|
||||
return mockSandboxManager;
|
||||
@@ -424,7 +422,7 @@ describe('ShellTool', () => {
|
||||
|
||||
expect(mockShellBackground).toHaveBeenCalledWith(
|
||||
12345,
|
||||
'test-session-id',
|
||||
'default',
|
||||
'sleep 10',
|
||||
);
|
||||
|
||||
@@ -668,7 +666,7 @@ describe('ShellTool', () => {
|
||||
|
||||
expect(mockShellBackground).toHaveBeenCalledWith(
|
||||
12345,
|
||||
'test-session-id',
|
||||
'default',
|
||||
'sleep 10',
|
||||
);
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { summarizeToolOutput } from '../utils/summarizer.js';
|
||||
import { formatShellOutput } from './shellOutputFormatter.js';
|
||||
import {
|
||||
ShellExecutionService,
|
||||
type ShellOutputEvent,
|
||||
@@ -79,7 +78,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
ToolResult
|
||||
> {
|
||||
private proactivePermissionsConfirmed?: SandboxPermissions;
|
||||
private _autoPromoteTimer?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
@@ -225,12 +223,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
override getExplanation(): string {
|
||||
let explanation = this.getContextualDetails().trim();
|
||||
const isAiMode = this.context.config.getInteractiveShellMode() === 'ai';
|
||||
if (isAiMode) {
|
||||
explanation += ` [auto-background after 3s]`;
|
||||
}
|
||||
return explanation;
|
||||
return this.getContextualDetails().trim();
|
||||
}
|
||||
|
||||
override getPolicyUpdateOptions(
|
||||
@@ -504,26 +497,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
}, timeoutMs);
|
||||
};
|
||||
|
||||
let currentPid: number | undefined;
|
||||
const isAiMode = this.context.config.getInteractiveShellMode() === 'ai';
|
||||
const shouldAutoPromote = isAiMode;
|
||||
const waitMs = isAiMode ? 3000 : 0;
|
||||
|
||||
const resetAutoPromoteTimer = () => {
|
||||
if (shouldAutoPromote && currentPid) {
|
||||
if (this._autoPromoteTimer) clearTimeout(this._autoPromoteTimer);
|
||||
this._autoPromoteTimer = setTimeout(() => {
|
||||
const sessionId =
|
||||
this.context.config?.getSessionId?.() ?? 'default';
|
||||
ShellExecutionService.background(
|
||||
currentPid!,
|
||||
sessionId,
|
||||
strippedCommand,
|
||||
);
|
||||
}, waitMs);
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
timeoutController.signal.addEventListener('abort', onAbort, {
|
||||
once: true,
|
||||
@@ -538,7 +511,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
cwd,
|
||||
(event: ShellOutputEvent) => {
|
||||
resetTimeout(); // Reset timeout on any event
|
||||
resetAutoPromoteTimer(); // Reset auto-promote on any event
|
||||
if (!updateOutput) {
|
||||
return;
|
||||
}
|
||||
@@ -610,7 +582,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
backgroundCompletionBehavior:
|
||||
this.context.config.getShellBackgroundCompletionBehavior(),
|
||||
originalCommand: strippedCommand,
|
||||
autoPromoteTimeoutMs: shouldAutoPromote ? waitMs : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -647,11 +618,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// In AI mode, set up auto-promotion timer.
|
||||
// When the timer fires, promote to background instead of cancelling.
|
||||
currentPid = pid;
|
||||
resetAutoPromoteTimer();
|
||||
}
|
||||
|
||||
const result = await resultPromise;
|
||||
@@ -692,73 +658,95 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
}
|
||||
|
||||
let data: BackgroundExecutionData | undefined;
|
||||
|
||||
let llmContent = '';
|
||||
let timeoutMessage = '';
|
||||
if (result.aborted) {
|
||||
if (timeoutController.signal.aborted) {
|
||||
timeoutMessage = `Command was automatically cancelled because it exceeded the timeout of ${(
|
||||
timeoutMs / 60000
|
||||
).toFixed(1)} minutes without output.`;
|
||||
llmContent = timeoutMessage;
|
||||
} else {
|
||||
llmContent =
|
||||
'Command was cancelled by user before it could complete.';
|
||||
}
|
||||
}
|
||||
|
||||
const formatterOutput = formatShellOutput({
|
||||
params: this.params,
|
||||
result,
|
||||
debugMode: this.context.config.getDebugMode(),
|
||||
backgroundPIDs,
|
||||
isAiMode,
|
||||
timeoutMessage,
|
||||
});
|
||||
|
||||
let data: BackgroundExecutionData | undefined;
|
||||
data = formatterOutput.data as BackgroundExecutionData | undefined;
|
||||
let returnDisplay: string | AnsiOutput = formatterOutput.returnDisplay;
|
||||
let llmContent = formatterOutput.llmContent;
|
||||
|
||||
if (!this.context.config.getDebugMode()) {
|
||||
if (
|
||||
!this.params.is_background &&
|
||||
!result.backgrounded &&
|
||||
!result.aborted
|
||||
) {
|
||||
if (result.output.trim() || result.ansiOutput) {
|
||||
returnDisplay =
|
||||
result.ansiOutput && result.ansiOutput.length > 0
|
||||
? result.ansiOutput
|
||||
: result.output;
|
||||
} else {
|
||||
if (result.signal) {
|
||||
returnDisplay = `Command terminated by signal: ${result.signal}`;
|
||||
} else if (result.error) {
|
||||
returnDisplay = `Command failed: ${getErrorMessage(result.error)}`;
|
||||
} else if (result.exitCode !== null && result.exitCode !== 0) {
|
||||
returnDisplay = `Command exited with code: ${result.exitCode}`;
|
||||
}
|
||||
}
|
||||
if (result.output.trim()) {
|
||||
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
|
||||
} else {
|
||||
llmContent += ' There was no output before it was cancelled.';
|
||||
}
|
||||
}
|
||||
|
||||
// Replace wrapper command with actual command in error messages
|
||||
if (result.error && !result.aborted) {
|
||||
llmContent = llmContent.replaceAll(
|
||||
commandToExecute,
|
||||
this.params.command,
|
||||
);
|
||||
}
|
||||
|
||||
// Update data with specific things needed by ShellTool
|
||||
if (this.params.is_background || result.backgrounded) {
|
||||
} else if (this.params.is_background || result.backgrounded) {
|
||||
llmContent = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
|
||||
data = {
|
||||
...data,
|
||||
initialOutput: result.output,
|
||||
pid: result.pid!,
|
||||
pid: result.pid,
|
||||
command: this.params.command,
|
||||
initialOutput: result.output,
|
||||
};
|
||||
} else if (result.exitCode !== null && result.exitCode !== 0) {
|
||||
data = {
|
||||
exitCode: result.exitCode,
|
||||
isError: true,
|
||||
} as BackgroundExecutionData;
|
||||
} else {
|
||||
// Create a formatted error string for display, replacing the wrapper command
|
||||
// with the user-facing command.
|
||||
const llmContentParts = [`Output: ${result.output || '(empty)'}`];
|
||||
|
||||
if (result.error) {
|
||||
const finalError = result.error.message.replaceAll(
|
||||
commandToExecute,
|
||||
this.params.command,
|
||||
);
|
||||
llmContentParts.push(`Error: ${finalError}`);
|
||||
}
|
||||
|
||||
if (result.exitCode !== null && result.exitCode !== 0) {
|
||||
llmContentParts.push(`Exit Code: ${result.exitCode}`);
|
||||
data = {
|
||||
exitCode: result.exitCode,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
llmContentParts.push(`Signal: ${result.signal}`);
|
||||
}
|
||||
if (backgroundPIDs.length) {
|
||||
llmContentParts.push(`Background PIDs: ${backgroundPIDs.join(', ')}`);
|
||||
}
|
||||
if (result.pid) {
|
||||
llmContentParts.push(`Process Group PGID: ${result.pid}`);
|
||||
}
|
||||
|
||||
llmContent = llmContentParts.join('\n');
|
||||
}
|
||||
|
||||
let returnDisplay: string | AnsiOutput = '';
|
||||
if (this.context.config.getDebugMode()) {
|
||||
returnDisplay = llmContent;
|
||||
} else {
|
||||
if (this.params.is_background || result.backgrounded) {
|
||||
returnDisplay = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
|
||||
} else if (result.aborted) {
|
||||
const cancelMsg = timeoutMessage || 'Command cancelled by user.';
|
||||
if (result.output.trim()) {
|
||||
returnDisplay = `${cancelMsg}\n\nOutput before cancellation:\n${result.output}`;
|
||||
} else {
|
||||
returnDisplay = cancelMsg;
|
||||
}
|
||||
} else if (result.output.trim() || result.ansiOutput) {
|
||||
returnDisplay =
|
||||
result.ansiOutput && result.ansiOutput.length > 0
|
||||
? result.ansiOutput
|
||||
: result.output;
|
||||
} else {
|
||||
if (result.signal) {
|
||||
returnDisplay = `Command terminated by signal: ${result.signal}`;
|
||||
} else if (result.error) {
|
||||
returnDisplay = `Command failed: ${getErrorMessage(result.error)}`;
|
||||
} else if (result.exitCode !== null && result.exitCode !== 0) {
|
||||
returnDisplay = `Command exited with code: ${result.exitCode}`;
|
||||
}
|
||||
// If output is empty and command succeeded (code 0, no error/signal/abort),
|
||||
// returnDisplay will remain empty, which is fine.
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristic Sandbox Denial Detection
|
||||
@@ -941,8 +929,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
};
|
||||
} finally {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
const autoTimer = this._autoPromoteTimer;
|
||||
if (autoTimer) clearTimeout(autoTimer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
timeoutController.signal.removeEventListener('abort', onAbort);
|
||||
try {
|
||||
@@ -1021,7 +1007,6 @@ export class ShellTool extends BaseDeclarativeTool<
|
||||
this.context.config.getEnableInteractiveShell(),
|
||||
this.context.config.getEnableShellOutputEfficiency(),
|
||||
this.context.config.getSandboxEnabled(),
|
||||
this.context.config.getInteractiveShellMode(),
|
||||
);
|
||||
return resolveToolDeclaration(definition, modelId);
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type ShellExecutionResult } from '../services/shellExecutionService.js';
|
||||
import { type ShellToolParams } from './shell.js';
|
||||
|
||||
export interface FormatShellOutputOptions {
|
||||
params: ShellToolParams;
|
||||
result: ShellExecutionResult;
|
||||
debugMode: boolean;
|
||||
timeoutMessage?: string;
|
||||
backgroundPIDs: number[];
|
||||
summarizedOutput?: string;
|
||||
isAiMode: boolean;
|
||||
}
|
||||
|
||||
export interface FormattedShellOutput {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function formatShellOutput(
|
||||
options: FormatShellOutputOptions,
|
||||
): FormattedShellOutput {
|
||||
const {
|
||||
params,
|
||||
result,
|
||||
debugMode,
|
||||
timeoutMessage,
|
||||
backgroundPIDs,
|
||||
summarizedOutput,
|
||||
} = options;
|
||||
|
||||
let llmContent = '';
|
||||
let data: Record<string, unknown> = {};
|
||||
|
||||
if (result.aborted) {
|
||||
llmContent = timeoutMessage || 'Command cancelled by user.';
|
||||
if (result.output.trim()) {
|
||||
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
|
||||
} else {
|
||||
llmContent += ' There was no output before it was cancelled.';
|
||||
}
|
||||
} else if (params.is_background || result.backgrounded) {
|
||||
const isAutoPromoted = result.backgrounded && !params.is_background;
|
||||
if (isAutoPromoted) {
|
||||
llmContent = `Command auto-promoted to background (PID: ${result.pid}). The process is still running. To check its screen state, call the read_shell tool with pid ${result.pid}. To send input or keystrokes, call the write_to_shell tool with pid ${result.pid}. If the process does not exit on its own when done, kill it with write_to_shell using special_keys=["Ctrl-C"].`;
|
||||
} else {
|
||||
llmContent = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
|
||||
}
|
||||
data = {
|
||||
pid: result.pid,
|
||||
command: params.command,
|
||||
directory: params.dir_path,
|
||||
backgrounded: true,
|
||||
};
|
||||
} else {
|
||||
const llmContentParts: string[] = [];
|
||||
|
||||
let content = summarizedOutput ?? result.output.trim();
|
||||
if (!content) {
|
||||
content = '(empty)';
|
||||
}
|
||||
|
||||
llmContentParts.push(`Output: ${content}`);
|
||||
|
||||
if (result.error) {
|
||||
llmContentParts.push(`Error: ${result.error.message}`);
|
||||
}
|
||||
|
||||
if (result.exitCode !== null && result.exitCode !== 0) {
|
||||
llmContentParts.push(`Exit Code: ${result.exitCode}`);
|
||||
}
|
||||
if (result.signal !== null) {
|
||||
llmContentParts.push(`Signal: ${result.signal}`);
|
||||
}
|
||||
if (backgroundPIDs.length) {
|
||||
llmContentParts.push(`Background PIDs: ${backgroundPIDs.join(', ')}`);
|
||||
}
|
||||
if (result.pid) {
|
||||
llmContentParts.push(`Process Group PGID: ${result.pid}`);
|
||||
}
|
||||
|
||||
llmContent = llmContentParts.join('\n');
|
||||
}
|
||||
|
||||
let returnDisplay = '';
|
||||
if (debugMode) {
|
||||
returnDisplay = llmContent;
|
||||
} else {
|
||||
if (params.is_background || result.backgrounded) {
|
||||
const isAutoPromotedDisplay =
|
||||
result.backgrounded && !params.is_background;
|
||||
if (isAutoPromotedDisplay) {
|
||||
returnDisplay = `Command auto-promoted to background (PID: ${result.pid}).`;
|
||||
} else {
|
||||
returnDisplay = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
|
||||
}
|
||||
} else if (result.aborted) {
|
||||
const cancelMsg = timeoutMessage || 'Command cancelled by user.';
|
||||
if (result.output.trim()) {
|
||||
returnDisplay = `${cancelMsg}\n\nOutput before cancellation:\n${result.output}`;
|
||||
} else {
|
||||
returnDisplay = cancelMsg;
|
||||
}
|
||||
} else if (result.error) {
|
||||
returnDisplay = `Command failed: ${result.error.message}`;
|
||||
} else if (result.exitCode !== 0 && result.exitCode !== null) {
|
||||
returnDisplay = `Command exited with code ${result.exitCode}`;
|
||||
if (result.output.trim()) {
|
||||
returnDisplay += `\n\n${result.output}`;
|
||||
}
|
||||
} else if (summarizedOutput) {
|
||||
returnDisplay = `Command succeeded. Output summarized:\n${summarizedOutput}`;
|
||||
} else {
|
||||
returnDisplay = `Command succeeded.`;
|
||||
if (result.output.trim()) {
|
||||
returnDisplay += `\n\n${result.output}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { llmContent, returnDisplay, data };
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
LS_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
WRITE_TO_SHELL_TOOL_NAME,
|
||||
READ_SHELL_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
@@ -54,12 +52,6 @@ import {
|
||||
LS_PARAM_IGNORE,
|
||||
SHELL_PARAM_COMMAND,
|
||||
SHELL_PARAM_IS_BACKGROUND,
|
||||
SHELL_PARAM_WAIT_SECONDS,
|
||||
WRITE_TO_SHELL_PARAM_PID,
|
||||
WRITE_TO_SHELL_PARAM_INPUT,
|
||||
WRITE_TO_SHELL_PARAM_SPECIAL_KEYS,
|
||||
READ_SHELL_PARAM_PID,
|
||||
READ_SHELL_PARAM_WAIT_SECONDS,
|
||||
WEB_SEARCH_PARAM_QUERY,
|
||||
WEB_FETCH_PARAM_PROMPT,
|
||||
READ_MANY_PARAM_INCLUDE,
|
||||
@@ -98,8 +90,6 @@ export {
|
||||
LS_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
WRITE_TO_SHELL_TOOL_NAME,
|
||||
READ_SHELL_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
@@ -146,12 +136,6 @@ export {
|
||||
LS_PARAM_IGNORE,
|
||||
SHELL_PARAM_COMMAND,
|
||||
SHELL_PARAM_IS_BACKGROUND,
|
||||
SHELL_PARAM_WAIT_SECONDS,
|
||||
WRITE_TO_SHELL_PARAM_PID,
|
||||
WRITE_TO_SHELL_PARAM_INPUT,
|
||||
WRITE_TO_SHELL_PARAM_SPECIAL_KEYS,
|
||||
READ_SHELL_PARAM_PID,
|
||||
READ_SHELL_PARAM_WAIT_SECONDS,
|
||||
WEB_SEARCH_PARAM_QUERY,
|
||||
WEB_FETCH_PARAM_PROMPT,
|
||||
READ_MANY_PARAM_INCLUDE,
|
||||
@@ -195,7 +179,6 @@ export const TOOLS_REQUIRING_NARROWING = new Set([
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
WRITE_TO_SHELL_TOOL_NAME,
|
||||
]);
|
||||
|
||||
export const TRACKER_CREATE_TASK_TOOL_NAME = 'tracker_create_task';
|
||||
@@ -268,8 +251,6 @@ export const ALL_BUILTIN_TOOL_NAMES = [
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
SHELL_TOOL_NAME,
|
||||
WRITE_TO_SHELL_TOOL_NAME,
|
||||
READ_SHELL_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
READ_MANY_FILES_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type ToolConfirmationOutcome,
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolInvocation,
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
type ToolExecuteConfirmationDetails,
|
||||
} from './tools.js';
|
||||
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||||
import {
|
||||
WRITE_TO_SHELL_TOOL_NAME,
|
||||
WRITE_TO_SHELL_PARAM_PID,
|
||||
WRITE_TO_SHELL_PARAM_INPUT,
|
||||
WRITE_TO_SHELL_PARAM_SPECIAL_KEYS,
|
||||
} from './tool-names.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
|
||||
/**
|
||||
* Mapping of named special keys to their ANSI escape sequences.
|
||||
*/
|
||||
const SPECIAL_KEY_MAP: Record<string, string> = {
|
||||
Enter: '\r',
|
||||
Tab: '\t',
|
||||
Up: '\x1b[A',
|
||||
Down: '\x1b[B',
|
||||
Left: '\x1b[D',
|
||||
Right: '\x1b[C',
|
||||
Escape: '\x1b',
|
||||
Backspace: '\x7f',
|
||||
'Ctrl-C': '\x03',
|
||||
'Ctrl-D': '\x04',
|
||||
'Ctrl-Z': '\x1a',
|
||||
Space: ' ',
|
||||
Delete: '\x1b[3~',
|
||||
Home: '\x1b[H',
|
||||
End: '\x1b[F',
|
||||
};
|
||||
|
||||
const VALID_SPECIAL_KEYS = Object.keys(SPECIAL_KEY_MAP);
|
||||
|
||||
/** Delay in ms to wait after writing input for the process to react. */
|
||||
const POST_INPUT_DELAY_MS = 150;
|
||||
|
||||
export interface WriteToShellParams {
|
||||
pid: number;
|
||||
input?: string;
|
||||
special_keys?: string[];
|
||||
}
|
||||
|
||||
export class WriteToShellToolInvocation extends BaseToolInvocation<
|
||||
WriteToShellParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
params: WriteToShellParams,
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
) {
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const parts: string[] = [`write to shell PID ${this.params.pid}`];
|
||||
if (this.params.input) {
|
||||
const display =
|
||||
this.params.input.length > 50
|
||||
? `${this.params.input.substring(0, 50)}...`
|
||||
: this.params.input;
|
||||
parts.push(`input: "${display}"`);
|
||||
}
|
||||
if (this.params.special_keys?.length) {
|
||||
parts.push(`keys: [${this.params.special_keys.join(', ')}]`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
protected override async getConfirmationDetails(
|
||||
_abortSignal: AbortSignal,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
const confirmationDetails: ToolExecuteConfirmationDetails = {
|
||||
type: 'exec',
|
||||
title: 'Confirm Shell Input',
|
||||
command: this.getDescription(),
|
||||
rootCommand: 'write_to_shell',
|
||||
rootCommands: ['write_to_shell'],
|
||||
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
|
||||
// Policy updates handled centrally
|
||||
},
|
||||
};
|
||||
return confirmationDetails;
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
const { pid, input, special_keys } = this.params;
|
||||
|
||||
// Validate the PID is active
|
||||
if (!ShellExecutionService.isPtyActive(pid)) {
|
||||
return {
|
||||
llmContent: `Error: No active process found with PID ${pid}. The process may have exited.`,
|
||||
returnDisplay: `No active process with PID ${pid}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate special keys
|
||||
if (special_keys?.length) {
|
||||
const invalidKeys = special_keys.filter(
|
||||
(k) => !VALID_SPECIAL_KEYS.includes(k),
|
||||
);
|
||||
if (invalidKeys.length > 0) {
|
||||
return {
|
||||
llmContent: `Error: Invalid special keys: ${invalidKeys.join(', ')}. Valid keys are: ${VALID_SPECIAL_KEYS.join(', ')}`,
|
||||
returnDisplay: `Invalid special keys: ${invalidKeys.join(', ')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Send text input
|
||||
if (input) {
|
||||
ShellExecutionService.writeToPty(pid, input);
|
||||
}
|
||||
|
||||
// Send special keys
|
||||
if (special_keys?.length) {
|
||||
for (const key of special_keys) {
|
||||
const sequence = SPECIAL_KEY_MAP[key];
|
||||
if (sequence) {
|
||||
ShellExecutionService.writeToPty(pid, sequence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait briefly for the process to react
|
||||
await new Promise((resolve) => setTimeout(resolve, POST_INPUT_DELAY_MS));
|
||||
|
||||
// Read the screen after writing
|
||||
const screen = ShellExecutionService.readScreen(pid);
|
||||
if (screen === null) {
|
||||
return {
|
||||
llmContent: `Input sent, but the process (PID ${pid}) has exited.`,
|
||||
returnDisplay: `Process exited after input.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: `Input sent to PID ${pid}. Current screen:\n${screen}`,
|
||||
returnDisplay: `Input sent to PID ${pid}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteToShellTool extends BaseDeclarativeTool<
|
||||
WriteToShellParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = WRITE_TO_SHELL_TOOL_NAME;
|
||||
|
||||
constructor(messageBus: MessageBus) {
|
||||
super(
|
||||
WriteToShellTool.Name,
|
||||
'WriteToShell',
|
||||
'Sends input to a running background shell process. Use this to interact with TUI applications, REPLs, and interactive commands. After writing, the current screen state is returned. Works with processes that were auto-promoted to background or started with is_background=true.',
|
||||
Kind.Execute,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
[WRITE_TO_SHELL_PARAM_PID]: {
|
||||
type: 'number',
|
||||
description:
|
||||
'The PID of the background process to write to. Obtained from a previous run_shell_command call that was auto-promoted to background or started with is_background=true.',
|
||||
},
|
||||
[WRITE_TO_SHELL_PARAM_INPUT]: {
|
||||
type: 'string',
|
||||
description:
|
||||
'(OPTIONAL) Text to send to the process. This is literal text typed into the terminal.',
|
||||
},
|
||||
[WRITE_TO_SHELL_PARAM_SPECIAL_KEYS]: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: VALID_SPECIAL_KEYS,
|
||||
},
|
||||
description:
|
||||
'(OPTIONAL) Named special keys to send after the input text. Each key is sent in sequence. Examples: ["Enter"], ["Tab"], ["Up", "Enter"], ["Ctrl-C"].',
|
||||
},
|
||||
},
|
||||
required: [WRITE_TO_SHELL_PARAM_PID],
|
||||
},
|
||||
messageBus,
|
||||
false, // output is not markdown
|
||||
);
|
||||
}
|
||||
|
||||
protected override validateToolParamValues(
|
||||
params: WriteToShellParams,
|
||||
): string | null {
|
||||
if (!params.pid || params.pid <= 0) {
|
||||
return 'PID must be a positive number.';
|
||||
}
|
||||
if (
|
||||
!params.input &&
|
||||
(!params.special_keys || !params.special_keys.length)
|
||||
) {
|
||||
return 'At least one of input or special_keys must be provided.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: WriteToShellParams,
|
||||
messageBus: MessageBus,
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
): ToolInvocation<WriteToShellParams, ToolResult> {
|
||||
return new WriteToShellToolInvocation(
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,8 @@ export async function resolveExecutable(
|
||||
let bashLanguage: Language | null = null;
|
||||
let treeSitterInitialization: Promise<void> | null = null;
|
||||
let treeSitterInitializationError: Error | null = null;
|
||||
let parserState: 'uninitialized' | 'initializing' | 'initialized' | 'error' =
|
||||
'uninitialized';
|
||||
|
||||
class ShellParserInitializationError extends Error {
|
||||
constructor(cause: Error) {
|
||||
@@ -163,16 +165,37 @@ async function loadBashLanguage(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function initializeShellParsers(): Promise<void> {
|
||||
if (!treeSitterInitialization) {
|
||||
treeSitterInitialization = loadBashLanguage().catch((error) => {
|
||||
treeSitterInitialization = null;
|
||||
// Log the error but don't throw, allowing the application to fall back to safe defaults (ASK_USER)
|
||||
// or regex checks where appropriate.
|
||||
debugLogger.debug('Failed to initialize shell parsers:', error);
|
||||
if (parserState === 'uninitialized') {
|
||||
parserState = 'initializing';
|
||||
let timerId: NodeJS.Timeout | undefined;
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
timerId = setTimeout(
|
||||
() => reject(new Error('Tree-sitter initialization timed out')),
|
||||
30000,
|
||||
);
|
||||
});
|
||||
treeSitterInitialization = Promise.race([
|
||||
loadBashLanguage(),
|
||||
timeoutPromise,
|
||||
])
|
||||
.finally(() => {
|
||||
if (timerId) clearTimeout(timerId);
|
||||
})
|
||||
.then(() => {
|
||||
parserState = 'initialized';
|
||||
})
|
||||
.catch((error) => {
|
||||
parserState = 'error';
|
||||
treeSitterInitialization = null;
|
||||
// Log the error but don't throw, allowing the application to fall back to safe defaults (ASK_USER)
|
||||
// or regex checks where appropriate.
|
||||
debugLogger.debug('Failed to initialize shell parsers:', error);
|
||||
});
|
||||
}
|
||||
|
||||
await treeSitterInitialization;
|
||||
if (treeSitterInitialization) {
|
||||
await treeSitterInitialization;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedCommandDetail {
|
||||
@@ -847,34 +870,40 @@ export const spawnAsync = async (
|
||||
|
||||
const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(finalCommand, finalArgs, {
|
||||
...options,
|
||||
env: finalEnv,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(finalCommand, finalArgs, {
|
||||
...options,
|
||||
env: finalEnv,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
reject(new Error(`Command failed with exit code ${code}:\n${stderr}`));
|
||||
}
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
reject(
|
||||
new Error(`Command failed with exit code ${code}:\n${stderr}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
reject(err);
|
||||
child.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
prepared.cleanup?.();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -902,109 +931,115 @@ export async function* execStreaming(
|
||||
env: options?.env ?? process.env,
|
||||
});
|
||||
|
||||
const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared;
|
||||
|
||||
const child = spawn(finalCommand, finalArgs, {
|
||||
...options,
|
||||
env: finalEnv,
|
||||
// ensure we don't open a window on windows if possible/relevant
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: child.stdout,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
const errorChunks: Buffer[] = [];
|
||||
let stderrTotalBytes = 0;
|
||||
const MAX_STDERR_BYTES = 20 * 1024; // 20KB limit
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
if (stderrTotalBytes < MAX_STDERR_BYTES) {
|
||||
errorChunks.push(chunk);
|
||||
stderrTotalBytes += chunk.length;
|
||||
}
|
||||
});
|
||||
|
||||
let error: Error | null = null;
|
||||
child.on('error', (err) => {
|
||||
error = err;
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
// If manually aborted by signal, we kill immediately.
|
||||
if (!child.killed) child.kill();
|
||||
};
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
options?.signal?.addEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
if (options?.signal?.aborted) break;
|
||||
yield line;
|
||||
}
|
||||
finished = true;
|
||||
} finally {
|
||||
rl.close();
|
||||
options?.signal?.removeEventListener('abort', onAbort);
|
||||
const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared;
|
||||
|
||||
// Ensure process is killed when the generator is closed (consumer breaks loop)
|
||||
let killedByGenerator = false;
|
||||
if (!finished && child.exitCode === null && !child.killed) {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore error if process is already dead
|
||||
const child = spawn(finalCommand, finalArgs, {
|
||||
...options,
|
||||
env: finalEnv,
|
||||
// ensure we don't open a window on windows if possible/relevant
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: child.stdout,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
const errorChunks: Buffer[] = [];
|
||||
let stderrTotalBytes = 0;
|
||||
const MAX_STDERR_BYTES = 20 * 1024; // 20KB limit
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
if (stderrTotalBytes < MAX_STDERR_BYTES) {
|
||||
errorChunks.push(chunk);
|
||||
stderrTotalBytes += chunk.length;
|
||||
}
|
||||
killedByGenerator = true;
|
||||
});
|
||||
|
||||
let error: Error | null = null;
|
||||
child.on('error', (err) => {
|
||||
error = err;
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
// If manually aborted by signal, we kill immediately.
|
||||
if (!child.killed) child.kill();
|
||||
};
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
options?.signal?.addEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
// Ensure we wait for the process to exit to check codes
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// If an error occurred before we got here (e.g. spawn failure), reject immediately.
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
let finished = false;
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
if (options?.signal?.aborted) break;
|
||||
yield line;
|
||||
}
|
||||
finished = true;
|
||||
} finally {
|
||||
rl.close();
|
||||
options?.signal?.removeEventListener('abort', onAbort);
|
||||
|
||||
// Ensure process is killed when the generator is closed (consumer breaks loop)
|
||||
let killedByGenerator = false;
|
||||
if (!finished && child.exitCode === null && !child.killed) {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore error if process is already dead
|
||||
}
|
||||
killedByGenerator = true;
|
||||
}
|
||||
|
||||
function checkExit(code: number | null) {
|
||||
// If we aborted or killed it manually, we treat it as success (stop waiting)
|
||||
if (options?.signal?.aborted || killedByGenerator) {
|
||||
resolve();
|
||||
// Ensure we wait for the process to exit to check codes
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// If an error occurred before we got here (e.g. spawn failure), reject immediately.
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = options?.allowedExitCodes ?? [0];
|
||||
if (code !== null && allowed.includes(code)) {
|
||||
resolve();
|
||||
} else {
|
||||
// If we have an accumulated error or explicit error event
|
||||
if (error) reject(error);
|
||||
else {
|
||||
const stderr = Buffer.concat(errorChunks).toString('utf8');
|
||||
const truncatedMsg =
|
||||
stderrTotalBytes >= MAX_STDERR_BYTES ? '...[truncated]' : '';
|
||||
reject(
|
||||
new Error(
|
||||
`Process exited with code ${code}: ${stderr}${truncatedMsg}`,
|
||||
),
|
||||
);
|
||||
function checkExit(code: number | null) {
|
||||
// If we aborted or killed it manually, we treat it as success (stop waiting)
|
||||
if (options?.signal?.aborted || killedByGenerator) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = options?.allowedExitCodes ?? [0];
|
||||
if (code !== null && allowed.includes(code)) {
|
||||
resolve();
|
||||
} else {
|
||||
// If we have an accumulated error or explicit error event
|
||||
if (error) reject(error);
|
||||
else {
|
||||
const stderr = Buffer.concat(errorChunks).toString('utf8');
|
||||
const truncatedMsg =
|
||||
stderrTotalBytes >= MAX_STDERR_BYTES ? '...[truncated]' : '';
|
||||
reject(
|
||||
new Error(
|
||||
`Process exited with code ${code}: ${stderr}${truncatedMsg}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (child.exitCode !== null) {
|
||||
checkExit(child.exitCode);
|
||||
} else {
|
||||
child.on('close', (code) => checkExit(code));
|
||||
child.on('error', (err) => reject(err));
|
||||
}
|
||||
});
|
||||
if (child.exitCode !== null) {
|
||||
checkExit(child.exitCode);
|
||||
} else {
|
||||
child.on('close', (code) => checkExit(code));
|
||||
child.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
prepared.cleanup?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"asciichart": "^1.5.25",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
export * from './file-system-test-helpers.js';
|
||||
export * from './fixtures/agents.js';
|
||||
export * from './memory-baselines.js';
|
||||
export * from './memory-test-harness.js';
|
||||
export * from './mock-utils.js';
|
||||
export * from './test-mcp-server.js';
|
||||
export * from './test-rig.js';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Baseline entry for a single memory test scenario.
|
||||
*/
|
||||
export interface MemoryBaseline {
|
||||
heapUsedBytes: number;
|
||||
heapTotalBytes: number;
|
||||
rssBytes: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level structure of the baselines JSON file.
|
||||
*/
|
||||
export interface MemoryBaselineFile {
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
scenarios: Record<string, MemoryBaseline>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load baselines from a JSON file.
|
||||
* Returns an empty baseline file if the file does not exist yet.
|
||||
*/
|
||||
export function loadBaselines(path: string): MemoryBaselineFile {
|
||||
if (!existsSync(path)) {
|
||||
return {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
scenarios: {},
|
||||
};
|
||||
}
|
||||
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
return JSON.parse(content) as MemoryBaselineFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save baselines to a JSON file.
|
||||
*/
|
||||
export function saveBaselines(
|
||||
path: string,
|
||||
baselines: MemoryBaselineFile,
|
||||
): void {
|
||||
baselines.updatedAt = new Date().toISOString();
|
||||
writeFileSync(path, JSON.stringify(baselines, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update (or create) a single scenario baseline in the file.
|
||||
*/
|
||||
export function updateBaseline(
|
||||
path: string,
|
||||
scenarioName: string,
|
||||
measured: {
|
||||
heapUsedBytes: number;
|
||||
heapTotalBytes: number;
|
||||
rssBytes: number;
|
||||
},
|
||||
): void {
|
||||
const baselines = loadBaselines(path);
|
||||
baselines.scenarios[scenarioName] = {
|
||||
heapUsedBytes: measured.heapUsedBytes,
|
||||
heapTotalBytes: measured.heapTotalBytes,
|
||||
rssBytes: measured.rssBytes,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
saveBaselines(path, baselines);
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import v8 from 'node:v8';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { loadBaselines, updateBaseline } from './memory-baselines.js';
|
||||
import type { MemoryBaseline, MemoryBaselineFile } from './memory-baselines.js';
|
||||
|
||||
/** Configuration for asciichart plot function. */
|
||||
interface PlotConfig {
|
||||
height?: number;
|
||||
format?: (x: number) => string;
|
||||
}
|
||||
|
||||
/** Type for the asciichart plot function. */
|
||||
type PlotFn = (series: number[], config?: PlotConfig) => string;
|
||||
|
||||
/**
|
||||
* A single memory snapshot at a point in time.
|
||||
*/
|
||||
export interface MemorySnapshot {
|
||||
timestamp: number;
|
||||
label: string;
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
rss: number;
|
||||
external: number;
|
||||
arrayBuffers: number;
|
||||
heapSizeLimit: number;
|
||||
heapSpaces: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from running a memory test scenario.
|
||||
*/
|
||||
export interface MemoryTestResult {
|
||||
scenarioName: string;
|
||||
snapshots: MemorySnapshot[];
|
||||
peakHeapUsed: number;
|
||||
peakRss: number;
|
||||
finalHeapUsed: number;
|
||||
finalRss: number;
|
||||
baseline: MemoryBaseline | undefined;
|
||||
withinTolerance: boolean;
|
||||
deltaPercent: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the MemoryTestHarness.
|
||||
*/
|
||||
export interface MemoryTestHarnessOptions {
|
||||
/** Path to the baselines JSON file */
|
||||
baselinesPath: string;
|
||||
/** Default tolerance percentage (0-100). Default: 10 */
|
||||
defaultTolerancePercent?: number;
|
||||
/** Number of GC cycles to run before each snapshot. Default: 3 */
|
||||
gcCycles?: number;
|
||||
/** Delay in ms between GC cycles. Default: 100 */
|
||||
gcDelayMs?: number;
|
||||
/** Number of samples to take for median calculation. Default: 3 */
|
||||
sampleCount?: number;
|
||||
/** Pause in ms between samples. Default: 50 */
|
||||
samplePauseMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MemoryTestHarness provides infrastructure for running memory usage tests.
|
||||
*
|
||||
* It handles:
|
||||
* - Forcing V8 garbage collection to reduce noise
|
||||
* - Taking V8 heap snapshots for accurate memory measurement
|
||||
* - Comparing against baselines with configurable tolerance
|
||||
* - Generating ASCII chart reports of memory trends
|
||||
*/
|
||||
export class MemoryTestHarness {
|
||||
private baselines: MemoryBaselineFile;
|
||||
private readonly baselinesPath: string;
|
||||
private readonly defaultTolerancePercent: number;
|
||||
private readonly gcCycles: number;
|
||||
private readonly gcDelayMs: number;
|
||||
private readonly sampleCount: number;
|
||||
private readonly samplePauseMs: number;
|
||||
private allResults: MemoryTestResult[] = [];
|
||||
|
||||
constructor(options: MemoryTestHarnessOptions) {
|
||||
this.baselinesPath = options.baselinesPath;
|
||||
this.defaultTolerancePercent = options.defaultTolerancePercent ?? 10;
|
||||
this.gcCycles = options.gcCycles ?? 3;
|
||||
this.gcDelayMs = options.gcDelayMs ?? 100;
|
||||
this.sampleCount = options.sampleCount ?? 3;
|
||||
this.samplePauseMs = options.samplePauseMs ?? 50;
|
||||
this.baselines = loadBaselines(this.baselinesPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force garbage collection multiple times and take a V8 heap snapshot.
|
||||
* Forces GC multiple times with delays to allow weak references and
|
||||
* FinalizationRegistry callbacks to run, reducing measurement noise.
|
||||
*/
|
||||
async takeSnapshot(label: string = 'snapshot'): Promise<MemorySnapshot> {
|
||||
await this.forceGC();
|
||||
|
||||
const memUsage = process.memoryUsage();
|
||||
const heapStats = v8.getHeapStatistics();
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
label,
|
||||
heapUsed: memUsage.heapUsed,
|
||||
heapTotal: memUsage.heapTotal,
|
||||
rss: memUsage.rss,
|
||||
external: memUsage.external,
|
||||
arrayBuffers: memUsage.arrayBuffers,
|
||||
heapSizeLimit: heapStats.heap_size_limit,
|
||||
heapSpaces: v8.getHeapSpaceStatistics(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take multiple snapshot samples and return the median to reduce noise.
|
||||
*/
|
||||
async takeMedianSnapshot(
|
||||
label: string = 'median',
|
||||
count?: number,
|
||||
): Promise<MemorySnapshot> {
|
||||
const samples: MemorySnapshot[] = [];
|
||||
const numSamples = count ?? this.sampleCount;
|
||||
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
samples.push(await this.takeSnapshot(`${label}_sample_${i}`));
|
||||
if (i < numSamples - 1) {
|
||||
await sleep(this.samplePauseMs);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by heapUsed and take the median
|
||||
samples.sort((a, b) => a.heapUsed - b.heapUsed);
|
||||
const medianIdx = Math.floor(samples.length / 2);
|
||||
const median = samples[medianIdx]!;
|
||||
|
||||
return {
|
||||
...median,
|
||||
label,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a memory test scenario.
|
||||
*
|
||||
* Takes before/after snapshots around the scenario function, collects
|
||||
* intermediate snapshots if the scenario provides them, and compares
|
||||
* the result against the stored baseline.
|
||||
*
|
||||
* @param name - Scenario name (must match baseline key)
|
||||
* @param fn - Async function that executes the scenario. Receives a
|
||||
* `recordSnapshot` callback for recording intermediate snapshots.
|
||||
* @param tolerancePercent - Override default tolerance for this scenario
|
||||
*/
|
||||
async runScenario(
|
||||
name: string,
|
||||
fn: (
|
||||
recordSnapshot: (label: string) => Promise<MemorySnapshot>,
|
||||
) => Promise<void>,
|
||||
tolerancePercent?: number,
|
||||
): Promise<MemoryTestResult> {
|
||||
const tolerance = tolerancePercent ?? this.defaultTolerancePercent;
|
||||
const snapshots: MemorySnapshot[] = [];
|
||||
|
||||
// Record a callback for intermediate snapshots
|
||||
const recordSnapshot = async (label: string): Promise<MemorySnapshot> => {
|
||||
const snap = await this.takeMedianSnapshot(label);
|
||||
snapshots.push(snap);
|
||||
return snap;
|
||||
};
|
||||
|
||||
// Before snapshot
|
||||
const beforeSnap = await this.takeMedianSnapshot('before');
|
||||
snapshots.push(beforeSnap);
|
||||
|
||||
// Run the scenario
|
||||
await fn(recordSnapshot);
|
||||
|
||||
// After snapshot (median of multiple samples)
|
||||
const afterSnap = await this.takeMedianSnapshot('after');
|
||||
snapshots.push(afterSnap);
|
||||
|
||||
// Calculate peak values
|
||||
const peakHeapUsed = Math.max(...snapshots.map((s) => s.heapUsed));
|
||||
const peakRss = Math.max(...snapshots.map((s) => s.rss));
|
||||
|
||||
// Get baseline
|
||||
const baseline = this.baselines.scenarios[name];
|
||||
|
||||
// Determine if within tolerance
|
||||
let deltaPercent = 0;
|
||||
let withinTolerance = true;
|
||||
|
||||
if (baseline) {
|
||||
deltaPercent =
|
||||
((afterSnap.heapUsed - baseline.heapUsedBytes) /
|
||||
baseline.heapUsedBytes) *
|
||||
100;
|
||||
withinTolerance = deltaPercent <= tolerance;
|
||||
}
|
||||
|
||||
const result: MemoryTestResult = {
|
||||
scenarioName: name,
|
||||
snapshots,
|
||||
peakHeapUsed,
|
||||
peakRss,
|
||||
finalHeapUsed: afterSnap.heapUsed,
|
||||
finalRss: afterSnap.rss,
|
||||
baseline,
|
||||
withinTolerance,
|
||||
deltaPercent,
|
||||
};
|
||||
|
||||
this.allResults.push(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a scenario result is within the baseline tolerance.
|
||||
* Throws an assertion error with details if it exceeds the threshold.
|
||||
*/
|
||||
assertWithinBaseline(
|
||||
result: MemoryTestResult,
|
||||
tolerancePercent?: number,
|
||||
): void {
|
||||
const tolerance = tolerancePercent ?? this.defaultTolerancePercent;
|
||||
|
||||
if (!result.baseline) {
|
||||
console.warn(
|
||||
`⚠ No baseline found for "${result.scenarioName}". ` +
|
||||
`Run with UPDATE_MEMORY_BASELINES=true to create one. ` +
|
||||
`Measured: ${formatMB(result.finalHeapUsed)} heap used.`,
|
||||
);
|
||||
return; // Don't fail if no baseline exists yet
|
||||
}
|
||||
|
||||
const deltaPercent =
|
||||
((result.finalHeapUsed - result.baseline.heapUsedBytes) /
|
||||
result.baseline.heapUsedBytes) *
|
||||
100;
|
||||
|
||||
if (deltaPercent > tolerance) {
|
||||
throw new Error(
|
||||
`Memory regression detected for "${result.scenarioName}"!\n` +
|
||||
` Measured: ${formatMB(result.finalHeapUsed)} heap used\n` +
|
||||
` Baseline: ${formatMB(result.baseline.heapUsedBytes)} heap used\n` +
|
||||
` Delta: ${deltaPercent.toFixed(1)}% (tolerance: ${tolerance}%)\n` +
|
||||
` Peak heap: ${formatMB(result.peakHeapUsed)}\n` +
|
||||
` Peak RSS: ${formatMB(result.peakRss)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the baseline for a scenario with the current measured values.
|
||||
*/
|
||||
updateScenarioBaseline(result: MemoryTestResult): void {
|
||||
updateBaseline(this.baselinesPath, result.scenarioName, {
|
||||
heapUsedBytes: result.finalHeapUsed,
|
||||
heapTotalBytes:
|
||||
result.snapshots[result.snapshots.length - 1]?.heapTotal ?? 0,
|
||||
rssBytes: result.finalRss,
|
||||
});
|
||||
// Reload baselines after update
|
||||
this.baselines = loadBaselines(this.baselinesPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze snapshots to detect sustained leaks across 3 snapshots.
|
||||
* A leak is flagged if growth is observed in both phases for any heap space.
|
||||
*/
|
||||
analyzeSnapshots(
|
||||
snapshots: MemorySnapshot[],
|
||||
thresholdBytes: number = 1024 * 1024, // 1 MB
|
||||
): { leaked: boolean; message: string } {
|
||||
if (snapshots.length < 3) {
|
||||
return { leaked: false, message: 'Not enough snapshots to analyze' };
|
||||
}
|
||||
|
||||
const snap1 = snapshots[snapshots.length - 3];
|
||||
const snap2 = snapshots[snapshots.length - 2];
|
||||
const snap3 = snapshots[snapshots.length - 1];
|
||||
|
||||
if (!snap1 || !snap2 || !snap3) {
|
||||
return { leaked: false, message: 'Missing snapshots' };
|
||||
}
|
||||
|
||||
const spaceNames = new Set<string>();
|
||||
snap1.heapSpaces.forEach((s: any) => spaceNames.add(s.space_name));
|
||||
snap2.heapSpaces.forEach((s: any) => spaceNames.add(s.space_name));
|
||||
snap3.heapSpaces.forEach((s: any) => spaceNames.add(s.space_name));
|
||||
|
||||
let hasSustainedGrowth = false;
|
||||
const growthDetails: string[] = [];
|
||||
|
||||
for (const name of spaceNames) {
|
||||
const size1 =
|
||||
snap1.heapSpaces.find((s: any) => s.space_name === name)
|
||||
?.space_used_size ?? 0;
|
||||
const size2 =
|
||||
snap2.heapSpaces.find((s: any) => s.space_name === name)
|
||||
?.space_used_size ?? 0;
|
||||
const size3 =
|
||||
snap3.heapSpaces.find((s: any) => s.space_name === name)
|
||||
?.space_used_size ?? 0;
|
||||
|
||||
const growth1 = size2 - size1;
|
||||
const growth2 = size3 - size2;
|
||||
|
||||
if (growth1 > thresholdBytes && growth2 > thresholdBytes) {
|
||||
hasSustainedGrowth = true;
|
||||
growthDetails.push(
|
||||
`${name}: sustained growth (${formatMB(growth1)} -> ${formatMB(growth2)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let message = '';
|
||||
if (hasSustainedGrowth) {
|
||||
message =
|
||||
`Memory bloat detected in heap spaces:\n ` +
|
||||
growthDetails.join('\n ');
|
||||
} else {
|
||||
message = `No sustained growth detected in any heap space above threshold.`;
|
||||
}
|
||||
|
||||
return { leaked: hasSustainedGrowth, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that memory returns to a baseline level after a peak.
|
||||
* Useful for verifying that large tool outputs are not retained.
|
||||
*/
|
||||
assertMemoryReturnsToBaseline(
|
||||
snapshots: MemorySnapshot[],
|
||||
tolerancePercent: number = 10,
|
||||
): void {
|
||||
if (snapshots.length < 3) {
|
||||
throw new Error('Need at least 3 snapshots to check return to baseline');
|
||||
}
|
||||
|
||||
const baseline = snapshots[0]; // Assume first is baseline
|
||||
const peak = snapshots.reduce(
|
||||
(max, s) => (s.heapUsed > max.heapUsed ? s : max),
|
||||
snapshots[0],
|
||||
);
|
||||
const final = snapshots[snapshots.length - 1];
|
||||
|
||||
if (!baseline || !peak || !final) {
|
||||
throw new Error('Missing snapshots for return to baseline check');
|
||||
}
|
||||
|
||||
const tolerance = baseline.heapUsed * (tolerancePercent / 100);
|
||||
const delta = final.heapUsed - baseline.heapUsed;
|
||||
|
||||
if (delta > tolerance) {
|
||||
throw new Error(
|
||||
`Memory did not return to baseline!\n` +
|
||||
` Baseline: ${formatMB(baseline.heapUsed)}\n` +
|
||||
` Peak: ${formatMB(peak.heapUsed)}\n` +
|
||||
` Final: ${formatMB(final.heapUsed)}\n` +
|
||||
` Delta: ${formatMB(delta)} (tolerance: ${formatMB(tolerance)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a report with ASCII charts and summary table.
|
||||
* Uses the `asciichart` library for terminal visualization.
|
||||
*/
|
||||
async generateReport(results?: MemoryTestResult[]): Promise<string> {
|
||||
const resultsToReport = results ?? this.allResults;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('');
|
||||
lines.push('═══════════════════════════════════════════════════');
|
||||
lines.push(' MEMORY USAGE TEST REPORT');
|
||||
lines.push('═══════════════════════════════════════════════════');
|
||||
lines.push('');
|
||||
|
||||
for (const result of resultsToReport) {
|
||||
const measured = formatMB(result.finalHeapUsed);
|
||||
const baseline = result.baseline
|
||||
? formatMB(result.baseline.heapUsedBytes)
|
||||
: 'N/A';
|
||||
const delta = result.baseline
|
||||
? `${result.deltaPercent >= 0 ? '+' : ''}${result.deltaPercent.toFixed(1)}%`
|
||||
: 'N/A';
|
||||
const status = !result.baseline
|
||||
? 'NEW'
|
||||
: result.withinTolerance
|
||||
? '✅'
|
||||
: '❌';
|
||||
|
||||
lines.push(
|
||||
`${result.scenarioName}: ${measured} (Baseline: ${baseline}, Delta: ${delta}) ${status}`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Generate ASCII chart for each scenario with multiple snapshots
|
||||
try {
|
||||
// @ts-expect-error - asciichart may not have types
|
||||
const asciichart = (await import('asciichart')) as {
|
||||
default?: { plot?: PlotFn };
|
||||
plot?: PlotFn;
|
||||
};
|
||||
const plot: PlotFn | undefined =
|
||||
asciichart.default?.plot ?? asciichart.plot;
|
||||
|
||||
for (const result of resultsToReport) {
|
||||
if (result.snapshots.length > 2) {
|
||||
lines.push(`📈 Memory trend: ${result.scenarioName}`);
|
||||
lines.push('─'.repeat(60));
|
||||
|
||||
const heapDataMB = result.snapshots.map(
|
||||
(s) => s.heapUsed / (1024 * 1024),
|
||||
);
|
||||
|
||||
if (plot) {
|
||||
const chart = plot(heapDataMB, {
|
||||
height: 10,
|
||||
format: (x: number) => `${x.toFixed(1)} MB`.padStart(10),
|
||||
});
|
||||
lines.push(chart);
|
||||
}
|
||||
|
||||
// Label the x-axis with snapshot labels
|
||||
const labels = result.snapshots.map((s) => s.label);
|
||||
lines.push(' ' + labels.join(' → '));
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lines.push(
|
||||
'(asciichart not available — install with: npm install --save-dev asciichart)',
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('═══════════════════════════════════════════════════');
|
||||
lines.push('');
|
||||
|
||||
const report = lines.join('\n');
|
||||
console.log(report);
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force V8 garbage collection.
|
||||
* Runs multiple GC cycles with delays to allow weak references
|
||||
* and FinalizationRegistry callbacks to run.
|
||||
*/
|
||||
private async forceGC(): Promise<void> {
|
||||
if (typeof globalThis.gc !== 'function') {
|
||||
throw new Error(
|
||||
'global.gc() not available. Run with --expose-gc for accurate measurements.',
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.gcCycles; i++) {
|
||||
globalThis.gc();
|
||||
if (i < this.gcCycles - 1) {
|
||||
await sleep(this.gcDelayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes as a human-readable MB string.
|
||||
*/
|
||||
function formatMB(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
Reference in New Issue
Block a user