Compare commits

...

33 Commits

Author SHA1 Message Date
gemini-cli-robot eeb8aae2ab chore(release): v0.33.1 2026-03-12 22:12:57 +00:00
gemini-cli-robot f59f9e9314 fix(patch): cherry-pick 8432bce to release/v0.33.0-pr-22069 to patch version v0.33.0 and create version 0.33.1 (#22206)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Abhi <abhipatel@google.com>
2026-03-12 16:00:37 -04:00
gemini-cli-robot 832e97cb76 chore(release): v0.33.0 2026-03-11 02:15:25 +00:00
gemini-cli-robot 6ee19e1f6b chore(release): v0.33.0-preview.15 2026-03-11 00:42:29 +00:00
gemini-cli-robot aa02abe3e8 fix(patch): cherry-pick 3ff68a9 to release/v0.33.0-preview.14-pr-21884 [CONFLICTS] (#21952)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-03-11 00:15:45 +00:00
gemini-cli-robot 0d4d80eb53 chore(release): v0.33.0-preview.14 2026-03-10 22:16:53 +00:00
gemini-cli-robot 0d27afcc7d fix(patch): cherry-pick 1b69637 to release/v0.33.0-preview.13-pr-21467 [CONFLICTS] (#21930)
Co-authored-by: Coco Sheng <cocosheng@google.com>
Co-authored-by: Abhi <abhipatel@google.com>
Co-authored-by: Adam Weidman <adamfweidman@google.com>
Co-authored-by: galz10 <galzahavi@google.com>
2026-03-10 14:35:44 -07:00
gemini-cli-robot 8948a9075f chore(release): v0.33.0-preview.13 2026-03-10 20:51:02 +00:00
gemini-cli-robot ca0db7ec4a fix(patch): cherry-pick e5615f4 to release/v0.33.0-preview.12-pr-21037 to patch version v0.33.0-preview.12 and create version 0.33.0-preview.13 (#21922)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-03-10 20:11:17 +00:00
gemini-cli-robot 524a84bfec chore(release): v0.33.0-preview.12 2026-03-10 15:18:49 +00:00
gemini-cli-robot 6431ea8724 fix(patch): cherry-pick 5dd2dab to release/v0.33.0-preview.11-pr-21871 (#21876)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-03-10 14:48:38 +00:00
gemini-cli-robot 0db01a9d79 chore(release): v0.33.0-preview.11 2026-03-10 13:15:34 +00:00
gemini-cli-robot 0af0fb6a50 fix(patch): cherry-pick 35ee2a8 to release/v0.33.0-preview.10-pr-21713 (#21859)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-03-10 12:17:21 +00:00
gemini-cli-robot 78b0b3dd4d chore(release): v0.33.0-preview.10 2026-03-10 01:56:08 +00:00
gemini-cli-robot c18aae398c fix(patch): cherry-pick 936f624 to release/v0.33.0-preview.9-pr-21702 to patch version v0.33.0-preview.9 and create version 0.33.0-preview.10 (#21800)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
2026-03-09 18:25:59 -07:00
gemini-cli-robot 94ccdc75c2 chore(release): v0.33.0-preview.9 2026-03-10 00:44:59 +00:00
gemini-cli-robot 5e80faf389 fix(patch): cherry-pick 9a74271 to release/v0.33.0-preview.8-pr-21236 [CONFLICTS] (#21788)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
Co-authored-by: galz10 <galzahavi@google.com>
2026-03-09 17:14:42 -07:00
gemini-cli-robot c2e92125c3 chore(release): v0.33.0-preview.8 2026-03-09 23:38:08 +00:00
gemini-cli-robot 302b48a0b3 fix(patch): cherry-pick 4f4431e to release/v0.33.0-preview.7-pr-21750 to patch version v0.33.0-preview.7 and create version 0.33.0-preview.8 (#21782)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-03-09 18:59:01 -04:00
gemini-cli-robot 8fa4585ebf chore(release): v0.33.0-preview.7 2026-03-09 19:52:20 +00:00
gemini-cli-robot 5d2983f12a chore(release): v0.33.0-preview.6 2026-03-09 18:28:31 +00:00
gemini-cli-robot 9912ea8686 fix(patch): cherry-pick 7837194 to release/v0.33.0-preview.5-pr-21487 to patch version v0.33.0-preview.5 and create version 0.33.0-preview.6 (#21720)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-03-09 13:59:58 -04:00
gemini-cli-robot 508aaeda89 chore(release): v0.33.0-preview.5 2026-03-07 01:33:18 +00:00
gemini-cli-robot a6256cdabf fix(patch): cherry-pick 931e668 to release/v0.33.0-preview.4-pr-21425 [CONFLICTS] (#21478)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Abhi <abhipatel@google.com>
2026-03-07 00:29:28 +00:00
gemini-cli-robot c316fc6c51 chore(release): v0.33.0-preview.4 2026-03-06 01:20:57 +00:00
gemini-cli-robot 7e6e40c814 fix(patch): cherry-pick 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch version v0.33.0-preview.3 and create version 0.33.0-preview.4 (#21349)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-03-05 16:53:40 -08:00
gemini-cli-robot b25c8137f6 chore(release): v0.33.0-preview.3 2026-03-05 23:56:03 +00:00
gemini-cli-robot 03a8fc3113 fix(patch): cherry-pick 0135b03 to release/v0.33.0-preview.2-pr-21171 [CONFLICTS] (#21336)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-03-05 23:22:08 +00:00
gemini-cli-robot 393e9a171f chore(release): v0.33.0-preview.2 2026-03-05 22:10:39 +00:00
gemini-cli-robot 59702f913d fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch version v0.33.0-preview.1 and create version 0.33.0-preview.2 (#21300)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-03-05 12:28:20 -08:00
gemini-cli-robot b3439e1458 chore(release): v0.33.0-preview.1 2026-03-04 04:12:41 +00:00
gemini-cli-robot b21b289e26 fix(patch): cherry-pick 0659ad1 to release/v0.33.0-preview.0-pr-21042 to patch version v0.33.0-preview.0 and create version 0.33.0-preview.1 (#21047)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-03 18:48:23 -08:00
gemini-cli-robot 56a63a35e2 chore(release): v0.33.0-preview.0 2026-03-03 23:20:33 +00:00
112 changed files with 3804 additions and 2335 deletions
+3 -1
View File
@@ -124,7 +124,9 @@ jobs:
- name: 'Link Checker'
uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1
with:
args: '--verbose --accept 200,503 ./**/*.md'
# Exclude GEMINI.md because the absolute GitHub URL in CONTRIBUTING.md (which is symlinked)
# causes intermittent 429 Too Many Requests errors from GitHub API rate limits.
args: '--verbose --accept 200,503 --exclude "GEMINI\\.md" ./**/*.md'
fail: true
test_linux:
name: 'Test (Linux) - ${{ matrix.node-version }}, ${{ matrix.shard }}'
+3 -1
View File
@@ -22,4 +22,6 @@ jobs:
id: 'lychee'
uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1
with:
args: '--verbose --no-progress --accept 200,503 ./**/*.md'
# Exclude GEMINI.md because the absolute GitHub URL in CONTRIBUTING.md (which is symlinked)
# causes intermittent 429 Too Many Requests errors from GitHub API rate limits.
args: '--verbose --no-progress --accept 200,503 --exclude "GEMINI\\.md" ./**/*.md'
+3 -60
View File
@@ -1,4 +1,4 @@
# Plan Mode (experimental)
# Plan Mode
Plan Mode is a read-only environment for architecting robust solutions before
implementation. It allows you to:
@@ -8,65 +8,8 @@ implementation. It allows you to:
- **Design:** Understand problems, evaluate trade-offs, and choose a solution.
- **Plan:** Align on an execution strategy before any code is modified.
> **Note:** This is a preview feature currently under active development. Your
> feedback is invaluable as we refine this feature. If you have ideas,
> suggestions, or encounter issues:
>
> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on
> GitHub.
> - Use the **/bug** command within Gemini CLI to file an issue.
- [Enabling Plan Mode](#enabling-plan-mode)
- [How to use Plan Mode](#how-to-use-plan-mode)
- [Entering Plan Mode](#entering-plan-mode)
- [Planning Workflow](#planning-workflow)
- [Exiting Plan Mode](#exiting-plan-mode)
- [Commands](#commands)
- [Tool Restrictions](#tool-restrictions)
- [Customizing Planning with Skills](#customizing-planning-with-skills)
- [Customizing Policies](#customizing-policies)
- [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode)
- [Example: Enable custom subagents in Plan Mode](#example-enable-custom-subagents-in-plan-mode)
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
- [Automatic Model Routing](#automatic-model-routing)
- [Cleanup](#cleanup)
## Enabling Plan Mode
To use Plan Mode, enable it via **/settings** (search for **Plan**) or add the
following to your `settings.json`:
```json
{
"experimental": {
"plan": true
}
}
```
## How to use Plan Mode
### Entering Plan Mode
You can configure Gemini CLI to start in Plan Mode by default or enter it
manually during a session.
- **Configuration:** Configure Gemini CLI to start directly in Plan Mode by
default:
1. Type `/settings` in the CLI.
2. Search for **Default Approval Mode**.
3. Set the value to **Plan**.
Alternatively, use the `gemini --approval-mode=plan` CLI flag or manually
update:
```json
{
"general": {
"defaultApprovalMode": "plan"
}
}
```
Plan Mode is enabled by default. You can manage this setting using the
`/settings` command.
- **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
(`Default` -> `Auto-Edit` -> `Plan`).
+1 -1
View File
@@ -144,7 +144,7 @@ they appear in the UI.
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
+2 -2
View File
@@ -268,8 +268,8 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Switch to Plan Mode (read-only) and view the current plan if
one has been generated.
- **Note:** This feature requires the `experimental.plan` setting to be
enabled in your configuration.
- **Note:** This feature is enabled by default. It can be disabled via the
`experimental.plan` setting in your configuration.
- **Sub-commands:**
- **`copy`**:
- **Description:** Copy the currently approved plan to your clipboard.
+2 -2
View File
@@ -1010,8 +1010,8 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **`experimental.plan`** (boolean):
- **Description:** Enable planning features (Plan Mode and tools).
- **Default:** `false`
- **Description:** Enable Plan Mode.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.modelSteering`** (boolean):
+2 -2
View File
@@ -55,7 +55,7 @@ describe.skip('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
child = spawn('node', [bundlePath, '--experimental-acp'], {
child = spawn('node', [bundlePath, '--acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
@@ -120,7 +120,7 @@ describe.skip('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
child = spawn('node', [bundlePath, '--experimental-acp'], {
child = spawn('node', [bundlePath, '--acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
+1 -1
View File
@@ -58,7 +58,7 @@ describe('ACP telemetry', () => {
'node',
[
bundlePath,
'--experimental-acp',
'--acp',
'--fake-responses',
join(rig.testDir!, 'fake-responses.json'),
],
+2 -2
View File
@@ -104,7 +104,7 @@ describe('extension reloading', () => {
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- hello')
) && output.includes('- mcp_test-server_hello')
);
},
30000, // 30s timeout
@@ -148,7 +148,7 @@ describe('extension reloading', () => {
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- goodbye')
) && output.includes('- mcp_test-server_goodbye')
);
},
30000,
+841 -1200
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.1",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.1"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.1",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -36,7 +36,7 @@
"winston": "^3.17.0"
},
"devDependencies": {
"@google/genai": "^1.30.0",
"@google/genai": "1.30.0",
"@types/express": "^5.0.3",
"@types/fs-extra": "^11.0.4",
"@types/supertest": "^6.0.3",
@@ -0,0 +1,248 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { CoderAgentExecutor } from './executor.js';
import type {
ExecutionEventBus,
RequestContext,
TaskStore,
} from '@a2a-js/sdk/server';
import { EventEmitter } from 'node:events';
import { requestStorage } from '../http/requestStorage.js';
// Mocks for constructor dependencies
vi.mock('../config/config.js', () => ({
loadConfig: vi.fn().mockReturnValue({
getSessionId: () => 'test-session',
getTargetDir: () => '/tmp',
getCheckpointingEnabled: () => false,
}),
loadEnvironment: vi.fn(),
setTargetDir: vi.fn().mockReturnValue('/tmp'),
}));
vi.mock('../config/settings.js', () => ({
loadSettings: vi.fn().mockReturnValue({}),
}));
vi.mock('../config/extension.js', () => ({
loadExtensions: vi.fn().mockReturnValue([]),
}));
vi.mock('../http/requestStorage.js', () => ({
requestStorage: {
getStore: vi.fn(),
},
}));
vi.mock('./task.js', () => {
const mockTaskInstance = (taskId: string, contextId: string) => ({
id: taskId,
contextId,
taskState: 'working',
acceptUserMessage: vi
.fn()
.mockImplementation(async function* (context, aborted) {
const isConfirmation = (
context.userMessage.parts as Array<{ kind: string }>
).some((p) => p.kind === 'confirmation');
// Hang only for main user messages (text), allow confirmations to finish quickly
if (!isConfirmation && aborted) {
await new Promise((resolve) => {
aborted.addEventListener('abort', resolve, { once: true });
});
}
yield { type: 'content', value: 'hello' };
}),
acceptAgentMessage: vi.fn().mockResolvedValue(undefined),
scheduleToolCalls: vi.fn().mockResolvedValue(undefined),
waitForPendingTools: vi.fn().mockResolvedValue(undefined),
getAndClearCompletedTools: vi.fn().mockReturnValue([]),
addToolResponsesToHistory: vi.fn(),
sendCompletedToolsToLlm: vi.fn().mockImplementation(async function* () {}),
cancelPendingTools: vi.fn(),
setTaskStateAndPublishUpdate: vi.fn(),
dispose: vi.fn(),
getMetadata: vi.fn().mockResolvedValue({}),
geminiClient: {
initialize: vi.fn().mockResolvedValue(undefined),
},
toSDKTask: () => ({
id: taskId,
contextId,
kind: 'task',
status: { state: 'working', timestamp: new Date().toISOString() },
metadata: {},
history: [],
artifacts: [],
}),
});
const MockTask = vi.fn().mockImplementation(mockTaskInstance);
(MockTask as unknown as { create: Mock }).create = vi
.fn()
.mockImplementation(async (taskId: string, contextId: string) =>
mockTaskInstance(taskId, contextId),
);
return { Task: MockTask };
});
describe('CoderAgentExecutor', () => {
let executor: CoderAgentExecutor;
let mockTaskStore: TaskStore;
let mockEventBus: ExecutionEventBus;
beforeEach(() => {
vi.clearAllMocks();
mockTaskStore = {
save: vi.fn().mockResolvedValue(undefined),
load: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
list: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
mockEventBus = new EventEmitter() as unknown as ExecutionEventBus;
mockEventBus.publish = vi.fn();
mockEventBus.finished = vi.fn();
executor = new CoderAgentExecutor(mockTaskStore);
});
it('should distinguish between primary and secondary execution', async () => {
const taskId = 'test-task';
const contextId = 'test-context';
const mockSocket = new EventEmitter();
const requestContext = {
userMessage: {
messageId: 'msg-1',
taskId,
contextId,
parts: [{ kind: 'text', text: 'hi' }],
metadata: {
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
},
},
} as unknown as RequestContext;
// Mock requestStorage for primary
(requestStorage.getStore as Mock).mockReturnValue({
req: { socket: mockSocket },
});
// First execution (Primary)
const primaryPromise = executor.execute(requestContext, mockEventBus);
// Give it enough time to reach line 490 in executor.ts
await new Promise((resolve) => setTimeout(resolve, 50));
expect(
(
executor as unknown as { executingTasks: Set<string> }
).executingTasks.has(taskId),
).toBe(true);
const wrapper = executor.getTask(taskId);
expect(wrapper).toBeDefined();
// Mock requestStorage for secondary
const secondarySocket = new EventEmitter();
(requestStorage.getStore as Mock).mockReturnValue({
req: { socket: secondarySocket },
});
const secondaryRequestContext = {
userMessage: {
messageId: 'msg-2',
taskId,
contextId,
parts: [{ kind: 'confirmation', callId: '1', outcome: 'proceed' }],
metadata: {
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
},
},
} as unknown as RequestContext;
const secondaryPromise = executor.execute(
secondaryRequestContext,
mockEventBus,
);
// Secondary execution should NOT add to executingTasks (already there)
// and should return early after its loop
await secondaryPromise;
// Task should still be in executingTasks and NOT disposed
expect(
(
executor as unknown as { executingTasks: Set<string> }
).executingTasks.has(taskId),
).toBe(true);
expect(wrapper?.task.dispose).not.toHaveBeenCalled();
// Now simulate secondary socket closure - it should NOT affect primary
secondarySocket.emit('end');
expect(
(
executor as unknown as { executingTasks: Set<string> }
).executingTasks.has(taskId),
).toBe(true);
expect(wrapper?.task.dispose).not.toHaveBeenCalled();
// Set to terminal state to verify disposal on finish
wrapper!.task.taskState = 'completed';
// Now close primary socket
mockSocket.emit('end');
await primaryPromise;
expect(
(
executor as unknown as { executingTasks: Set<string> }
).executingTasks.has(taskId),
).toBe(false);
expect(wrapper?.task.dispose).toHaveBeenCalled();
});
it('should evict task from cache when it reaches terminal state', async () => {
const taskId = 'test-task-terminal';
const contextId = 'test-context';
const mockSocket = new EventEmitter();
(requestStorage.getStore as Mock).mockReturnValue({
req: { socket: mockSocket },
});
const requestContext = {
userMessage: {
messageId: 'msg-1',
taskId,
contextId,
parts: [{ kind: 'text', text: 'hi' }],
metadata: {
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
},
},
} as unknown as RequestContext;
const primaryPromise = executor.execute(requestContext, mockEventBus);
await new Promise((resolve) => setTimeout(resolve, 50));
const wrapper = executor.getTask(taskId)!;
expect(wrapper).toBeDefined();
// Simulate terminal state
wrapper.task.taskState = 'completed';
// Finish primary execution
mockSocket.emit('end');
await primaryPromise;
expect(executor.getTask(taskId)).toBeUndefined();
expect(wrapper.task.dispose).toHaveBeenCalled();
});
});
+55 -16
View File
@@ -252,6 +252,10 @@ export class CoderAgentExecutor implements AgentExecutor {
);
await this.taskStore?.save(wrapper.toSDKTask());
logger.info(`[CoderAgentExecutor] Task ${taskId} state CANCELED saved.`);
// Cleanup listener subscriptions to avoid memory leaks.
wrapper.task.dispose();
this.tasks.delete(taskId);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
@@ -320,23 +324,26 @@ export class CoderAgentExecutor implements AgentExecutor {
if (store) {
// Grab the raw socket from the request object
const socket = store.req.socket;
const onClientEnd = () => {
const onSocketEnd = () => {
logger.info(
`[CoderAgentExecutor] Client socket closed for task ${taskId}. Cancelling execution.`,
`[CoderAgentExecutor] Socket ended for message ${userMessage.messageId} (task ${taskId}). Aborting execution loop.`,
);
if (!abortController.signal.aborted) {
abortController.abort();
}
// Clean up the listener to prevent memory leaks
socket.removeListener('close', onClientEnd);
socket.removeListener('end', onSocketEnd);
};
// Listen on the socket's 'end' event (remote closed the connection)
socket.on('end', onClientEnd);
socket.on('end', onSocketEnd);
socket.once('close', () => {
socket.removeListener('end', onSocketEnd);
});
// It's also good practice to remove the listener if the task completes successfully
abortSignal.addEventListener('abort', () => {
socket.removeListener('end', onClientEnd);
socket.removeListener('end', onSocketEnd);
});
logger.info(
`[CoderAgentExecutor] Socket close handler set up for task ${taskId}.`,
@@ -457,6 +464,26 @@ export class CoderAgentExecutor implements AgentExecutor {
return;
}
// Check if this is the primary/initial execution for this task
const isPrimaryExecution = !this.executingTasks.has(taskId);
if (!isPrimaryExecution) {
logger.info(
`[CoderAgentExecutor] Primary execution already active for task ${taskId}. Starting secondary loop for message ${userMessage.messageId}.`,
);
currentTask.eventBus = eventBus;
for await (const _ of currentTask.acceptUserMessage(
requestContext,
abortController.signal,
)) {
logger.info(
`[CoderAgentExecutor] Processing user message ${userMessage.messageId} in secondary execution loop for task ${taskId}.`,
);
}
// End this execution-- the original/source will be resumed.
return;
}
logger.info(
`[CoderAgentExecutor] Starting main execution for message ${userMessage.messageId} for task ${taskId}.`,
);
@@ -598,18 +625,30 @@ export class CoderAgentExecutor implements AgentExecutor {
}
}
} finally {
this.executingTasks.delete(taskId);
logger.info(
`[CoderAgentExecutor] Saving final state for task ${taskId}.`,
);
try {
await this.taskStore?.save(wrapper.toSDKTask());
logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`);
} catch (saveError) {
logger.error(
`[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`,
saveError,
if (isPrimaryExecution) {
this.executingTasks.delete(taskId);
logger.info(
`[CoderAgentExecutor] Saving final state for task ${taskId}.`,
);
try {
await this.taskStore?.save(wrapper.toSDKTask());
logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`);
} catch (saveError) {
logger.error(
`[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`,
saveError,
);
}
if (
['canceled', 'failed', 'completed'].includes(currentTask.taskState)
) {
logger.info(
`[CoderAgentExecutor] Task ${taskId} reached terminal state ${currentTask.taskState}. Evicting and disposing.`,
);
wrapper.task.dispose();
this.tasks.delete(taskId);
}
}
}
}
@@ -0,0 +1,655 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Task } from './task.js';
import {
type Config,
MessageBusType,
ToolConfirmationOutcome,
ApprovalMode,
Scheduler,
type MessageBus,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
describe('Task Event-Driven Scheduler', () => {
let mockConfig: Config;
let mockEventBus: ExecutionEventBus;
let messageBus: MessageBus;
beforeEach(() => {
vi.clearAllMocks();
mockConfig = createMockConfig({
isEventDrivenSchedulerEnabled: () => true,
}) as Config;
messageBus = mockConfig.getMessageBus();
mockEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
});
it('should instantiate Scheduler when enabled', () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
expect(task.scheduler).toBeInstanceOf(Scheduler);
});
it('should subscribe to TOOL_CALLS_UPDATE and map status changes', async () => {
// @ts-expect-error - Calling private constructor
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'executing',
};
// Simulate MessageBus event
// Simulate MessageBus event
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
if (!handler) {
throw new Error('TOOL_CALLS_UPDATE handler not found');
}
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
});
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
status: expect.objectContaining({
state: 'submitted', // initial task state
}),
metadata: expect.objectContaining({
coderAgent: expect.objectContaining({
kind: 'tool-call-update',
}),
}),
}),
);
});
it('should handle tool confirmations by publishing to MessageBus', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
// Simulate MessageBus event to stash the correlationId
// Simulate MessageBus event
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
if (!handler) {
throw new Error('TOOL_CALLS_UPDATE handler not found');
}
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
});
// Simulate A2A client confirmation
const part = {
kind: 'data',
data: {
callId: '1',
outcome: 'proceed_once',
},
};
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart(part);
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-1',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedOnce,
}),
);
});
it('should handle Rejection (Cancel) and Modification (ModifyWithEditor)', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// Simulate Rejection (Cancel)
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'cancel' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-1',
confirmed: false,
}),
);
const toolCall2 = {
request: { callId: '2', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-2',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall2] });
// Simulate ModifyWithEditor
const handled2 = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '2', outcome: 'modify_with_editor' },
});
expect(handled2).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-2',
confirmed: false,
outcome: ToolConfirmationOutcome.ModifyWithEditor,
payload: undefined,
}),
);
});
it('should handle MCP Server tool operations correctly', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'call_mcp_tool', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-mcp-1',
confirmationDetails: {
type: 'mcp',
title: 'MCP Server Operation',
prompt: 'test_mcp',
},
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// Simulate ProceedOnce for MCP
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_once' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-mcp-1',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedOnce,
}),
);
});
it('should handle MCP Server tool ProceedAlwaysServer outcome', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'call_mcp_tool', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-mcp-2',
confirmationDetails: {
type: 'mcp',
title: 'MCP Server Operation',
prompt: 'test_mcp',
},
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_always_server' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-mcp-2',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedAlwaysServer,
}),
);
});
it('should handle MCP Server tool ProceedAlwaysTool outcome', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'call_mcp_tool', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-mcp-3',
confirmationDetails: {
type: 'mcp',
title: 'MCP Server Operation',
prompt: 'test_mcp',
},
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_always_tool' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-mcp-3',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedAlwaysTool,
}),
);
});
it('should handle MCP Server tool ProceedAlwaysAndSave outcome', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'call_mcp_tool', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-mcp-4',
confirmationDetails: {
type: 'mcp',
title: 'MCP Server Operation',
prompt: 'test_mcp',
},
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_always_and_save' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-mcp-4',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedAlwaysAndSave,
}),
);
});
it('should execute without confirmation in YOLO mode and not transition to input-required', async () => {
// Enable YOLO mode
const yoloConfig = createMockConfig({
isEventDrivenSchedulerEnabled: () => true,
getApprovalMode: () => ApprovalMode.YOLO,
}) as Config;
const yoloMessageBus = yoloConfig.getMessageBus();
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', yoloConfig, mockEventBus);
task.setTaskStateAndPublishUpdate = vi.fn();
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly
expect(yoloMessageBus.publish).not.toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
}),
);
// Should NOT transition to input-required since it was auto-approved
expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith(
'input-required',
expect.anything(),
undefined,
undefined,
true,
);
});
it('should handle output updates via the message bus', async () => {
// @ts-expect-error - Calling private constructor
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'executing',
liveOutput: 'chunk1',
};
// Simulate MessageBus event
// Simulate MessageBus event
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
if (!handler) {
throw new Error('TOOL_CALLS_UPDATE handler not found');
}
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
});
// Should publish artifact update for output
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'artifact-update',
artifact: expect.objectContaining({
artifactId: 'tool-1-output',
parts: [{ kind: 'text', text: 'chunk1' }],
}),
}),
);
});
it('should complete artifact creation without hanging', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCallId = 'create-file-123';
task['_registerToolCall'](toolCallId, 'executing');
const toolCall = {
request: {
callId: toolCallId,
name: 'writeFile',
args: { path: 'test.sh' },
},
status: 'success',
result: { ok: true },
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// The tool should be complete and registered appropriately, eventually
// triggering the toolCompletionPromise resolution when all clear.
const internalTask = task as unknown as {
completedToolCalls: unknown[];
pendingToolCalls: Map<string, string>;
};
expect(internalTask.completedToolCalls.length).toBe(1);
expect(internalTask.pendingToolCalls.size).toBe(0);
});
it('should preserve messageId across multiple text chunks to prevent UI duplication', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
// Initialize the ID for the first turn (happens internally upon LLM stream)
task.currentAgentMessageId = 'test-id-123';
// Simulate sending multiple text chunks
task._sendTextContent('chunk 1');
task._sendTextContent('chunk 2');
// Both text contents should have been published with the same messageId
const textCalls = (mockEventBus.publish as Mock).mock.calls.filter(
(call) => call[0].status?.message?.kind === 'message',
);
expect(textCalls.length).toBe(2);
expect(textCalls[0][0].status.message.messageId).toBe('test-id-123');
expect(textCalls[1][0].status.message.messageId).toBe('test-id-123');
// Simulate starting a new turn by calling getAndClearCompletedTools
// (which precedes sendCompletedToolsToLlm where a new ID is minted)
task.getAndClearCompletedTools();
// sendCompletedToolsToLlm internally rolls the ID forward.
// Simulate what sendCompletedToolsToLlm does:
const internalTask = task as unknown as {
setTaskStateAndPublishUpdate: (state: string, change: unknown) => void;
};
internalTask.setTaskStateAndPublishUpdate('working', {});
// Simulate what sendCompletedToolsToLlm does: generate a new UUID for the next turn
task.currentAgentMessageId = 'test-id-456';
task._sendTextContent('chunk 3');
const secondTurnCalls = (mockEventBus.publish as Mock).mock.calls.filter(
(call) => call[0].status?.message?.messageId === 'test-id-456',
);
expect(secondTurnCalls.length).toBe(1);
expect(secondTurnCalls[0][0].status.message.parts[0].text).toBe('chunk 3');
});
it('should handle parallel tool calls correctly', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall1 = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test 1', prompt: 'test 1' },
};
const toolCall2 = {
request: { callId: '2', name: 'pwd', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-2',
confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' },
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
// Publish update for both tool calls simultaneously
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1, toolCall2],
});
// Confirm first tool call
const handled1 = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_once' },
});
expect(handled1).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-1',
confirmed: true,
}),
);
// Confirm second tool call
const handled2 = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '2', outcome: 'cancel' },
});
expect(handled2).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-2',
confirmed: false,
}),
);
});
it('should wait for executing tools before transitioning to input-required state', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
task.setTaskStateAndPublishUpdate = vi.fn();
// Register tool 1 as executing
task['_registerToolCall']('1', 'executing');
const toolCall1 = {
request: { callId: '1', name: 'ls', args: {} },
status: 'executing',
};
const toolCall2 = {
request: { callId: '2', name: 'pwd', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-2',
confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' },
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1, toolCall2],
});
// Should NOT transition to input-required yet
expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith(
'input-required',
expect.anything(),
undefined,
undefined,
true,
);
// Complete tool 1
const toolCall1Complete = {
...toolCall1,
status: 'success',
result: { ok: true },
};
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1Complete, toolCall2],
});
// Now it should transition
expect(task.setTaskStateAndPublishUpdate).toHaveBeenCalledWith(
'input-required',
expect.anything(),
undefined,
undefined,
true,
);
});
it('should ignore confirmations for unknown tool calls', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: 'unknown-id', outcome: 'proceed_once' },
});
// Should return false for unhandled tool call
expect(handled).toBe(false);
// Should not publish anything to the message bus
expect(messageBus.publish).not.toHaveBeenCalled();
});
});
+28 -2
View File
@@ -504,13 +504,14 @@ describe('Task', () => {
});
describe('auto-approval', () => {
it('should auto-approve tool calls when autoExecute is true', () => {
it('should NOT publish ToolCallConfirmationEvent when autoExecute is true', () => {
task.autoExecute = true;
const onConfirmSpy = vi.fn();
const toolCalls = [
{
request: { callId: '1' },
status: 'awaiting_approval',
correlationId: 'test-corr-id',
confirmationDetails: {
type: 'edit',
onConfirm: onConfirmSpy,
@@ -524,9 +525,17 @@ describe('Task', () => {
expect(onConfirmSpy).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
const calls = (mockEventBus.publish as Mock).mock.calls;
// Search if ToolCallConfirmationEvent was published
const confEvent = calls.find(
(call) =>
call[0].metadata?.coderAgent?.kind ===
CoderAgentEvent.ToolCallConfirmationEvent,
);
expect(confEvent).toBeUndefined();
});
it('should auto-approve tool calls when approval mode is YOLO', () => {
it('should NOT publish ToolCallConfirmationEvent when approval mode is YOLO', () => {
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO);
task.autoExecute = false;
const onConfirmSpy = vi.fn();
@@ -534,6 +543,7 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
correlationId: 'test-corr-id',
confirmationDetails: {
type: 'edit',
onConfirm: onConfirmSpy,
@@ -547,6 +557,14 @@ describe('Task', () => {
expect(onConfirmSpy).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
const calls = (mockEventBus.publish as Mock).mock.calls;
// Search if ToolCallConfirmationEvent was published
const confEvent = calls.find(
(call) =>
call[0].metadata?.coderAgent?.kind ===
CoderAgentEvent.ToolCallConfirmationEvent,
);
expect(confEvent).toBeUndefined();
});
it('should NOT auto-approve when autoExecute is false and mode is not YOLO', () => {
@@ -567,6 +585,14 @@ describe('Task', () => {
task._schedulerToolCallsUpdate(toolCalls);
expect(onConfirmSpy).not.toHaveBeenCalled();
const calls = (mockEventBus.publish as Mock).mock.calls;
// Search if ToolCallConfirmationEvent was published
const confEvent = calls.find(
(call) =>
call[0].metadata?.coderAgent?.kind ===
CoderAgentEvent.ToolCallConfirmationEvent,
);
expect(confEvent).toBeDefined();
});
});
});
+277 -41
View File
@@ -5,6 +5,7 @@
*/
import {
Scheduler,
CoreToolScheduler,
type GeminiClient,
GeminiEventType,
@@ -31,6 +32,8 @@ import {
isSubagentProgress,
EDIT_TOOL_NAMES,
processRestorableToolCalls,
MessageBusType,
type ToolCallsUpdateMessage,
} from '@google/gemini-cli-core';
import {
type ExecutionEventBus,
@@ -93,21 +96,30 @@ function isToolCallConfirmationDetails(
export class Task {
id: string;
contextId: string;
scheduler: CoreToolScheduler;
scheduler: Scheduler | CoreToolScheduler;
config: Config;
geminiClient: GeminiClient;
pendingToolConfirmationDetails: Map<string, ToolCallConfirmationDetails>;
pendingCorrelationIds: Map<string, string> = new Map();
taskState: TaskState;
eventBus?: ExecutionEventBus;
completedToolCalls: CompletedToolCall[];
processedToolCallIds: Set<string> = new Set();
skipFinalTrueAfterInlineEdit = false;
modelInfo?: string;
currentPromptId: string | undefined;
currentAgentMessageId = uuidv4();
promptCount = 0;
autoExecute: boolean;
private get isYoloMatch(): boolean {
return (
this.autoExecute || this.config.getApprovalMode() === ApprovalMode.YOLO
);
}
// For tool waiting logic
private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
private toolsAlreadyConfirmed: Set<string> = new Set();
private toolCompletionPromise?: Promise<void>;
private toolCompletionNotifier?: {
resolve: () => void;
@@ -124,7 +136,13 @@ export class Task {
this.id = id;
this.contextId = contextId;
this.config = config;
this.scheduler = this.createScheduler();
if (this.config.isEventDrivenSchedulerEnabled()) {
this.scheduler = this.setupEventDrivenScheduler();
} else {
this.scheduler = this.createLegacyScheduler();
}
this.geminiClient = this.config.getGeminiClient();
this.pendingToolConfirmationDetails = new Map();
this.taskState = 'submitted';
@@ -224,7 +242,7 @@ export class Task {
logger.info(
`[Task] Waiting for ${this.pendingToolCalls.size} pending tool(s)...`,
);
return this.toolCompletionPromise;
await this.toolCompletionPromise;
}
cancelPendingTools(reason: string): void {
@@ -237,6 +255,13 @@ export class Task {
this.toolCompletionNotifier.reject(new Error(reason));
}
this.pendingToolCalls.clear();
this.pendingCorrelationIds.clear();
if (this.scheduler instanceof Scheduler) {
this.scheduler.cancelAll();
} else {
this.scheduler.cancelAll(new AbortController().signal);
}
// Reset the promise for any future operations, ensuring it's in a clean state.
this._resetToolCompletionPromise();
}
@@ -249,7 +274,7 @@ export class Task {
kind: 'message',
role,
parts: [{ kind: 'text', text }],
messageId: uuidv4(),
messageId: role === 'agent' ? this.currentAgentMessageId : uuidv4(),
taskId: this.id,
contextId: this.contextId,
};
@@ -417,26 +442,34 @@ export class Task {
// Only send an update if the status has actually changed.
if (hasChanged) {
const coderAgentMessage: CoderAgentMessage =
tc.status === 'awaiting_approval'
? { kind: CoderAgentEvent.ToolCallConfirmationEvent }
: { kind: CoderAgentEvent.ToolCallUpdateEvent };
const message = this.toolStatusMessage(tc, this.id, this.contextId);
// Skip sending confirmation event if we are going to auto-approve it anyway
if (
tc.status === 'awaiting_approval' &&
tc.confirmationDetails &&
this.isYoloMatch
) {
logger.info(
`[Task] Skipping ToolCallConfirmationEvent for ${tc.request.callId} due to YOLO mode.`,
);
} else {
const coderAgentMessage: CoderAgentMessage =
tc.status === 'awaiting_approval'
? { kind: CoderAgentEvent.ToolCallConfirmationEvent }
: { kind: CoderAgentEvent.ToolCallUpdateEvent };
const message = this.toolStatusMessage(tc, this.id, this.contextId);
const event = this._createStatusUpdateEvent(
this.taskState,
coderAgentMessage,
message,
false, // Always false for these continuous updates
);
this.eventBus?.publish(event);
const event = this._createStatusUpdateEvent(
this.taskState,
coderAgentMessage,
message,
false, // Always false for these continuous updates
);
this.eventBus?.publish(event);
}
}
});
if (
this.autoExecute ||
this.config.getApprovalMode() === ApprovalMode.YOLO
) {
if (this.isYoloMatch) {
logger.info(
'[Task] ' +
(this.autoExecute ? '' : 'YOLO mode enabled. ') +
@@ -484,7 +517,7 @@ export class Task {
}
}
private createScheduler(): CoreToolScheduler {
private createLegacyScheduler(): CoreToolScheduler {
const scheduler = new CoreToolScheduler({
outputUpdateHandler: this._schedulerOutputUpdate.bind(this),
onAllToolCallsComplete: this._schedulerAllToolCallsComplete.bind(this),
@@ -495,6 +528,171 @@ export class Task {
return scheduler;
}
private messageBusListener?: (message: ToolCallsUpdateMessage) => void;
private setupEventDrivenScheduler(): Scheduler {
const messageBus = this.config.getMessageBus();
const scheduler = new Scheduler({
schedulerId: this.id,
config: this.config,
messageBus,
getPreferredEditor: () => DEFAULT_GUI_EDITOR,
});
this.messageBusListener = this.handleEventDrivenToolCallsUpdate.bind(this);
messageBus.subscribe<ToolCallsUpdateMessage>(
MessageBusType.TOOL_CALLS_UPDATE,
this.messageBusListener,
);
return scheduler;
}
dispose(): void {
if (this.messageBusListener) {
this.config
.getMessageBus()
.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, this.messageBusListener);
this.messageBusListener = undefined;
}
if (this.scheduler instanceof Scheduler) {
this.scheduler.dispose();
}
}
private handleEventDrivenToolCallsUpdate(
event: ToolCallsUpdateMessage,
): void {
if (event.type !== MessageBusType.TOOL_CALLS_UPDATE) {
return;
}
const toolCalls = event.toolCalls;
toolCalls.forEach((tc) => {
this.handleEventDrivenToolCall(tc);
});
this.checkInputRequiredState();
}
private handleEventDrivenToolCall(tc: ToolCall): void {
const callId = tc.request.callId;
// Do not process events for tools that have already been finalized.
// This prevents duplicate completions if the state manager emits a snapshot containing
// already resolved tools whose IDs were removed from pendingToolCalls.
if (
this.processedToolCallIds.has(callId) ||
this.completedToolCalls.some((c) => c.request.callId === callId)
) {
return;
}
const previousStatus = this.pendingToolCalls.get(callId);
const hasChanged = previousStatus !== tc.status;
// 1. Handle Output
if (tc.status === 'executing' && tc.liveOutput) {
this._schedulerOutputUpdate(callId, tc.liveOutput);
}
// 2. Handle terminal states
if (
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled'
) {
this.toolsAlreadyConfirmed.delete(callId);
if (hasChanged) {
logger.info(
`[Task] Tool call ${callId} completed with status: ${tc.status}`,
);
this.completedToolCalls.push(tc);
this._resolveToolCall(callId);
}
} else {
// Keep track of pending tools
this._registerToolCall(callId, tc.status);
}
// 3. Handle Confirmation Stash
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
const details = tc.confirmationDetails;
if (tc.correlationId) {
this.pendingCorrelationIds.set(callId, tc.correlationId);
}
this.pendingToolConfirmationDetails.set(callId, {
...details,
onConfirm: async () => {},
} as ToolCallConfirmationDetails);
}
// 4. Publish Status Updates to A2A event bus
if (hasChanged) {
const coderAgentMessage: CoderAgentMessage =
tc.status === 'awaiting_approval'
? { kind: CoderAgentEvent.ToolCallConfirmationEvent }
: { kind: CoderAgentEvent.ToolCallUpdateEvent };
const message = this.toolStatusMessage(tc, this.id, this.contextId);
const statusUpdate = this._createStatusUpdateEvent(
this.taskState,
coderAgentMessage,
message,
false,
);
this.eventBus?.publish(statusUpdate);
}
}
private checkInputRequiredState(): void {
if (this.isYoloMatch) {
return;
}
// 6. Handle Input Required State
let isAwaitingApproval = false;
let isExecuting = false;
for (const [callId, status] of this.pendingToolCalls.entries()) {
if (status === 'executing' || status === 'scheduled') {
isExecuting = true;
} else if (
status === 'awaiting_approval' &&
!this.toolsAlreadyConfirmed.has(callId)
) {
isAwaitingApproval = true;
}
}
if (
isAwaitingApproval &&
!isExecuting &&
!this.skipFinalTrueAfterInlineEdit
) {
this.skipFinalTrueAfterInlineEdit = false;
const wasAlreadyInputRequired = this.taskState === 'input-required';
this.setTaskStateAndPublishUpdate(
'input-required',
{ kind: CoderAgentEvent.StateChangeEvent },
undefined,
undefined,
/*final*/ true,
);
// Unblock waitForPendingTools to correctly end the executor loop and release the HTTP response stream.
// The IDE client will open a new stream with the confirmation reply.
if (!wasAlreadyInputRequired && this.toolCompletionNotifier) {
this.toolCompletionNotifier.resolve();
}
}
}
private _pickFields<
T extends ToolCall | AnyDeclarativeTool,
K extends UnionKeys<T>,
@@ -705,7 +903,16 @@ export class Task {
};
this.setTaskStateAndPublishUpdate('working', stateChange);
await this.scheduler.schedule(updatedRequests, abortSignal);
// Pre-register tools to ensure waitForPendingTools sees them as pending
// before the async scheduler enqueues them and fires the event bus update.
for (const req of updatedRequests) {
if (!this.pendingToolCalls.has(req.callId)) {
this._registerToolCall(req.callId, 'scheduled');
}
}
// Fire and forget so we don't block the executor loop before waitForPendingTools can be called
void this.scheduler.schedule(updatedRequests, abortSignal);
}
async acceptAgentMessage(event: ServerGeminiStreamEvent): Promise<void> {
@@ -829,9 +1036,15 @@ export class Task {
) {
return false;
}
if (!part.data['outcome']) {
return false;
}
const callId = part.data['callId'];
const outcomeString = part.data['outcome'];
this.toolsAlreadyConfirmed.add(callId);
let confirmationOutcome: ToolConfirmationOutcome | undefined;
if (outcomeString === 'proceed_once') {
@@ -844,6 +1057,8 @@ export class Task {
confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysServer;
} else if (outcomeString === 'proceed_always_tool') {
confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysTool;
} else if (outcomeString === 'proceed_always_and_save') {
confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysAndSave;
} else if (outcomeString === 'modify_with_editor') {
confirmationOutcome = ToolConfirmationOutcome.ModifyWithEditor;
} else {
@@ -854,8 +1069,9 @@ export class Task {
}
const confirmationDetails = this.pendingToolConfirmationDetails.get(callId);
const correlationId = this.pendingCorrelationIds.get(callId);
if (!confirmationDetails) {
if (!confirmationDetails && !correlationId) {
logger.warn(
`[Task] Received tool confirmation for unknown or already processed callId: ${callId}`,
);
@@ -877,24 +1093,35 @@ export class Task {
// This will trigger the scheduler to continue or cancel the specific tool.
// The scheduler's onToolCallsUpdate will then reflect the new state (e.g., executing or cancelled).
// If `edit` tool call, pass updated payload if presesent
if (confirmationDetails.type === 'edit') {
const newContent = part.data['newContent'];
const payload =
typeof newContent === 'string'
? ({ newContent } as ToolConfirmationPayload)
: undefined;
this.skipFinalTrueAfterInlineEdit = !!payload;
try {
// If `edit` tool call, pass updated payload if present
const newContent = part.data['newContent'];
const payload =
confirmationDetails?.type === 'edit' && typeof newContent === 'string'
? ({ newContent } as ToolConfirmationPayload)
: undefined;
this.skipFinalTrueAfterInlineEdit = !!payload;
try {
if (correlationId) {
await this.config.getMessageBus().publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId,
confirmed:
confirmationOutcome !== ToolConfirmationOutcome.Cancel &&
confirmationOutcome !==
ToolConfirmationOutcome.ModifyWithEditor,
outcome: confirmationOutcome,
payload,
});
} else if (confirmationDetails?.onConfirm) {
// Fallback for legacy callback-based confirmation
await confirmationDetails.onConfirm(confirmationOutcome, payload);
} finally {
// Once confirmationDetails.onConfirm finishes (or fails) with a payload,
// reset skipFinalTrueAfterInlineEdit so that external callers receive
// their call has been completed.
this.skipFinalTrueAfterInlineEdit = false;
}
} else {
await confirmationDetails.onConfirm(confirmationOutcome);
} finally {
// Once confirmation payload is sent or callback finishes,
// reset skipFinalTrueAfterInlineEdit so that external callers receive
// their call has been completed.
this.skipFinalTrueAfterInlineEdit = false;
}
} finally {
if (gcpProject) {
@@ -910,6 +1137,7 @@ export class Task {
// Note !== ToolConfirmationOutcome.ModifyWithEditor does not work!
if (confirmationOutcome !== 'modify_with_editor') {
this.pendingToolConfirmationDetails.delete(callId);
this.pendingCorrelationIds.delete(callId);
}
// If outcome is Cancel, scheduler should update status to 'cancelled', which then resolves the tool.
@@ -943,6 +1171,9 @@ export class Task {
getAndClearCompletedTools(): CompletedToolCall[] {
const tools = [...this.completedToolCalls];
for (const tool of tools) {
this.processedToolCallIds.add(tool.request.callId);
}
this.completedToolCalls = [];
return tools;
}
@@ -1003,6 +1234,7 @@ export class Task {
};
// Set task state to working as we are about to call LLM
this.setTaskStateAndPublishUpdate('working', stateChange);
this.currentAgentMessageId = uuidv4();
yield* this.geminiClient.sendMessageStream(
llmParts,
aborted,
@@ -1024,6 +1256,10 @@ export class Task {
if (confirmationHandled) {
anyConfirmationHandled = true;
// If a confirmation was handled, the scheduler will now run the tool (or cancel it).
// We resolve the toolCompletionPromise manually in checkInputRequiredState
// to break the original execution loop, so we must reset it here so the
// new loop correctly awaits the tool's final execution.
this._resetToolCompletionPromise();
// We don't send anything to the LLM for this part.
// The subsequent tool execution will eventually lead to resolveToolCall.
continue;
@@ -1038,6 +1274,7 @@ export class Task {
if (hasContentForLlm) {
this.currentPromptId =
this.config.getSessionId() + '########' + this.promptCount++;
this.currentAgentMessageId = uuidv4();
logger.info('[Task] Sending new parts to LLM.');
const stateChange: StateChange = {
kind: CoderAgentEvent.StateChangeEvent,
@@ -1083,7 +1320,6 @@ export class Task {
if (content === '') {
return;
}
logger.info('[Task] Sending text content to event bus.');
const message = this._createTextMessage(content);
const textContent: TextContent = {
kind: CoderAgentEvent.TextContentEvent,
@@ -1115,7 +1351,7 @@ export class Task {
data: content,
} as Part,
],
messageId: uuidv4(),
messageId: this.currentAgentMessageId,
taskId: this.id,
contextId: this.contextId,
};
+2
View File
@@ -105,6 +105,8 @@ export async function loadConfig(
checkpointing,
interactive: true,
enableInteractiveShell: true,
enableEventDrivenScheduler:
settings.experimental?.enableEventDrivenScheduler ?? true,
ptyInfo: 'auto',
};
@@ -37,6 +37,12 @@ export interface Settings {
showMemoryUsage?: boolean;
checkpointing?: CheckpointingSettings;
folderTrust?: boolean;
general?: {
previewFeatures?: boolean;
};
experimental?: {
enableEventDrivenScheduler?: boolean;
};
// Git-aware file filtering settings
fileFiltering?: {
@@ -64,6 +64,7 @@ export function createMockConfig(
getEmbeddingModel: vi.fn().mockReturnValue('text-embedding-004'),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getUserTier: vi.fn(),
isEventDrivenSchedulerEnabled: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn(),
getPolicyEngine: vi.fn(),
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.1",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,12 +26,12 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.1"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.41.0",
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.23.0",
"ansi-escapes": "^7.3.0",
@@ -14,7 +14,7 @@ import {
type Mock,
type Mocked,
} from 'vitest';
import { GeminiAgent, Session } from './zedIntegration.js';
import { GeminiAgent, Session } from './acpClient.js';
import type { CommandHandler } from './commandHandler.js';
import * as acp from '@agentclientprotocol/sdk';
import {
@@ -172,7 +172,7 @@ describe('GeminiAgent', () => {
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
@@ -208,7 +208,16 @@ describe('GeminiAgent', () => {
});
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
expect(response.authMethods).toHaveLength(3);
expect(response.authMethods).toHaveLength(4);
const gatewayAuth = response.authMethods?.find(
(m) => m.id === AuthType.GATEWAY,
);
expect(gatewayAuth?._meta).toEqual({
gateway: {
protocol: 'google',
restartRequired: 'false',
},
});
const geminiAuth = response.authMethods?.find(
(m) => m.id === AuthType.USE_GEMINI,
);
@@ -228,6 +237,8 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
undefined,
undefined,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -247,6 +258,8 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
'test-api-key',
undefined,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -255,6 +268,45 @@ describe('GeminiAgent', () => {
);
});
it('should authenticate correctly with gateway method', async () => {
await agent.authenticate({
methodId: AuthType.GATEWAY,
_meta: {
gateway: {
baseUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
},
},
} as unknown as acp.AuthenticateRequest);
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.GATEWAY,
undefined,
'https://example.com',
{ Authorization: 'Bearer token' },
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.GATEWAY,
);
});
it('should throw acp.RequestError when gateway payload is malformed', async () => {
await expect(
agent.authenticate({
methodId: AuthType.GATEWAY,
_meta: {
gateway: {
// Invalid baseUrl
baseUrl: 123,
headers: { Authorization: 'Bearer token' },
},
},
} as unknown as acp.AuthenticateRequest),
).rejects.toThrow(/Malformed gateway payload/);
});
it('should create a new session', async () => {
vi.useFakeTimers();
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
@@ -598,7 +650,7 @@ describe('Session', () => {
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
setApprovalMode: vi.fn(),
setModel: vi.fn(),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService),
waitForMcpInit: vi.fn(),
@@ -70,7 +70,7 @@ import { runExitCleanup } from '../utils/cleanup.js';
import { SessionSelector } from '../utils/sessionUtils.js';
import { CommandHandler } from './commandHandler.js';
export async function runZedIntegration(
export async function runAcpClient(
config: Config,
settings: LoadedSettings,
argv: CliArgs,
@@ -98,6 +98,8 @@ export class GeminiAgent {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
private apiKey: string | undefined;
private baseUrl: string | undefined;
private customHeaders: Record<string, string> | undefined;
constructor(
private config: Config,
@@ -131,6 +133,17 @@ export class GeminiAgent {
name: 'Vertex AI',
description: 'Use an API key with Vertex AI GenAI API',
},
{
id: AuthType.GATEWAY,
name: 'AI API Gateway',
description: 'Use a custom AI API Gateway',
_meta: {
gateway: {
protocol: 'google',
restartRequired: 'false',
},
},
},
];
await this.config.initialize();
@@ -179,7 +192,38 @@ export class GeminiAgent {
if (apiKey) {
this.apiKey = apiKey;
}
await this.config.refreshAuth(method, apiKey ?? this.apiKey);
// Extract gateway details if present
const gatewaySchema = z.object({
baseUrl: z.string().optional(),
headers: z.record(z.string()).optional(),
});
let baseUrl: string | undefined;
let headers: Record<string, string> | undefined;
if (meta?.['gateway']) {
const result = gatewaySchema.safeParse(meta['gateway']);
if (result.success) {
baseUrl = result.data.baseUrl;
headers = result.data.headers;
} else {
throw new acp.RequestError(
-32602,
`Malformed gateway payload: ${result.error.message}`,
);
}
}
this.baseUrl = baseUrl;
this.customHeaders = headers;
await this.config.refreshAuth(
method,
apiKey ?? this.apiKey,
baseUrl,
headers,
);
} catch (e) {
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
}
@@ -209,7 +253,12 @@ export class GeminiAgent {
let isAuthenticated = false;
let authErrorMessage = '';
try {
await config.refreshAuth(authType, this.apiKey);
await config.refreshAuth(
authType,
this.apiKey,
this.baseUrl,
this.customHeaders,
);
isAuthenticated = true;
// Extra validation for Gemini API key
@@ -371,7 +420,12 @@ export class GeminiAgent {
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
await config.refreshAuth(selectedAuthType, this.apiKey);
await config.refreshAuth(
selectedAuthType,
this.apiKey,
this.baseUrl,
this.customHeaders,
);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
@@ -13,7 +13,7 @@ import {
type Mocked,
type Mock,
} from 'vitest';
import { GeminiAgent } from './zedIntegration.js';
import { GeminiAgent } from './acpClient.js';
import * as acp from '@agentclientprotocol/sdk';
import {
ApprovalMode,
@@ -92,7 +92,7 @@ describe('GeminiAgent Session Resume', () => {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
@@ -204,6 +204,11 @@ describe('GeminiAgent Session Resume', () => {
name: 'YOLO',
description: 'Auto-approves all tools',
},
{
id: ApprovalMode.PLAN,
name: 'Plan',
description: 'Read-only mode',
},
],
currentModeId: ApprovalMode.DEFAULT,
},
+2 -2
View File
@@ -2630,13 +2630,13 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should throw error when --approval-mode=plan is used but experimental.plan setting is missing', async () => {
it('should allow plan approval mode by default when --approval-mode=plan is used', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN);
});
it('should pass planSettings.directory from settings to config', async () => {
+12 -3
View File
@@ -81,7 +81,8 @@ export interface CliArgs {
policy: string[] | undefined;
allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined;
experimentalAcp: boolean | undefined;
acp?: boolean;
experimentalAcp?: boolean;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined;
@@ -177,10 +178,15 @@ export async function parseArguments(
.filter(Boolean),
),
})
.option('experimental-acp', {
.option('acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('experimental-acp', {
type: 'boolean',
description:
'Starts the agent in ACP mode (deprecated, use --acp instead)',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
@@ -632,6 +638,7 @@ export async function loadCliConfig(
// -i/--prompt-interactive forces interactive mode with an initial prompt
const interactive =
!!argv.promptInteractive ||
!!argv.acp ||
!!argv.experimentalAcp ||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
!argv.isCommand);
@@ -758,6 +765,7 @@ export async function loadCliConfig(
}
return new Config({
acpMode: !!argv.acp || !!argv.experimentalAcp,
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -821,7 +829,7 @@ export async function loadCliConfig(
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession,
@@ -867,6 +875,7 @@ export async function loadCliConfig(
fakeResponses: argv.fakeResponses,
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors,
billing: settings.billing,
maxAttempts: settings.general?.maxAttempts,
ptyInfo: ptyInfo?.name,
disableLLMCorrection: settings.tools?.disableLLMCorrection,
@@ -89,13 +89,13 @@ describe('Policy Engine Integration Tests', () => {
// Tools from allowed server should be allowed
// Tools from allowed server should be allowed
expect(
(await engine.check({ name: 'allowed-server__tool1' }, undefined))
(await engine.check({ name: 'mcp_allowed-server_tool1' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(
await engine.check(
{ name: 'allowed-server__another_tool' },
{ name: 'mcp_allowed-server_another_tool' },
undefined,
)
).decision,
@@ -103,13 +103,13 @@ describe('Policy Engine Integration Tests', () => {
// Tools from trusted server should be allowed
expect(
(await engine.check({ name: 'trusted-server__tool1' }, undefined))
(await engine.check({ name: 'mcp_trusted-server_tool1' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(
await engine.check(
{ name: 'trusted-server__special_tool' },
{ name: 'mcp_trusted-server_special_tool' },
undefined,
)
).decision,
@@ -117,17 +117,17 @@ describe('Policy Engine Integration Tests', () => {
// Tools from blocked server should be denied
expect(
(await engine.check({ name: 'blocked-server__tool1' }, undefined))
(await engine.check({ name: 'mcp_blocked-server_tool1' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'blocked-server__any_tool' }, undefined))
(await engine.check({ name: 'mcp_blocked-server_any_tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
// Tools from unknown servers should use default
expect(
(await engine.check({ name: 'unknown-server__tool' }, undefined))
(await engine.check({ name: 'mcp_unknown-server_tool' }, undefined))
.decision,
).toBe(PolicyDecision.ASK_USER);
});
@@ -147,12 +147,16 @@ describe('Policy Engine Integration Tests', () => {
// ANY tool with a server name should be allowed
expect(
(await engine.check({ name: 'mcp-server__tool' }, 'mcp-server'))
(await engine.check({ name: 'mcp_mcp-server_tool' }, 'mcp-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'another-server__tool' }, 'another-server'))
.decision,
(
await engine.check(
{ name: 'mcp_another-server_tool' },
'another-server',
)
).decision,
).toBe(PolicyDecision.ALLOW);
// Built-in tools should NOT be allowed by the MCP wildcard
@@ -167,7 +171,7 @@ describe('Policy Engine Integration Tests', () => {
allowed: ['my-server'],
},
tools: {
exclude: ['my-server__dangerous-tool'],
exclude: ['mcp_my-server_dangerous-tool'],
},
};
@@ -180,20 +184,24 @@ describe('Policy Engine Integration Tests', () => {
// MCP server allowed (priority 4.1) provides general allow for server
// MCP server allowed (priority 4.1) provides general allow for server
expect(
(await engine.check({ name: 'my-server__safe-tool' }, undefined))
(await engine.check({ name: 'mcp_my-server_safe-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
// But specific tool exclude (priority 4.4) wins over server allow
expect(
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
.decision,
(
await engine.check(
{ name: 'mcp_my-server_dangerous-tool' },
undefined,
)
).decision,
).toBe(PolicyDecision.DENY);
});
it('should handle complex mixed configurations', async () => {
const settings: Settings = {
tools: {
allowed: ['custom-tool', 'my-server__special-tool'],
allowed: ['custom-tool', 'mcp_my-server_special-tool'],
exclude: ['glob', 'dangerous-tool'],
},
mcp: {
@@ -238,21 +246,21 @@ describe('Policy Engine Integration Tests', () => {
(await engine.check({ name: 'custom-tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'my-server__special-tool' }, undefined))
(await engine.check({ name: 'mcp_my-server_special-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
// MCP server tools
expect(
(await engine.check({ name: 'allowed-server__tool' }, undefined))
(await engine.check({ name: 'mcp_allowed-server_tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'trusted-server__tool' }, undefined))
(await engine.check({ name: 'mcp_trusted-server_tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'blocked-server__tool' }, undefined))
(await engine.check({ name: 'mcp_blocked-server_tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
@@ -479,7 +487,7 @@ describe('Policy Engine Integration Tests', () => {
expect(blockedToolRule?.priority).toBe(4.4); // Command line exclude
const blockedServerRule = rules.find(
(r) => r.toolName === 'blocked-server__*',
(r) => r.toolName === 'mcp_blocked-server_*',
);
expect(blockedServerRule?.priority).toBe(4.9); // MCP server exclude
@@ -489,11 +497,13 @@ describe('Policy Engine Integration Tests', () => {
expect(specificToolRule?.priority).toBe(4.3); // Command line allow
const trustedServerRule = rules.find(
(r) => r.toolName === 'trusted-server__*',
(r) => r.toolName === 'mcp_trusted-server_*',
);
expect(trustedServerRule?.priority).toBe(4.2); // MCP trusted server
const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*');
const mcpServerRule = rules.find(
(r) => r.toolName === 'mcp_mcp-server_*',
);
expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
@@ -505,18 +515,19 @@ describe('Policy Engine Integration Tests', () => {
(await engine.check({ name: 'blocked-tool' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'blocked-server__any' }, undefined))
(await engine.check({ name: 'mcp_blocked-server_any' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'specific-tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'trusted-server__any' }, undefined))
(await engine.check({ name: 'mcp_trusted-server_any' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'mcp-server__any' }, undefined)).decision,
(await engine.check({ name: 'mcp_mcp-server_any' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe(
PolicyDecision.ALLOW,
@@ -545,7 +556,7 @@ describe('Policy Engine Integration Tests', () => {
// Exclusion (195) should win over trust (90)
expect(
(await engine.check({ name: 'conflicted-server__tool' }, undefined))
(await engine.check({ name: 'mcp_conflicted-server_tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
});
@@ -556,7 +567,7 @@ describe('Policy Engine Integration Tests', () => {
excluded: ['my-server'], // Priority 195 - DENY
},
tools: {
allowed: ['my-server__special-tool'], // Priority 100 - ALLOW
allowed: ['mcp_my-server_special-tool'], // Priority 100 - ALLOW
},
};
@@ -569,11 +580,11 @@ describe('Policy Engine Integration Tests', () => {
// Server exclusion (195) wins over specific tool allow (100)
// This might be counterintuitive but follows the priority system
expect(
(await engine.check({ name: 'my-server__special-tool' }, undefined))
(await engine.check({ name: 'mcp_my-server_special-tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'my-server__other-tool' }, undefined))
(await engine.check({ name: 'mcp_my-server_other-tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
});
@@ -643,13 +654,13 @@ describe('Policy Engine Integration Tests', () => {
const tool3Rule = rules.find((r) => r.toolName === 'tool3');
expect(tool3Rule?.priority).toBe(4.4); // Excluded tools (user tier)
const server2Rule = rules.find((r) => r.toolName === 'server2__*');
const server2Rule = rules.find((r) => r.toolName === 'mcp_server2_*');
expect(server2Rule?.priority).toBe(4.9); // Excluded servers (user tier)
const tool1Rule = rules.find((r) => r.toolName === 'tool1');
expect(tool1Rule?.priority).toBe(4.3); // Allowed tools (user tier)
const server1Rule = rules.find((r) => r.toolName === 'server1__*');
const server1Rule = rules.find((r) => r.toolName === 'mcp_server1_*');
expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier)
const globRule = rules.find((r) => r.toolName === 'glob');
@@ -424,12 +424,10 @@ describe('SettingsSchema', () => {
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(false);
expect(setting.default).toBe(true);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(true);
expect(setting.description).toBe(
'Enable planning features (Plan Mode and tools).',
);
expect(setting.description).toBe('Enable Plan Mode.');
});
it('should have hooksConfig.notifications setting in schema', () => {
+2 -2
View File
@@ -1802,8 +1802,8 @@ const SETTINGS_SCHEMA = {
label: 'Plan',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable planning features (Plan Mode and tools).',
default: true,
description: 'Enable Plan Mode.',
showInDialog: true,
},
modelSteering: {
+3 -3
View File
@@ -79,7 +79,7 @@ import {
type InitializationResult,
} from './core/initializer.js';
import { validateAuthMethod } from './config/auth.js';
import { runZedIntegration } from './zed-integration/zedIntegration.js';
import { runAcpClient } from './acp/acpClient.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
@@ -672,8 +672,8 @@ export async function main() {
await getOauthClient(settings.merged.security.auth.selectedType, config);
}
if (config.getExperimentalZedIntegration()) {
return runZedIntegration(config, settings, argv);
if (config.getAcpMode()) {
return runAcpClient(config, settings, argv);
}
let input = config.getQuestion();
+2 -2
View File
@@ -179,7 +179,7 @@ describe('gemini.tsx main function cleanup', () => {
vi.restoreAllMocks();
});
it('should log error when cleanupExpiredSessions fails', async () => {
it.skip('should log error when cleanupExpiredSessions fails', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
@@ -216,7 +216,7 @@ describe('gemini.tsx main function cleanup', () => {
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: vi.fn(() => false),
getExperimentalZedIntegration: vi.fn(() => true),
getAcpMode: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getProjectRoot: vi.fn(() => '/'),
@@ -129,7 +129,7 @@ describe('BuiltinCommandLoader', () => {
vi.clearAllMocks();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
getEnableHooksUI: () => false,
@@ -287,7 +287,7 @@ describe('BuiltinCommandLoader profile', () => {
vi.resetModules();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: () => false,
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
+1 -1
View File
@@ -42,7 +42,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
setSessionId: vi.fn(),
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
getExperimentalZedIntegration: vi.fn(() => false),
getAcpMode: vi.fn(() => false),
isBrowserLaunchSuppressed: vi.fn(() => false),
setRemoteAdminSettings: vi.fn(),
isYoloModeDisabled: vi.fn(() => false),
-130
View File
@@ -2544,136 +2544,6 @@ describe('AppContainer State Management', () => {
});
});
describe('Expansion Persistence', () => {
let rerender: () => void;
let unmount: () => void;
let stdin: ReturnType<typeof render>['stdin'];
const setupExpansionPersistenceTest = async (
HighPriorityChild?: React.FC,
) => {
const getTree = () => (
<SettingsContext.Provider value={mockSettings}>
<KeypressProvider config={mockConfig}>
<OverflowProvider>
<AppContainer
config={mockConfig}
version="1.0.0"
initializationResult={mockInitResult}
/>
{HighPriorityChild && <HighPriorityChild />}
</OverflowProvider>
</KeypressProvider>
</SettingsContext.Provider>
);
const renderResult = render(getTree());
stdin = renderResult.stdin;
await act(async () => {
vi.advanceTimersByTime(100);
});
rerender = () => renderResult.rerender(getTree());
unmount = () => renderResult.unmount();
};
const writeStdin = async (sequence: string) => {
await act(async () => {
stdin.write(sequence);
// Advance timers to allow escape sequence parsing and broadcasting
vi.advanceTimersByTime(100);
});
rerender();
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should reset expansion when a key is NOT handled by anyone', async () => {
await setupExpansionPersistenceTest();
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
rerender();
expect(capturedUIState.constrainHeight).toBe(false);
// Press a random key that no one handles (hits Low priority fallback)
await writeStdin('x');
// Should be reset to true (collapsed)
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
it('should toggle expansion when Ctrl+O is pressed', async () => {
await setupExpansionPersistenceTest();
// Initial state is collapsed
expect(capturedUIState.constrainHeight).toBe(true);
// Press Ctrl+O to expand (Ctrl+O is sequence \x0f)
await writeStdin('\x0f');
expect(capturedUIState.constrainHeight).toBe(false);
// Press Ctrl+O again to collapse
await writeStdin('\x0f');
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
it('should NOT collapse when a high-priority component handles the key (e.g., up/down arrows)', async () => {
const NavigationHandler = () => {
// use real useKeypress
useKeypress(
(key: Key) => {
if (key.name === 'up' || key.name === 'down') {
return true; // Handle navigation
}
return false;
},
{ isActive: true, priority: true }, // High priority
);
return null;
};
await setupExpansionPersistenceTest(NavigationHandler);
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
rerender();
expect(capturedUIState.constrainHeight).toBe(false);
// 1. Simulate Up arrow (handled by high priority child)
// CSI A is Up arrow
await writeStdin('\u001b[A');
// Should STILL be expanded
expect(capturedUIState.constrainHeight).toBe(false);
// 2. Simulate Down arrow (handled by high priority child)
// CSI B is Down arrow
await writeStdin('\u001b[B');
// Should STILL be expanded
expect(capturedUIState.constrainHeight).toBe(false);
// 3. Sanity check: press an unhandled key
await writeStdin('x');
// Should finally collapse
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
});
describe('Shortcuts Help Visibility', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let mockedUseKeypress: Mock;
+2 -9
View File
@@ -1382,11 +1382,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Compute available terminal height based on controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight -
controlsHeight -
staticExtraHeight -
2 -
backgroundShellHeight,
terminalHeight - controlsHeight - backgroundShellHeight - 1,
);
config.setShellExecutionConfig({
@@ -1873,10 +1869,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
useKeypress(handleGlobalKeypress, {
isActive: true,
priority: KeypressPriority.Low,
});
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useKeypress(
() => {
@@ -786,16 +786,21 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
const TITLE_MARGIN = 1;
const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT;
const listHeight = availableHeight
? Math.max(1, availableHeight - overhead)
: undefined;
const questionHeight =
const questionHeightLimit =
listHeight && !isAlternateBuffer
? Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
? question.unconstrainedHeight
? Math.max(1, listHeight - selectionItems.length * 2)
: Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
: undefined;
const maxItemsToShow =
listHeight && questionHeight
? Math.max(1, Math.floor((listHeight - questionHeight) / 2))
listHeight && questionHeightLimit
? Math.max(1, Math.floor((listHeight - questionHeightLimit) / 2))
: selectionItems.length;
return (
@@ -803,7 +808,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
{progressHeader}
<Box marginBottom={TITLE_MARGIN}>
<MaxSizedBox
maxHeight={questionHeight}
maxHeight={questionHeightLimit}
maxWidth={availableWidth}
overflowDirection="bottom"
>
@@ -231,7 +231,7 @@ const createMockConfig = (overrides = {}): Config =>
getDebugMode: vi.fn(() => false),
getAccessibility: vi.fn(() => ({})),
getMcpServers: vi.fn(() => ({})),
isPlanEnabled: vi.fn(() => false),
isPlanEnabled: vi.fn(() => true),
getToolRegistry: () => ({
getTool: vi.fn(),
}),
@@ -247,6 +247,7 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
],
placeholder: 'Type your feedback...',
multiSelect: false,
unconstrainedHeight: false,
},
]}
onSubmit={(answers) => {
@@ -282,7 +282,10 @@ describe('ToolConfirmationQueue', () => {
// hideToolIdentity is true for ask_user -> subtracts 4 instead of 6
// availableContentHeight = 19 - 4 = 15
// ToolConfirmationMessage handlesOwnUI=true -> returns full 15
// AskUserDialog uses 15 lines to render its multi-line question and options.
// AskUserDialog allocates questionHeight = availableHeight - overhead - DIALOG_PADDING.
// listHeight = 15 - overhead (Header:0, Margin:1, Footer:2) = 12.
// maxQuestionHeight = listHeight - 4 = 8.
// 8 lines is enough for the 6-line question.
await waitFor(() => {
expect(lastFrame()).toContain('Line 6');
expect(lastFrame()).not.toContain('lines hidden');
@@ -110,7 +110,6 @@ async function handleOverageMenu(
isDialogPending,
setOverageMenuRequest,
setModelSwitchedFromQuotaError,
historyManager,
} = args;
logBillingEvent(
@@ -155,13 +154,6 @@ async function handleOverageMenu(
setModelSwitchedFromQuotaError(false);
config.setQuotaErrorOccurred(false);
config.setOverageStrategy('always');
historyManager.addItem(
{
type: MessageType.INFO,
text: `Using AI Credits for this request.`,
},
Date.now(),
);
return 'retry_with_credits';
case 'use_fallback':
@@ -86,7 +86,7 @@ describe('useApprovalModeIndicator', () => {
(value: ApprovalMode) => void
>,
isYoloModeDisabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
isTrustedFolder: vi.fn().mockReturnValue(true) as Mock<() => boolean>,
getCoreTools: vi.fn().mockReturnValue([]) as Mock<() => string[]>,
getToolDiscoveryCommand: vi.fn().mockReturnValue(undefined) as Mock<
@@ -271,6 +271,7 @@ describe('useGeminiStream', () => {
addHistory: vi.fn(),
getSessionId: vi.fn(() => 'test-session-id'),
setQuotaErrorOccurred: vi.fn(),
resetBillingTurnState: vi.fn(),
getQuotaErrorOccurred: vi.fn(() => false),
getModel: vi.fn(() => 'gemini-2.5-pro'),
getContentGeneratorConfig: vi.fn(() => ({
+4 -4
View File
@@ -1036,10 +1036,6 @@ export const useGeminiStream = (
'Response stopped due to prohibited image content.',
[FinishReason.NO_IMAGE]:
'Response stopped because no image was generated.',
[FinishReason.IMAGE_RECITATION]:
'Response stopped due to image recitation policy.',
[FinishReason.IMAGE_OTHER]:
'Response stopped due to other image-related reasons.',
};
const message = finishReasonMessages[finishReason];
@@ -1374,6 +1370,9 @@ export const useGeminiStream = (
if (!options?.isContinuation) {
setModelSwitchedFromQuotaError(false);
config.setQuotaErrorOccurred(false);
config.resetBillingTurnState(
settings.merged.billing?.overageStrategy,
);
suppressedToolErrorCountRef.current = 0;
suppressedToolErrorNoteShownRef.current = false;
lowVerbosityFailureNoteShownRef.current = false;
@@ -1534,6 +1533,7 @@ export const useGeminiStream = (
setThought,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
settings.merged.billing?.overageStrategy,
],
);
@@ -67,14 +67,6 @@ export function useQuotaAndFallback({
const isDialogPending = useRef(false);
const isValidationPending = useRef(false);
// Initial overage strategy from settings; runtime value read from config at call time.
const initialOverageStrategy =
(settings.merged.billing?.overageStrategy as
| 'ask'
| 'always'
| 'never'
| undefined) ?? 'ask';
// Set up Flash fallback handler
useEffect(() => {
const fallbackHandler: FallbackModelHandler = async (
@@ -109,9 +101,7 @@ export function useQuotaAndFallback({
? getResetTimeMessage(error.retryDelayMs)
: undefined;
const overageStrategy =
config.getBillingSettings().overageStrategy ??
initialOverageStrategy;
const overageStrategy = config.getBillingSettings().overageStrategy;
const creditsResult = await handleCreditsFlow({
config,
@@ -209,7 +199,6 @@ export function useQuotaAndFallback({
userTier,
paidTier,
settings,
initialOverageStrategy,
setModelSwitchedFromQuotaError,
onShowAuthSelection,
errorVerbosity,
+3 -2
View File
@@ -126,9 +126,10 @@ export function stripUnsafeCharacters(str: string): string {
// C1: 0x80-0x9F
// BiDi: U+200E (LRM), U+200F (RLM), U+202A-U+202E, U+2066-U+2069
// Zero-width: U+200B (ZWSP), U+FEFF (BOM)
// U+0602: ARABIC FOOTNOTE MARKER (can crash string-width)
return strippedVT.replace(
// eslint-disable-next-line no-control-regex
/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F\u200E\u200F\u202A-\u202E\u2066-\u2069\u200B\uFEFF]/g,
/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F\u200E\u200F\u202A-\u202E\u2066-\u2069\u200B\uFEFF\u0602]/g,
'',
);
}
@@ -187,7 +188,7 @@ export const getCachedStringWidth = (str: string): number => {
} catch {
// Fallback for characters that cause string-width to crash (e.g. U+0602)
// See: https://github.com/google-gemini/gemini-cli/issues/16418
width = toCodePoints(stripAnsi(str)).length;
width = toCodePoints(stripUnsafeCharacters(str)).length;
}
stringWidthCache.set(str, width);
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.1",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -25,7 +25,8 @@
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google/genai": "1.41.0",
"@google/genai": "1.30.0",
"@grpc/grpc-js": "^1.14.3",
"@iarna/toml": "^2.2.5",
"@joshua.litt/get-ripgrep": "^0.0.3",
"@modelcontextprotocol/sdk": "^1.23.0",
@@ -59,7 +60,7 @@
"fdir": "^6.4.6",
"fzf": "^0.5.2",
"glob": "^12.0.0",
"google-auth-library": "^10.5.0",
"google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
+5 -3
View File
@@ -102,9 +102,11 @@ const localAgentSchema = z
display_name: z.string().optional(),
tools: z
.array(
z.string().refine((val) => isValidToolName(val), {
message: 'Invalid tool name',
}),
z
.string()
.refine((val) => isValidToolName(val, { allowWildcards: true }), {
message: 'Invalid tool name',
}),
)
.optional(),
model: z.string().optional(),
@@ -17,10 +17,7 @@ import { debugLogger } from '../utils/debugLogger.js';
import { LocalAgentExecutor, type ActivityCallback } from './local-executor.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import {
DiscoveredMCPTool,
MCP_QUALIFIED_NAME_SEPARATOR,
} from '../tools/mcp-tool.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { LSTool } from '../tools/ls.js';
import { LS_TOOL_NAME, READ_FILE_TOOL_NAME } from '../tools/tool-names.js';
import {
@@ -36,6 +33,7 @@ import {
type PartListUnion,
type Tool,
type CallableTool,
type FunctionDeclaration,
} from '@google/genai';
import type { Config } from '../config/config.js';
import { MockTool } from '../test-utils/mock-tool.js';
@@ -504,7 +502,7 @@ describe('LocalAgentExecutor', () => {
it('should automatically qualify MCP tools in agent definitions', async () => {
const serverName = 'mcp-server';
const toolName = 'mcp-tool';
const qualifiedName = `${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${toolName}`;
const qualifiedName = `mcp_${serverName}_${toolName}`;
const mockMcpTool = {
tool: vi.fn(),
@@ -554,6 +552,34 @@ describe('LocalAgentExecutor', () => {
getToolSpy.mockRestore();
});
it('should not duplicate schemas when instantiated tools are provided in toolConfig', async () => {
// Create an instantiated mock tool
const instantiatedTool = new MockTool({ name: 'instantiated_tool' });
// Create an agent definition containing the instantiated tool
const definition = createTestDefinition([instantiatedTool]);
// Create the executor
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
// Extract the prepared tools list using the private method
const toolsList = (
executor as unknown as { prepareToolsList: () => FunctionDeclaration[] }
).prepareToolsList();
// Filter for the specific tool schema
const foundSchemas = (
toolsList as unknown as FunctionDeclaration[]
).filter((t: FunctionDeclaration) => t.name === 'instantiated_tool');
// Assert that there is exactly ONE schema for this tool
expect(foundSchemas).toHaveLength(1);
});
});
describe('run (Execution Loop and Logic)', () => {
+48 -23
View File
@@ -16,7 +16,13 @@ import type {
Schema,
} from '@google/genai';
import { ToolRegistry } from '../tools/tool-registry.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { type AnyDeclarativeTool } from '../tools/tools.js';
import {
DiscoveredMCPTool,
isMcpToolName,
parseMcpToolName,
MCP_TOOL_PREFIX,
} from '../tools/mcp-tool.js';
import { CompressionStatus } from '../core/turn.js';
import { type ToolCallRequestInfo } from '../scheduler/types.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
@@ -125,28 +131,55 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
runtimeContext.getAgentRegistry().getAllAgentNames(),
);
const registerToolByName = (toolName: string) => {
const registerToolInstance = (tool: AnyDeclarativeTool) => {
// Check if the tool is a subagent to prevent recursion.
// We do not allow agents to call other agents.
if (allAgentNames.has(toolName)) {
if (allAgentNames.has(tool.name)) {
debugLogger.warn(
`[LocalAgentExecutor] Skipping subagent tool '${toolName}' for agent '${definition.name}' to prevent recursion.`,
`[LocalAgentExecutor] Skipping subagent tool '${tool.name}' for agent '${definition.name}' to prevent recursion.`,
);
return;
}
agentToolRegistry.registerTool(tool);
};
const registerToolByName = (toolName: string) => {
// Handle global wildcard
if (toolName === '*') {
for (const tool of parentToolRegistry.getAllTools()) {
registerToolInstance(tool);
}
return;
}
// Handle MCP wildcards
if (isMcpToolName(toolName)) {
if (toolName === `${MCP_TOOL_PREFIX}*`) {
for (const tool of parentToolRegistry.getAllTools()) {
if (tool instanceof DiscoveredMCPTool) {
registerToolInstance(tool);
}
}
return;
}
const parsed = parseMcpToolName(toolName);
if (parsed.serverName && parsed.toolName === '*') {
for (const tool of parentToolRegistry.getToolsByServer(
parsed.serverName,
)) {
registerToolInstance(tool);
}
return;
}
}
// If the tool is referenced by name, retrieve it from the parent
// registry and register it with the agent's isolated registry.
const tool = parentToolRegistry.getTool(toolName);
if (tool) {
if (tool instanceof DiscoveredMCPTool) {
// Subagents MUST use fully qualified names for MCP tools to ensure
// unambiguous tool calls and to comply with policy requirements.
// We automatically "upgrade" any MCP tool to its qualified version.
agentToolRegistry.registerTool(tool.asFullyQualifiedTool());
} else {
agentToolRegistry.registerTool(tool);
}
registerToolInstance(tool);
}
};
@@ -1147,22 +1180,14 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
const { toolConfig, outputConfig } = this.definition;
if (toolConfig) {
const toolNamesToLoad: string[] = [];
for (const toolRef of toolConfig.tools) {
if (typeof toolRef === 'string') {
toolNamesToLoad.push(toolRef);
} else if (typeof toolRef === 'object' && 'schema' in toolRef) {
// Tool instance with an explicit schema property.
toolsList.push(toolRef.schema);
} else {
if (typeof toolRef === 'object' && !('schema' in toolRef)) {
// Raw `FunctionDeclaration` object.
toolsList.push(toolRef);
}
}
// Add schemas from tools that were registered by name.
toolsList.push(
...this.toolRegistry.getFunctionDeclarationsFiltered(toolNamesToLoad),
);
// Add schemas from tools that were explicitly registered by name, wildcard, or instance.
toolsList.push(...this.toolRegistry.getFunctionDeclarations());
}
// Always inject complete_task.
@@ -20,14 +20,21 @@ import {
} from '../config/models.js';
import { AuthType } from '../core/contentGenerator.js';
const createMockConfig = (overrides: Partial<Config> = {}): Config =>
({
const createMockConfig = (overrides: Partial<Config> = {}): Config => {
const config = {
getUserTier: () => undefined,
getModel: () => 'gemini-2.5-pro',
getGemini31LaunchedSync: () => false,
getUseCustomToolModelSync: () => {
const useGemini31 = config.getGemini31LaunchedSync();
const authType = config.getContentGeneratorConfig().authType;
return useGemini31 && authType === AuthType.USE_GEMINI;
},
getContentGeneratorConfig: () => ({ authType: undefined }),
...overrides,
}) as unknown as Config;
} as unknown as Config;
return config;
};
describe('policyHelpers', () => {
describe('resolvePolicyChain', () => {
@@ -6,7 +6,6 @@
import type { GenerateContentConfig } from '@google/genai';
import type { Config } from '../config/config.js';
import { AuthType } from '../core/contentGenerator.js';
import type {
FailureKind,
FallbackAction,
@@ -46,9 +45,7 @@ export function resolvePolicyChain(
let chain;
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const useCustomToolModel =
useGemini31 &&
config.getContentGeneratorConfig?.()?.authType === AuthType.USE_GEMINI;
const useCustomToolModel = config.getUseCustomToolModelSync?.() ?? false;
const resolvedModel = resolveModel(
modelFromConfig,
+3 -3
View File
@@ -229,14 +229,14 @@ describe('billing', () => {
expect(isOverageEligibleModel('gemini-3.1-pro-preview')).toBe(true);
});
it('should return true for gemini-3.1-pro-preview-customtools', () => {
it('should return false for gemini-3.1-pro-preview-customtools', () => {
expect(isOverageEligibleModel('gemini-3.1-pro-preview-customtools')).toBe(
false,
);
});
it('should return false for gemini-3-flash-preview', () => {
expect(isOverageEligibleModel('gemini-3-flash-preview')).toBe(false);
it('should return true for gemini-3-flash-preview', () => {
expect(isOverageEligibleModel('gemini-3-flash-preview')).toBe(true);
});
it('should return false for gemini-2.5-pro', () => {
+2
View File
@@ -12,6 +12,7 @@ import type {
import {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
} from '../config/models.js';
/**
@@ -32,6 +33,7 @@ export const G1_CREDIT_TYPE: CreditType = 'GOOGLE_ONE_AI';
export const OVERAGE_ELIGIBLE_MODELS = new Set([
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
]);
/**
+2 -1
View File
@@ -95,7 +95,8 @@ const mockConfig = {
getNoBrowser: () => false,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => false,
getExperimentalZedIntegration: () => false,
getAcpMode: () => false,
isInteractive: () => true,
} as unknown as Config;
// Mock fetch globally
+2 -2
View File
@@ -271,8 +271,8 @@ async function initOauthClient(
await triggerPostAuthCallbacks(client.credentials);
} else {
// In Zed integration, we skip the interactive consent and directly open the browser
if (!config.getExperimentalZedIntegration()) {
// In ACP mode, we skip the interactive consent and directly open the browser
if (!config.getAcpMode()) {
const userConsent = await getConsentForOauth('');
if (!userConsent) {
throw new FatalCancellationError('Authentication cancelled by user.');
+7
View File
@@ -47,7 +47,9 @@ import {
isOverageEligibleModel,
shouldAutoUseCredits,
} from '../billing/billing.js';
import { logBillingEvent } from '../telemetry/loggers.js';
import { coreEvents } from '../utils/events.js';
import { CreditsUsedEvent } from '../telemetry/billingEvents.js';
import type {
CaCountTokenResponse,
@@ -102,6 +104,11 @@ export class CodeAssistServer implements ContentGenerator {
const modelIsEligible = isOverageEligibleModel(req.model);
const shouldEnableCredits = modelIsEligible && autoUse;
if (shouldEnableCredits && !this.config?.getCreditsNotificationShown()) {
this.config?.setCreditsNotificationShown(true);
coreEvents.emitFeedback('info', 'Using AI Credits for this request.');
}
const enabledCreditTypes = shouldEnableCredits
? ([G1_CREDIT_TYPE] as string[])
: undefined;
+63 -2
View File
@@ -500,6 +500,8 @@ describe('Server Config (config.ts)', () => {
config,
authType,
undefined,
undefined,
undefined,
);
// Verify that contentGeneratorConfig is updated
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
@@ -2395,6 +2397,65 @@ describe('Availability Service Integration', () => {
config.resetTurn();
expect(spy).toHaveBeenCalled();
});
it('resetTurn does NOT reset billing state', () => {
const config = new Config({
...baseParams,
billing: { overageStrategy: 'ask' },
});
// Simulate accepting credits mid-turn
config.setOverageStrategy('always');
config.setCreditsNotificationShown(true);
// resetTurn should leave billing state intact
config.resetTurn();
expect(config.getBillingSettings().overageStrategy).toBe('always');
expect(config.getCreditsNotificationShown()).toBe(true);
});
it('resetBillingTurnState resets overageStrategy to configured value', () => {
const config = new Config({
...baseParams,
billing: { overageStrategy: 'ask' },
});
config.setOverageStrategy('always');
expect(config.getBillingSettings().overageStrategy).toBe('always');
config.resetBillingTurnState('ask');
expect(config.getBillingSettings().overageStrategy).toBe('ask');
});
it('resetBillingTurnState preserves overageStrategy when configured as always', () => {
const config = new Config({
...baseParams,
billing: { overageStrategy: 'always' },
});
config.resetBillingTurnState('always');
expect(config.getBillingSettings().overageStrategy).toBe('always');
});
it('resetBillingTurnState defaults to ask when no strategy provided', () => {
const config = new Config({
...baseParams,
billing: { overageStrategy: 'always' },
});
config.resetBillingTurnState();
expect(config.getBillingSettings().overageStrategy).toBe('ask');
});
it('resetBillingTurnState resets creditsNotificationShown', () => {
const config = new Config(baseParams);
config.setCreditsNotificationShown(true);
expect(config.getCreditsNotificationShown()).toBe(true);
config.resetBillingTurnState();
expect(config.getCreditsNotificationShown()).toBe(false);
});
});
describe('Hooks configuration', () => {
@@ -2698,9 +2759,9 @@ describe('Config Quota & Preview Model Access', () => {
});
describe('isPlanEnabled', () => {
it('should return false by default', () => {
it('should return true by default', () => {
const config = new Config(baseParams);
expect(config.isPlanEnabled()).toBe(false);
expect(config.isPlanEnabled()).toBe(true);
});
it('should return true when plan is enabled', () => {
+53 -23
View File
@@ -6,7 +6,6 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { inspect } from 'node:util';
import process from 'node:process';
import type {
@@ -135,7 +134,7 @@ import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
import { startupProfiler } from '../telemetry/startupProfiler.js';
import type { AgentDefinition } from '../agents/types.js';
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
import { isSubpath } from '../utils/paths.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import { UserHintService } from './userHintService.js';
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
import { loadPoliciesFromToml } from '../policy/toml-loader.js';
@@ -503,7 +502,7 @@ export interface ConfigParameters {
model: string;
disableLoopDetection?: boolean;
maxSessionTurns?: number;
experimentalZedIntegration?: boolean;
acpMode?: boolean;
listSessions?: boolean;
deleteSession?: string;
listExtensions?: boolean;
@@ -671,6 +670,7 @@ export class Config implements McpContext {
fallbackModelHandler?: FallbackModelHandler;
validationHandler?: ValidationHandler;
private quotaErrorOccurred: boolean = false;
private creditsNotificationShown: boolean = false;
private modelQuotas: Map<
string,
{ remaining: number; limit: number; resetTime?: string }
@@ -699,7 +699,7 @@ export class Config implements McpContext {
private readonly summarizeToolOutput:
| Record<string, SummarizeToolOutputSettings>
| undefined;
private readonly experimentalZedIntegration: boolean = false;
private readonly acpMode: boolean = false;
private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly includeDirectoryTree: boolean = true;
private readonly importFormat: 'tree' | 'flat';
@@ -869,7 +869,7 @@ export class Config implements McpContext {
this.enableAgents = params.enableAgents ?? false;
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? false;
this.planEnabled = params.plan ?? true;
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
this.skillsSupport = params.skillsSupport ?? true;
@@ -894,8 +894,7 @@ export class Config implements McpContext {
DEFAULT_PROTECT_LATEST_TURN,
};
this.maxSessionTurns = params.maxSessionTurns ?? -1;
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
this.acpMode = params.acpMode ?? false;
this.listSessions = params.listSessions ?? false;
this.deleteSession = params.deleteSession;
this.listExtensions = params.listExtensions ?? false;
@@ -1148,7 +1147,7 @@ export class Config implements McpContext {
}
});
if (!this.interactive || this.experimentalZedIntegration) {
if (!this.interactive || this.acpMode) {
await this.mcpInitializationPromise;
}
@@ -1191,7 +1190,12 @@ export class Config implements McpContext {
return this.contentGenerator;
}
async refreshAuth(authMethod: AuthType, apiKey?: string) {
async refreshAuth(
authMethod: AuthType,
apiKey?: string,
baseUrl?: string,
customHeaders?: Record<string, string>,
) {
// Reset availability service when switching auth
this.modelAvailabilityService.reset();
@@ -1218,6 +1222,8 @@ export class Config implements McpContext {
this,
authMethod,
apiKey,
baseUrl,
customHeaders,
);
this.contentGenerator = await createContentGenerator(
newContentGeneratorConfig,
@@ -1432,6 +1438,12 @@ export class Config implements McpContext {
this.modelAvailabilityService.resetTurn();
}
/** Resets billing state (overageStrategy, creditsNotificationShown) once per user prompt. */
resetBillingTurnState(overageStrategy?: OverageStrategy): void {
this.creditsNotificationShown = false;
this.billing.overageStrategy = overageStrategy ?? 'ask';
}
getMaxSessionTurns(): number {
return this.maxSessionTurns;
}
@@ -1444,6 +1456,14 @@ export class Config implements McpContext {
return this.quotaErrorOccurred;
}
setCreditsNotificationShown(value: boolean): void {
this.creditsNotificationShown = value;
}
getCreditsNotificationShown(): boolean {
return this.creditsNotificationShown;
}
setQuota(
remaining: number | undefined,
limit: number | undefined,
@@ -2205,8 +2225,8 @@ export class Config implements McpContext {
return this.usageStatisticsEnabled;
}
getExperimentalZedIntegration(): boolean {
return this.experimentalZedIntegration;
getAcpMode(): boolean {
return this.acpMode;
}
async waitForMcpInit(): Promise<void> {
@@ -2339,17 +2359,7 @@ export class Config implements McpContext {
* @returns true if the path is allowed, false otherwise.
*/
isPathAllowed(absolutePath: string): boolean {
const realpath = (p: string) => {
let resolved: string;
try {
resolved = fs.realpathSync(p);
} catch {
resolved = path.resolve(p);
}
return os.platform() === 'win32' ? resolved.toLowerCase() : resolved;
};
const resolvedPath = realpath(absolutePath);
const resolvedPath = resolveToRealPath(absolutePath);
const workspaceContext = this.getWorkspaceContext();
if (workspaceContext.isPathWithinWorkspace(resolvedPath)) {
@@ -2357,7 +2367,7 @@ export class Config implements McpContext {
}
const projectTempDir = this.storage.getProjectTempDir();
const resolvedTempDir = realpath(projectTempDir);
const resolvedTempDir = resolveToRealPath(projectTempDir);
return isSubpath(resolvedTempDir, resolvedPath);
}
@@ -2466,6 +2476,26 @@ export class Config implements McpContext {
return this.getGemini31LaunchedSync();
}
/**
* Returns whether the custom tool model should be used.
*/
async getUseCustomToolModel(): Promise<boolean> {
const useGemini3_1 = await this.getGemini31Launched();
const authType = this.contentGeneratorConfig?.authType;
return useGemini3_1 && authType === AuthType.USE_GEMINI;
}
/**
* Returns whether the custom tool model should be used.
*
* Note: This method should only be called after startup, once experiments have been loaded.
*/
getUseCustomToolModelSync(): boolean {
const useGemini3_1 = this.getGemini31LaunchedSync();
const authType = this.contentGeneratorConfig?.authType;
return useGemini3_1 && authType === AuthType.USE_GEMINI;
}
/**
* Returns whether Gemini 3.1 has been launched.
*
+2 -1
View File
@@ -136,7 +136,8 @@ export function isPreviewModel(model: string): boolean {
model === PREVIEW_GEMINI_3_1_MODEL ||
model === PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL ||
model === PREVIEW_GEMINI_FLASH_MODEL ||
model === PREVIEW_GEMINI_MODEL_AUTO
model === PREVIEW_GEMINI_MODEL_AUTO ||
model === GEMINI_MODEL_ALIAS_AUTO
);
}
+3 -5
View File
@@ -24,7 +24,7 @@ vi.mock('fs', async (importOriginal) => {
});
import { Storage } from './storage.js';
import { GEMINI_DIR, homedir } from '../utils/paths.js';
import { GEMINI_DIR, homedir, resolveToRealPath } from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
@@ -279,8 +279,7 @@ describe('Storage additional helpers', () => {
name: 'custom absolute path outside throws',
customDir: '/absolute/path/to/plans',
expected: '',
expectedError:
"Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root '/tmp/project'.",
expectedError: `Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
},
{
name: 'absolute path that happens to be inside project root',
@@ -306,8 +305,7 @@ describe('Storage additional helpers', () => {
name: 'escaping relative path throws',
customDir: '../escaped-plans',
expected: '',
expectedError:
"Custom plans directory '../escaped-plans' resolves to '/tmp/escaped-plans', which is outside the project root '/tmp/project'.",
expectedError: `Custom plans directory '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
},
{
name: 'hidden directory starting with ..',
@@ -162,6 +162,8 @@ export interface Question {
multiSelect?: boolean;
/** Placeholder hint text. For type='text', shown in the input field. For type='choice', shown in the "Other" custom input. */
placeholder?: string;
/** Allow the question to consume more vertical space instead of being strictly capped. */
unconstrainedHeight?: boolean;
}
export interface AskUserRequest {
@@ -103,7 +103,7 @@ The following tools are available in Plan Mode:
<tool>\`exit_plan_mode\`</tool>
<tool>\`write_file\`</tool>
<tool>\`replace\`</tool>
<tool>\`read_data\` (readonly-server)</tool>
<tool>\`mcp_readonly-server_read_data\` (readonly-server)</tool>
</available_tools>
## Rules
@@ -271,7 +271,7 @@ The following tools are available in Plan Mode:
<tool>\`exit_plan_mode\`</tool>
<tool>\`write_file\`</tool>
<tool>\`replace\`</tool>
<tool>\`read_data\` (readonly-server)</tool>
<tool>\`mcp_readonly-server_read_data\` (readonly-server)</tool>
</available_tools>
## Rules
@@ -558,7 +558,7 @@ The following tools are available in Plan Mode:
<tool>\`exit_plan_mode\`</tool>
<tool>\`write_file\`</tool>
<tool>\`replace\`</tool>
<tool>\`read_data\` (readonly-server)</tool>
<tool>\`mcp_readonly-server_read_data\` (readonly-server)</tool>
</available_tools>
## Rules
+20 -2
View File
@@ -59,6 +59,7 @@ export enum AuthType {
USE_VERTEX_AI = 'vertex-ai',
LEGACY_CLOUD_SHELL = 'cloud-shell',
COMPUTE_ADC = 'compute-default-credentials',
GATEWAY = 'gateway',
}
/**
@@ -93,12 +94,16 @@ export type ContentGeneratorConfig = {
vertexai?: boolean;
authType?: AuthType;
proxy?: string;
baseUrl?: string;
customHeaders?: Record<string, string>;
};
export async function createContentGeneratorConfig(
config: Config,
authType: AuthType | undefined,
apiKey?: string,
baseUrl?: string,
customHeaders?: Record<string, string>,
): Promise<ContentGeneratorConfig> {
const geminiApiKey =
apiKey ||
@@ -115,6 +120,8 @@ export async function createContentGeneratorConfig(
const contentGeneratorConfig: ContentGeneratorConfig = {
authType,
proxy: config?.getProxy(),
baseUrl,
customHeaders,
};
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now
@@ -203,9 +210,13 @@ export async function createContentGenerator(
if (
config.authType === AuthType.USE_GEMINI ||
config.authType === AuthType.USE_VERTEX_AI
config.authType === AuthType.USE_VERTEX_AI ||
config.authType === AuthType.GATEWAY
) {
let headers: Record<string, string> = { ...baseHeaders };
if (config.customHeaders) {
headers = { ...headers, ...config.customHeaders };
}
if (gcConfig?.getUsageStatisticsEnabled()) {
const installationManager = new InstallationManager();
const installationId = installationManager.getInstallationId();
@@ -214,7 +225,14 @@ export async function createContentGenerator(
'x-gemini-api-privileged-user-id': `${installationId}`,
};
}
const httpOptions = { headers };
const httpOptions: {
baseUrl?: string;
headers: Record<string, string>;
} = { headers };
if (config.baseUrl) {
httpOptions.baseUrl = config.baseUrl;
}
const googleGenAI = new GoogleGenAI({
apiKey: config.apiKey === '' ? undefined : config.apiKey,
@@ -709,7 +709,7 @@ describe('estimateContextBreakdown', () => {
{
functionDeclarations: [
{
name: 'myserver__search',
name: 'mcp_myserver_search',
description: 'Search via MCP',
parameters: {},
},
@@ -747,8 +747,7 @@ describe('estimateContextBreakdown', () => {
expect(builtinOnly.mcp_servers).toBe(0);
});
it('should not classify tools with __ in the middle of a segment as MCP', () => {
// "__" at start or end (not a valid server__tool pattern) should not be MCP
it('should not classify tools without mcp_ prefix as MCP', () => {
const config = {
tools: [
{
@@ -842,7 +841,7 @@ describe('estimateContextBreakdown', () => {
functionDeclarations: [
{ name: 'read_file', description: 'Read', parameters: {} },
{
name: 'myserver__search',
name: 'mcp_myserver_search',
description: 'MCP search',
parameters: {},
},
@@ -858,7 +857,7 @@ describe('estimateContextBreakdown', () => {
expect(result.history).toBeGreaterThan(0);
// tool_calls should only contain non-MCP tools
expect(result.tool_calls['read_file']).toBeGreaterThan(0);
expect(result.tool_calls['myserver__search']).toBeUndefined();
expect(result.tool_calls['mcp_myserver_search']).toBeUndefined();
// MCP tokens are only in mcp_servers
expect(result.mcp_servers).toBeGreaterThan(0);
});
@@ -870,7 +869,7 @@ describe('estimateContextBreakdown', () => {
parts: [
{
functionCall: {
name: 'myserver__search',
name: 'mcp_myserver_search',
args: { query: 'test' },
},
},
@@ -881,7 +880,7 @@ describe('estimateContextBreakdown', () => {
parts: [
{
functionResponse: {
name: 'myserver__search',
name: 'mcp_myserver_search',
response: { results: [] },
},
},
@@ -890,7 +889,7 @@ describe('estimateContextBreakdown', () => {
];
const result = estimateContextBreakdown(contents);
// MCP tool calls should NOT appear in tool_calls
expect(result.tool_calls['myserver__search']).toBeUndefined();
expect(result.tool_calls['mcp_myserver_search']).toBeUndefined();
// MCP call tokens should only be counted in mcp_servers
expect(result.mcp_servers).toBeGreaterThan(0);
});
@@ -908,7 +907,7 @@ describe('estimateContextBreakdown', () => {
},
{
functionCall: {
name: 'myserver__search',
name: 'mcp_myserver_search',
args: { q: 'hello' },
},
},
@@ -919,7 +918,7 @@ describe('estimateContextBreakdown', () => {
// Non-MCP tools should be in tool_calls
expect(result.tool_calls['read_file']).toBeGreaterThan(0);
// MCP tools should NOT be in tool_calls
expect(result.tool_calls['myserver__search']).toBeUndefined();
expect(result.tool_calls['mcp_myserver_search']).toBeUndefined();
// MCP tool calls should only be in mcp_servers
expect(result.mcp_servers).toBeGreaterThan(0);
});
+12 -4
View File
@@ -465,9 +465,13 @@ describe('Core System Prompt (prompts.ts)', () => {
const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('# Active Approval Mode: Plan');
// Read-only MCP tool should appear with server name
expect(prompt).toContain('`read_data` (readonly-server)');
expect(prompt).toContain(
'`mcp_readonly-server_read_data` (readonly-server)',
);
// Non-read-only MCP tool should not appear (excluded by policy)
expect(prompt).not.toContain('`write_data` (nonreadonly-server)');
expect(prompt).not.toContain(
'`mcp_nonreadonly-server_write_data` (nonreadonly-server)',
);
expect(prompt).toMatchSnapshot();
});
@@ -485,8 +489,12 @@ describe('Core System Prompt (prompts.ts)', () => {
const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('`read_data` (readonly-server)');
expect(prompt).not.toContain('`write_data` (nonreadonly-server)');
expect(prompt).toContain(
'`mcp_readonly-server_read_data` (readonly-server)',
);
expect(prompt).not.toContain(
'`mcp_nonreadonly-server_write_data` (nonreadonly-server)',
);
});
it('should only list available tools in PLAN mode', () => {
+17 -27
View File
@@ -202,8 +202,7 @@ describe('createPolicyEngineConfig', () => {
'/tmp/mock/default/policies',
);
const rule = config.rules?.find(
(r) =>
r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW,
(r) => r.mcpName === 'my-server' && r.decision === PolicyDecision.ALLOW,
);
expect(rule).toBeDefined();
expect(rule?.priority).toBe(4.1); // MCP allowed server
@@ -220,8 +219,7 @@ describe('createPolicyEngineConfig', () => {
'/tmp/mock/default/policies',
);
const rule = config.rules?.find(
(r) =>
r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY,
(r) => r.mcpName === 'my-server' && r.decision === PolicyDecision.DENY,
);
expect(rule).toBeDefined();
expect(rule?.priority).toBe(4.9); // MCP excluded server
@@ -247,8 +245,7 @@ describe('createPolicyEngineConfig', () => {
const trustedRule = config.rules?.find(
(r) =>
r.toolName === 'trusted-server__*' &&
r.decision === PolicyDecision.ALLOW,
r.mcpName === 'trusted-server' && r.decision === PolicyDecision.ALLOW,
);
expect(trustedRule).toBeDefined();
expect(trustedRule?.priority).toBe(4.2); // MCP trusted server
@@ -256,8 +253,7 @@ describe('createPolicyEngineConfig', () => {
// Untrusted server should not have an allow rule
const untrustedRule = config.rules?.find(
(r) =>
r.toolName === 'untrusted-server__*' &&
r.decision === PolicyDecision.ALLOW,
r.mcpName === 'untrusted-server' && r.decision === PolicyDecision.ALLOW,
);
expect(untrustedRule).toBeUndefined();
});
@@ -284,8 +280,7 @@ describe('createPolicyEngineConfig', () => {
// Check allowed server
const allowedRule = config.rules?.find(
(r) =>
r.toolName === 'allowed-server__*' &&
r.decision === PolicyDecision.ALLOW,
r.mcpName === 'allowed-server' && r.decision === PolicyDecision.ALLOW,
);
expect(allowedRule).toBeDefined();
expect(allowedRule?.priority).toBe(4.1); // MCP allowed server
@@ -293,8 +288,7 @@ describe('createPolicyEngineConfig', () => {
// Check trusted server
const trustedRule = config.rules?.find(
(r) =>
r.toolName === 'trusted-server__*' &&
r.decision === PolicyDecision.ALLOW,
r.mcpName === 'trusted-server' && r.decision === PolicyDecision.ALLOW,
);
expect(trustedRule).toBeDefined();
expect(trustedRule?.priority).toBe(4.2); // MCP trusted server
@@ -302,8 +296,7 @@ describe('createPolicyEngineConfig', () => {
// Check excluded server
const excludedRule = config.rules?.find(
(r) =>
r.toolName === 'excluded-server__*' &&
r.decision === PolicyDecision.DENY,
r.mcpName === 'excluded-server' && r.decision === PolicyDecision.DENY,
);
expect(excludedRule).toBeDefined();
expect(excludedRule?.priority).toBe(4.9); // MCP excluded server
@@ -368,7 +361,7 @@ describe('createPolicyEngineConfig', () => {
const { createPolicyEngineConfig } = await import('./config.js');
const settings: PolicySettings = {
mcp: { excluded: ['my-server'] },
tools: { allowed: ['my-server__specific-tool'] },
tools: { allowed: ['mcp_my-server_specific-tool'] },
};
const config = await createPolicyEngineConfig(
settings,
@@ -377,12 +370,11 @@ describe('createPolicyEngineConfig', () => {
);
const serverDenyRule = config.rules?.find(
(r) =>
r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY,
(r) => r.mcpName === 'my-server' && r.decision === PolicyDecision.DENY,
);
const toolAllowRule = config.rules?.find(
(r) =>
r.toolName === 'my-server__specific-tool' &&
r.toolName === 'mcp_my-server_specific-tool' &&
r.decision === PolicyDecision.ALLOW,
);
@@ -404,7 +396,7 @@ describe('createPolicyEngineConfig', () => {
trust: true,
},
},
tools: { exclude: ['my-server__dangerous-tool'] },
tools: { exclude: ['mcp_my-server_dangerous-tool'] },
};
const config = await createPolicyEngineConfig(
settings,
@@ -413,12 +405,11 @@ describe('createPolicyEngineConfig', () => {
);
const serverAllowRule = config.rules?.find(
(r) =>
r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW,
(r) => r.mcpName === 'my-server' && r.decision === PolicyDecision.ALLOW,
);
const toolDenyRule = config.rules?.find(
(r) =>
r.toolName === 'my-server__dangerous-tool' &&
r.toolName === 'mcp_my-server_dangerous-tool' &&
r.decision === PolicyDecision.DENY,
);
@@ -432,8 +423,8 @@ describe('createPolicyEngineConfig', () => {
it('should handle complex priority scenarios correctly', async () => {
const settings: PolicySettings = {
tools: {
allowed: ['my-server__tool1', 'other-tool'], // Priority 4.3
exclude: ['my-server__tool2', 'glob'], // Priority 4.4
allowed: ['mcp_trusted-server_tool1', 'other-tool'], // Priority 4.3
exclude: ['mcp_trusted-server_tool2', 'glob'], // Priority 4.4
},
mcp: {
allowed: ['allowed-server'], // Priority 4.1
@@ -564,13 +555,12 @@ describe('createPolicyEngineConfig', () => {
// Neither server should have an allow rule
const noTrustRule = config.rules?.find(
(r) =>
r.toolName === 'no-trust-property__*' &&
r.mcpName === 'no-trust-property' &&
r.decision === PolicyDecision.ALLOW,
);
const explicitFalseRule = config.rules?.find(
(r) =>
r.toolName === 'explicit-false__*' &&
r.decision === PolicyDecision.ALLOW,
r.mcpName === 'explicit-false' && r.decision === PolicyDecision.ALLOW,
);
expect(noTrustRule).toBeUndefined();
+14 -4
View File
@@ -31,6 +31,7 @@ import { debugLogger } from '../utils/debugLogger.js';
import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
import { isNodeError } from '../utils/errors.js';
import { MCP_TOOL_PREFIX } from '../tools/mcp-tool.js';
import { isDirectorySecure } from '../utils/security.js';
@@ -342,7 +343,11 @@ export async function createPolicyEngineConfig(
if (settings.mcp?.excluded) {
for (const serverName of settings.mcp.excluded) {
rules.push({
toolName: `${serverName}__*`,
toolName:
serverName === '*'
? `${MCP_TOOL_PREFIX}*`
: `${MCP_TOOL_PREFIX}${serverName}_*`,
mcpName: serverName,
decision: PolicyDecision.DENY,
priority: MCP_EXCLUDED_PRIORITY,
source: 'Settings (MCP Excluded)',
@@ -423,9 +428,10 @@ export async function createPolicyEngineConfig(
)) {
if (serverConfig.trust) {
// Trust all tools from this MCP server
// Using pattern matching for MCP tool names which are formatted as "serverName__toolName"
// Using explicit mcpName metadata and FQN mcp_{serverName}_*
rules.push({
toolName: `${serverName}__*`,
toolName: `${MCP_TOOL_PREFIX}${serverName}_*`,
mcpName: serverName,
decision: PolicyDecision.ALLOW,
priority: TRUSTED_MCP_SERVER_PRIORITY,
source: 'Settings (MCP Trusted)',
@@ -439,7 +445,11 @@ export async function createPolicyEngineConfig(
if (settings.mcp?.allowed) {
for (const serverName of settings.mcp.allowed) {
rules.push({
toolName: `${serverName}__*`,
toolName:
serverName === '*'
? `${MCP_TOOL_PREFIX}*`
: `${MCP_TOOL_PREFIX}${serverName}_*`,
mcpName: serverName,
decision: PolicyDecision.ALLOW,
priority: ALLOWED_MCP_SERVER_PRIORITY,
source: 'Settings (MCP Allowed)',
@@ -51,3 +51,8 @@ priority = 50
toolName = "google_web_search"
decision = "allow"
priority = 50
[[rule]]
toolName = ["codebase_investigator", "cli_help"]
decision = "allow"
priority = 50
+255 -129
View File
@@ -150,7 +150,8 @@ describe('PolicyEngine', () => {
it('should match unqualified tool names with qualified rules when serverName is provided', async () => {
const rules: PolicyRule[] = [
{
toolName: 'my-server__tool',
toolName: 'mcp_my-server_tool',
mcpName: 'my-server',
decision: PolicyDecision.ALLOW,
},
];
@@ -159,23 +160,9 @@ describe('PolicyEngine', () => {
// Match with qualified name (standard)
expect(
(await engine.check({ name: 'my-server__tool' }, 'my-server')).decision,
(await engine.check({ name: 'mcp_my-server_tool' }, 'my-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
// Match with unqualified name + serverName (the fix)
expect((await engine.check({ name: 'tool' }, 'my-server')).decision).toBe(
PolicyDecision.ALLOW,
);
// Should NOT match with unqualified name but NO serverName
expect((await engine.check({ name: 'tool' }, undefined)).decision).toBe(
PolicyDecision.ASK_USER,
);
// Should NOT match with unqualified name but WRONG serverName
expect(
(await engine.check({ name: 'tool' }, 'wrong-server')).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should match by args pattern', async () => {
@@ -346,6 +333,48 @@ describe('PolicyEngine', () => {
PolicyDecision.ASK_USER,
);
});
it('should return ALLOW by default in YOLO mode when no rules match', async () => {
engine = new PolicyEngine({ approvalMode: ApprovalMode.YOLO });
// No rules defined, should return ALLOW in YOLO mode
const { decision } = await engine.check({ name: 'any-tool' }, undefined);
expect(decision).toBe(PolicyDecision.ALLOW);
});
it('should NOT override explicit DENY rules in YOLO mode', async () => {
const rules: PolicyRule[] = [
{ toolName: 'dangerous-tool', decision: PolicyDecision.DENY },
];
engine = new PolicyEngine({ rules, approvalMode: ApprovalMode.YOLO });
const { decision } = await engine.check(
{ name: 'dangerous-tool' },
undefined,
);
expect(decision).toBe(PolicyDecision.DENY);
// But other tools still allowed
expect(
(await engine.check({ name: 'safe-tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
});
it('should respect rule priority in YOLO mode when a match exists', async () => {
const rules: PolicyRule[] = [
{
toolName: 'test-tool',
decision: PolicyDecision.ASK_USER,
priority: 10,
},
{ toolName: 'test-tool', decision: PolicyDecision.DENY, priority: 20 },
];
engine = new PolicyEngine({ rules, approvalMode: ApprovalMode.YOLO });
// Priority 20 (DENY) should win over priority 10 (ASK_USER)
const { decision } = await engine.check({ name: 'test-tool' }, undefined);
expect(decision).toBe(PolicyDecision.DENY);
});
});
describe('addRule', () => {
@@ -476,61 +505,41 @@ describe('PolicyEngine', () => {
(await engine.check({ name: 'read_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'my-server__tool' }, 'my-server')).decision,
(await engine.check({ name: 'mcp_my-server_tool' }, 'my-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
});
it('should match any MCP tool when toolName is *__*', async () => {
it('should match any MCP tool when toolName is mcp_*', async () => {
engine = new PolicyEngine({
rules: [
{ toolName: '*__*', decision: PolicyDecision.ALLOW, priority: 10 },
{ toolName: 'mcp_*', decision: PolicyDecision.ALLOW, priority: 10 },
],
defaultDecision: PolicyDecision.DENY,
});
expect((await engine.check({ name: 'mcp__tool' }, 'mcp')).decision).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'other__tool' }, 'other')).decision,
(await engine.check({ name: 'mcp_mcp_tool' }, 'mcp')).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'mcp_other_tool' }, 'other')).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'read_file' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
});
it('should match specific tool across all servers when using *__tool', async () => {
engine = new PolicyEngine({
rules: [
{
toolName: '*__search',
decision: PolicyDecision.ALLOW,
priority: 10,
},
],
defaultDecision: PolicyDecision.DENY,
});
expect((await engine.check({ name: 'ws__search' }, 'ws')).decision).toBe(
PolicyDecision.ALLOW,
);
expect((await engine.check({ name: 'gh__search' }, 'gh')).decision).toBe(
PolicyDecision.ALLOW,
);
expect((await engine.check({ name: 'gh__list' }, 'gh')).decision).toBe(
PolicyDecision.DENY,
);
});
it('should match MCP server wildcard patterns', async () => {
const rules: PolicyRule[] = [
{
toolName: 'my-server__*',
toolName: 'mcp_my-server_*',
mcpName: 'my-server',
decision: PolicyDecision.ALLOW,
priority: 10,
},
{
toolName: 'blocked-server__*',
toolName: 'mcp_blocked-server_*',
mcpName: 'blocked-server',
decision: PolicyDecision.DENY,
priority: 20,
},
@@ -540,19 +549,23 @@ describe('PolicyEngine', () => {
// Should match my-server tools
expect(
(await engine.check({ name: 'my-server__tool1' }, 'my-server'))
(await engine.check({ name: 'mcp_my-server_tool1' }, 'my-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'my-server__another_tool' }, 'my-server'))
.decision,
(
await engine.check(
{ name: 'mcp_my-server_another_tool' },
'my-server',
)
).decision,
).toBe(PolicyDecision.ALLOW);
// Should match blocked-server tools
expect(
(
await engine.check(
{ name: 'blocked-server__tool1' },
{ name: 'mcp_blocked-server_tool1' },
'blocked-server',
)
).decision,
@@ -560,7 +573,7 @@ describe('PolicyEngine', () => {
expect(
(
await engine.check(
{ name: 'blocked-server__dangerous' },
{ name: 'mcp_blocked-server_dangerous' },
'blocked-server',
)
).decision,
@@ -568,7 +581,7 @@ describe('PolicyEngine', () => {
// Should not match other patterns
expect(
(await engine.check({ name: 'other-server__tool' }, 'other-server'))
(await engine.check({ name: 'mcp_other-server_tool' }, 'other-server'))
.decision,
).toBe(PolicyDecision.ASK_USER);
expect(
@@ -582,12 +595,14 @@ describe('PolicyEngine', () => {
it('should prioritize specific tool rules over server wildcards', async () => {
const rules: PolicyRule[] = [
{
toolName: 'my-server__*',
toolName: 'mcp_my-server_*',
mcpName: 'my-server',
decision: PolicyDecision.ALLOW,
priority: 10,
},
{
toolName: 'my-server__dangerous-tool',
toolName: 'mcp_my-server_dangerous-tool',
mcpName: 'my-server',
decision: PolicyDecision.DENY,
priority: 20,
},
@@ -597,33 +612,38 @@ describe('PolicyEngine', () => {
// Specific tool deny should override server allow
expect(
(await engine.check({ name: 'my-server__dangerous-tool' }, 'my-server'))
.decision,
(
await engine.check(
{ name: 'mcp_my-server_dangerous-tool' },
'my-server',
)
).decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'my-server__safe-tool' }, 'my-server'))
(await engine.check({ name: 'mcp_my-server_safe-tool' }, 'my-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
});
it('should NOT match spoofed server names when using wildcards', async () => {
// Vulnerability: A rule for 'prefix__*' matches 'prefix__suffix__tool'
// effectively allowing a server named 'prefix__suffix' to spoof 'prefix'.
// Vulnerability: A rule for 'mcp_prefix_*' matches 'mcp_prefix__suffix_tool'
// effectively allowing a server named 'mcp_prefix_suffix' to spoof 'prefix'.
const rules: PolicyRule[] = [
{
toolName: 'safe_server__*',
toolName: 'mcp_safe_server_*',
mcpName: 'safe_server',
decision: PolicyDecision.ALLOW,
},
];
engine = new PolicyEngine({ rules });
// A tool from a different server 'safe_server__malicious'
const spoofedToolCall = { name: 'safe_server__malicious__tool' };
// A tool from a different server 'mcp_safe_server_malicious'
const spoofedToolCall = { name: 'mcp_mcp_safe_server_malicious_tool' };
// CURRENT BEHAVIOR (FIXED): Matches because it starts with 'safe_server__' BUT serverName doesn't match 'safe_server'
// We expect this to FAIL matching the ALLOW rule, thus falling back to default (ASK_USER)
expect(
(await engine.check(spoofedToolCall, 'safe_server__malicious'))
(await engine.check(spoofedToolCall, 'mcp_safe_server_malicious'))
.decision,
).toBe(PolicyDecision.ASK_USER);
});
@@ -631,14 +651,15 @@ describe('PolicyEngine', () => {
it('should verify tool name prefix even if serverName matches', async () => {
const rules: PolicyRule[] = [
{
toolName: 'safe_server__*',
toolName: 'mcp_safe_server_*',
mcpName: 'safe_server',
decision: PolicyDecision.ALLOW,
},
];
engine = new PolicyEngine({ rules });
// serverName matches, but tool name does not start with prefix
const invalidToolCall = { name: 'other_server__tool' };
const invalidToolCall = { name: 'mcp_other_server_tool' };
expect(
(await engine.check(invalidToolCall, 'safe_server')).decision,
).toBe(PolicyDecision.ASK_USER);
@@ -647,13 +668,14 @@ describe('PolicyEngine', () => {
it('should allow when both serverName and tool name prefix match', async () => {
const rules: PolicyRule[] = [
{
toolName: 'safe_server__*',
toolName: 'mcp_safe_server_*',
mcpName: 'safe_server',
decision: PolicyDecision.ALLOW,
},
];
engine = new PolicyEngine({ rules });
const validToolCall = { name: 'safe_server__tool' };
const validToolCall = { name: 'mcp_safe_server_tool' };
expect((await engine.check(validToolCall, 'safe_server')).decision).toBe(
PolicyDecision.ALLOW,
);
@@ -2007,11 +2029,16 @@ describe('PolicyEngine', () => {
it('should support wildcard patterns for checkers', async () => {
const rules: PolicyRule[] = [
{ toolName: 'server__tool', decision: PolicyDecision.ALLOW },
{
toolName: 'mcp_server_tool',
mcpName: 'server',
decision: PolicyDecision.ALLOW,
},
];
const wildcardChecker: SafetyCheckerRule = {
checker: { type: 'external', name: 'wildcard' },
toolName: 'server__*',
toolName: 'mcp_server_*',
mcpName: 'server',
};
engine = new PolicyEngine(
@@ -2023,7 +2050,7 @@ describe('PolicyEngine', () => {
decision: SafetyCheckDecision.ALLOW,
});
await engine.check({ name: 'server__tool' }, 'server');
await engine.check({ name: 'mcp_server_tool' }, 'server');
expect(mockCheckerRunner.runChecker).toHaveBeenCalledWith(
expect.anything(),
@@ -2137,6 +2164,8 @@ describe('PolicyEngine', () => {
rules: PolicyRule[];
approvalMode?: ApprovalMode;
nonInteractive?: boolean;
allToolNames?: string[];
metadata?: Map<string, Record<string, unknown>>;
expected: string[];
}
@@ -2144,11 +2173,13 @@ describe('PolicyEngine', () => {
{
name: 'should return empty set when no rules provided',
rules: [],
allToolNames: ['tool1'],
expected: [],
},
{
name: 'should apply rules without explicit modes to all modes',
rules: [{ toolName: 'tool1', decision: PolicyDecision.DENY }],
allToolNames: ['tool1', 'tool2'],
expected: ['tool1'],
},
{
@@ -2168,6 +2199,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.DEFAULT],
},
],
allToolNames: ['tool1'],
expected: [],
},
{
@@ -2184,6 +2216,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.DEFAULT],
},
],
allToolNames: ['tool1', 'tool2', 'tool3'],
expected: ['tool1'],
},
{
@@ -2202,6 +2235,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.DEFAULT],
},
],
allToolNames: ['tool1'],
expected: ['tool1'],
},
{
@@ -2220,6 +2254,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.DEFAULT],
},
],
allToolNames: ['tool1'],
expected: [],
},
{
@@ -2232,7 +2267,8 @@ describe('PolicyEngine', () => {
},
],
nonInteractive: true,
expected: [],
allToolNames: ['tool1'],
expected: ['tool1'],
},
{
name: 'should ignore rules with argsPattern',
@@ -2244,6 +2280,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.DEFAULT],
},
],
allToolNames: ['tool1'],
expected: [],
},
{
@@ -2256,6 +2293,7 @@ describe('PolicyEngine', () => {
},
],
approvalMode: ApprovalMode.PLAN,
allToolNames: ['tool1'],
expected: ['tool1'],
},
{
@@ -2268,6 +2306,7 @@ describe('PolicyEngine', () => {
},
],
approvalMode: ApprovalMode.DEFAULT,
allToolNames: ['tool1'],
expected: [],
},
{
@@ -2286,36 +2325,55 @@ describe('PolicyEngine', () => {
},
],
approvalMode: ApprovalMode.YOLO,
allToolNames: ['dangerous-tool', 'safe-tool'],
expected: [],
},
{
name: 'should respect server wildcard DENY',
rules: [
{
toolName: 'server__*',
toolName: 'mcp_server_*',
mcpName: 'server',
decision: PolicyDecision.DENY,
modes: [ApprovalMode.DEFAULT],
},
],
expected: ['server__*'],
allToolNames: [
'mcp_server_tool1',
'mcp_server_tool2',
'mcp_other_tool',
],
metadata: new Map([
['mcp_server_tool1', { _serverName: 'server' }],
['mcp_server_tool2', { _serverName: 'server' }],
['mcp_other_tool', { _serverName: 'other' }],
]),
expected: ['mcp_server_tool1', 'mcp_server_tool2'],
},
{
name: 'should expand server wildcard for specific tools if already processed',
rules: [
{
toolName: 'server__*',
toolName: 'mcp_server_*',
mcpName: 'server',
decision: PolicyDecision.DENY,
priority: 100,
modes: [ApprovalMode.DEFAULT],
},
{
toolName: 'server__tool1',
decision: PolicyDecision.DENY,
toolName: 'mcp_server_tool1',
mcpName: 'server',
decision: PolicyDecision.DENY, // redundant but tests ordering
priority: 10,
modes: [ApprovalMode.DEFAULT],
},
],
expected: ['server__*', 'server__tool1'],
allToolNames: ['mcp_server_tool1', 'mcp_server_tool2'],
metadata: new Map([
['mcp_server_tool1', { _serverName: 'server' }],
['mcp_server_tool2', { _serverName: 'server' }],
]),
expected: ['mcp_server_tool1', 'mcp_server_tool2'],
},
{
name: 'should exclude run_shell_command but NOT write_file in simulated Plan Mode',
@@ -2342,24 +2400,29 @@ describe('PolicyEngine', () => {
priority: 10,
},
],
expected: ['run_shell_command'],
allToolNames: ['write_file', 'run_shell_command', 'read_file'],
expected: ['run_shell_command', 'read_file'],
},
{
name: 'should NOT exclude tool if covered by a higher priority wildcard ALLOW',
rules: [
{
toolName: 'server__*',
toolName: 'mcp_server_*',
mcpName: 'server',
decision: PolicyDecision.ALLOW,
priority: 100,
modes: [ApprovalMode.DEFAULT],
},
{
toolName: 'server__tool1',
toolName: 'mcp_server_tool1',
mcpName: 'server',
decision: PolicyDecision.DENY,
priority: 10,
modes: [ApprovalMode.DEFAULT],
},
],
allToolNames: ['mcp_server_tool1'],
metadata: new Map([['mcp_server_tool1', { _serverName: 'server' }]]),
expected: [],
},
{
@@ -2371,41 +2434,63 @@ describe('PolicyEngine', () => {
priority: 10,
},
],
expected: ['*'],
allToolNames: ['toolA', 'toolB', 'mcp_server_toolC'],
expected: ['toolA', 'toolB', 'mcp_server_toolC'], // all tools denied by *
},
{
name: 'should handle MCP category wildcard *__* in getExcludedTools',
rules: [
{
toolName: '*__*',
toolName: 'mcp_*',
decision: PolicyDecision.DENY,
priority: 10,
},
],
expected: ['*__*'],
allToolNames: ['localTool', 'mcp_myserver_mytool'],
metadata: new Map([
['mcp_myserver_mytool', { _serverName: 'myserver' }],
]),
expected: ['mcp_myserver_mytool'],
},
{
name: 'should handle tool wildcard *__search in getExcludedTools',
name: 'should handle tool wildcard mcp_server_* in getExcludedTools',
rules: [
{
toolName: '*__search',
toolName: 'mcp_server_*',
decision: PolicyDecision.DENY,
priority: 10,
},
],
expected: ['*__search'],
allToolNames: [
'localTool',
'mcp_server_search',
'mcp_otherserver_read',
],
metadata: new Map([
['mcp_server_search', { _serverName: 'server' }],
['mcp_otherserver_read', { _serverName: 'otherserver' }],
]),
expected: ['mcp_server_search'],
},
];
it.each(testCases)(
'$name',
({ rules, approvalMode, nonInteractive, expected }) => {
({
rules,
approvalMode,
nonInteractive,
allToolNames,
metadata,
expected,
}) => {
engine = new PolicyEngine({
rules,
approvalMode: approvalMode ?? ApprovalMode.DEFAULT,
nonInteractive: nonInteractive ?? false,
});
const excluded = engine.getExcludedTools();
const toolsSet = allToolNames ? new Set(allToolNames) : undefined;
const excluded = engine.getExcludedTools(metadata, toolsSet);
expect(Array.from(excluded).sort()).toEqual(expected.sort());
},
);
@@ -2420,7 +2505,10 @@ describe('PolicyEngine', () => {
},
],
});
const excluded = engine.getExcludedTools();
const excluded = engine.getExcludedTools(
undefined,
new Set(['dangerous_tool']),
);
expect(Array.from(excluded)).toEqual([]);
});
@@ -2438,7 +2526,10 @@ describe('PolicyEngine', () => {
['dangerous_tool', { destructiveHint: true }],
['safe_tool', { readOnlyHint: true }],
]);
const excluded = engine.getExcludedTools(metadata);
const excluded = engine.getExcludedTools(
metadata,
new Set(['dangerous_tool', 'safe_tool']),
);
expect(Array.from(excluded)).toEqual(['dangerous_tool']);
});
@@ -2455,7 +2546,10 @@ describe('PolicyEngine', () => {
const metadata = new Map<string, Record<string, unknown>>([
['safe_tool', { readOnlyHint: true }],
]);
const excluded = engine.getExcludedTools(metadata);
const excluded = engine.getExcludedTools(
metadata,
new Set(['safe_tool']),
);
expect(Array.from(excluded)).toEqual([]);
});
@@ -2463,7 +2557,8 @@ describe('PolicyEngine', () => {
engine = new PolicyEngine({
rules: [
{
toolName: 'server__*',
toolName: 'mcp_server_*',
mcpName: 'server',
toolAnnotations: { destructiveHint: true },
decision: PolicyDecision.DENY,
priority: 10,
@@ -2471,12 +2566,25 @@ describe('PolicyEngine', () => {
],
});
const metadata = new Map<string, Record<string, unknown>>([
['server__dangerous_tool', { destructiveHint: true }],
['other__dangerous_tool', { destructiveHint: true }],
['server__safe_tool', { readOnlyHint: true }],
[
'mcp_server_dangerous_tool',
{ destructiveHint: true, _serverName: 'server' },
],
[
'mcp_other_dangerous_tool',
{ destructiveHint: true, _serverName: 'other' },
],
['mcp_server_safe_tool', { readOnlyHint: true, _serverName: 'server' }],
]);
const excluded = engine.getExcludedTools(metadata);
expect(Array.from(excluded)).toEqual(['server__dangerous_tool']);
const excluded = engine.getExcludedTools(
metadata,
new Set([
'mcp_server_dangerous_tool',
'mcp_other_dangerous_tool',
'mcp_server_safe_tool',
]),
);
expect(Array.from(excluded)).toEqual(['mcp_server_dangerous_tool']);
});
it('should exclude unprocessed tools from allToolNames when global DENY is active', () => {
@@ -2493,8 +2601,8 @@ describe('PolicyEngine', () => {
priority: 70,
},
{
// Simulates plan.toml: mcpName="*" → toolName="*__*"
toolName: '*__*',
// Simulates plan.toml: mcpName="*" → toolName="mcp_*"
toolName: 'mcp_*',
toolAnnotations: { readOnlyHint: true },
decision: PolicyDecision.ASK_USER,
priority: 70,
@@ -2505,36 +2613,42 @@ describe('PolicyEngine', () => {
},
],
});
// MCP tools are registered with unqualified names in ToolRegistry
// MCP tools are registered with qualified names in ToolRegistry
const allToolNames = new Set([
'glob',
'read_file',
'shell',
'web_fetch',
'read_mcp_tool',
'write_mcp_tool',
'mcp_my-server_read_mcp_tool',
'mcp_my-server_write_mcp_tool',
]);
// buildToolMetadata() includes _serverName for MCP tools
const toolMetadata = new Map<string, Record<string, unknown>>([
['read_mcp_tool', { readOnlyHint: true, _serverName: 'my-server' }],
['write_mcp_tool', { readOnlyHint: false, _serverName: 'my-server' }],
[
'mcp_my-server_read_mcp_tool',
{ readOnlyHint: true, _serverName: 'my-server' },
],
[
'mcp_my-server_write_mcp_tool',
{ readOnlyHint: false, _serverName: 'my-server' },
],
]);
const excluded = engine.getExcludedTools(toolMetadata, allToolNames);
expect(excluded.has('shell')).toBe(true);
expect(excluded.has('web_fetch')).toBe(true);
// Non-read-only MCP tool excluded by catch-all DENY
expect(excluded.has('write_mcp_tool')).toBe(true);
expect(excluded.has('mcp_my-server_write_mcp_tool')).toBe(true);
expect(excluded.has('glob')).toBe(false);
expect(excluded.has('read_file')).toBe(false);
// Read-only MCP tool allowed by annotation rule
expect(excluded.has('read_mcp_tool')).toBe(false);
expect(excluded.has('mcp_my-server_read_mcp_tool')).toBe(false);
});
it('should match already-qualified MCP tool names without _serverName', () => {
it('should match MCP wildcard rules when explicitly mapped with _serverName', () => {
engine = new PolicyEngine({
rules: [
{
toolName: '*__*',
toolName: 'mcp_*',
toolAnnotations: { readOnlyHint: true },
decision: PolicyDecision.ASK_USER,
priority: 70,
@@ -2547,17 +2661,23 @@ describe('PolicyEngine', () => {
});
// Tool registered with qualified name (collision case)
const allToolNames = new Set([
'myserver__read_tool',
'myserver__write_tool',
'mcp_myserver_read_tool',
'mcp_myserver_write_tool',
]);
const toolMetadata = new Map<string, Record<string, unknown>>([
['myserver__read_tool', { readOnlyHint: true }],
['myserver__write_tool', { readOnlyHint: false }],
[
'mcp_myserver_read_tool',
{ readOnlyHint: true, _serverName: 'myserver' },
],
[
'mcp_myserver_write_tool',
{ readOnlyHint: false, _serverName: 'myserver' },
],
]);
const excluded = engine.getExcludedTools(toolMetadata, allToolNames);
// Qualified name already contains __, matched directly without _serverName
expect(excluded.has('myserver__read_tool')).toBe(false);
expect(excluded.has('myserver__write_tool')).toBe(true);
// Qualified name matched using explicit _serverName
expect(excluded.has('mcp_myserver_read_tool')).toBe(false);
expect(excluded.has('mcp_myserver_write_tool')).toBe(true);
});
it('should not exclude unprocessed tools when allToolNames is not provided (backward compat)', () => {
@@ -2648,7 +2768,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.PLAN],
},
{
toolName: '*__*',
toolName: 'mcp_*',
toolAnnotations: { readOnlyHint: true },
decision: PolicyDecision.ASK_USER,
priority: 70,
@@ -2679,13 +2799,19 @@ describe('PolicyEngine', () => {
'write_todos',
'memory',
'save_memory',
'read_tool',
'write_tool',
'mcp_mcp-server_read_tool',
'mcp_mcp-server_write_tool',
]);
// buildToolMetadata() includes _serverName for MCP tools
const toolMetadata = new Map<string, Record<string, unknown>>([
['read_tool', { readOnlyHint: true, _serverName: 'mcp-server' }],
['write_tool', { readOnlyHint: false, _serverName: 'mcp-server' }],
[
'mcp_mcp-server_read_tool',
{ readOnlyHint: true, _serverName: 'mcp-server' },
],
[
'mcp_mcp-server_write_tool',
{ readOnlyHint: false, _serverName: 'mcp-server' },
],
]);
const excluded = engine.getExcludedTools(toolMetadata, allToolNames);
// These should be excluded (caught by catch-all DENY)
@@ -2698,7 +2824,7 @@ describe('PolicyEngine', () => {
expect(excluded.has('write_file')).toBe(true);
expect(excluded.has('replace')).toBe(true);
// Non-read-only MCP tool excluded by catch-all DENY
expect(excluded.has('write_tool')).toBe(true);
expect(excluded.has('mcp_mcp-server_write_tool')).toBe(true);
// These should NOT be excluded (explicitly allowed)
expect(excluded.has('glob')).toBe(false);
expect(excluded.has('grep_search')).toBe(false);
@@ -2710,7 +2836,7 @@ describe('PolicyEngine', () => {
expect(excluded.has('exit_plan_mode')).toBe(false);
expect(excluded.has('save_memory')).toBe(false);
// Read-only MCP tool allowed by annotation rule (matched via _serverName)
expect(excluded.has('read_tool')).toBe(false);
expect(excluded.has('mcp_mcp-server_read_tool')).toBe(false);
});
});
@@ -3058,13 +3184,13 @@ describe('PolicyEngine', () => {
engine = new PolicyEngine({
rules: [
{
toolName: '*__*',
toolName: 'mcp_*',
toolAnnotations: { experimental: true },
decision: PolicyDecision.DENY,
priority: 20,
},
{
toolName: '*__*',
toolName: 'mcp_*',
decision: PolicyDecision.ALLOW,
priority: 10,
},
@@ -3073,14 +3199,14 @@ describe('PolicyEngine', () => {
expect(
(
await engine.check({ name: 'mcp__test' }, 'mcp', {
await engine.check({ name: 'mcp_mcp_test' }, 'mcp', {
experimental: true,
})
).decision,
).toBe(PolicyDecision.DENY);
expect(
(
await engine.check({ name: 'mcp__stable' }, 'mcp', {
await engine.check({ name: 'mcp_mcp_stable' }, 'mcp', {
experimental: false,
})
).decision,
+115 -192
View File
@@ -25,6 +25,11 @@ import {
hasRedirection,
} from '../utils/shell-utils.js';
import { getToolAliases } from '../tools/tool-names.js';
import {
MCP_TOOL_PREFIX,
isMcpToolAnnotation,
parseMcpToolName,
} from '../tools/mcp-tool.js';
function isWildcardPattern(name: string): boolean {
return name === '*' || name.includes('*');
@@ -32,7 +37,7 @@ function isWildcardPattern(name: string): boolean {
/**
* Checks if a tool call matches a wildcard pattern.
* Supports global (*) and composite (server__*, *__tool, *__*) patterns.
* Supports global (*) and the explicit MCP (*mcp_serverName_**) format.
*/
function matchesWildcard(
pattern: string,
@@ -43,59 +48,25 @@ function matchesWildcard(
return true;
}
if (pattern.includes('__')) {
return matchesCompositePattern(pattern, toolName, serverName);
if (pattern === `${MCP_TOOL_PREFIX}*`) {
return serverName !== undefined;
}
if (pattern.startsWith(MCP_TOOL_PREFIX) && pattern.endsWith('_*')) {
const expectedServerName = pattern.slice(MCP_TOOL_PREFIX.length, -2);
// 1. Must be an MCP tool call (has serverName)
// 2. Server name must match
// 3. Tool name must be properly qualified by that server
if (serverName === undefined || serverName !== expectedServerName) {
return false;
}
return toolName.startsWith(`${MCP_TOOL_PREFIX}${expectedServerName}_`);
}
// Not a recognized wildcard pattern, fallback to exact match just in case
return toolName === pattern;
}
/**
* Matches composite patterns like "server__*", "*__tool", or "*__*".
*/
function matchesCompositePattern(
pattern: string,
toolName: string,
serverName: string | undefined,
): boolean {
const parts = pattern.split('__');
if (parts.length !== 2) return false;
const [patternServer, patternTool] = parts;
// 1. Identify the tool's components
const { actualServer, actualTool } = getToolMetadata(toolName, serverName);
// 2. Composite patterns require a server context
if (actualServer === undefined) {
return false;
}
// 3. Robustness: if serverName is provided, toolName MUST be qualified by it.
// This prevents "malicious-server" from spoofing "trusted-server" by naming itself "trusted-server__malicious".
if (serverName !== undefined && !toolName.startsWith(serverName + '__')) {
return false;
}
// 4. Match components
const serverMatch = patternServer === '*' || patternServer === actualServer;
const toolMatch = patternTool === '*' || patternTool === actualTool;
return serverMatch && toolMatch;
}
/**
* Extracts the server and unqualified tool name from a tool call context.
*/
function getToolMetadata(toolName: string, serverName: string | undefined) {
const sepIndex = toolName.indexOf('__');
const isQualified = sepIndex !== -1;
return {
actualServer:
serverName ?? (isQualified ? toolName.substring(0, sepIndex) : undefined),
actualTool: isQualified ? toolName.substring(sepIndex + 2) : toolName,
};
}
function ruleMatches(
rule: PolicyRule | SafetyCheckerRule,
toolCall: FunctionCall,
@@ -111,9 +82,20 @@ function ruleMatches(
}
}
// Strictly enforce mcpName identity if the rule dictates it
if (rule.mcpName) {
if (rule.mcpName === '*') {
// Rule requires it to be ANY MCP tool
if (serverName === undefined) return false;
} else {
// Rule requires it to be a specific MCP server
if (serverName !== rule.mcpName) return false;
}
}
// Check tool name if specified
if (rule.toolName) {
// Support wildcard patterns: "serverName__*" matches "serverName__anyTool"
// Support wildcard patterns: "mcp_serverName_*" matches "mcp_serverName_anyTool"
if (rule.toolName === '*') {
// Match all tools
} else if (isWildcardPattern(rule.toolName)) {
@@ -371,6 +353,22 @@ export class PolicyEngine {
serverName: string | undefined,
toolAnnotations?: Record<string, unknown>,
): Promise<CheckResult> {
// Case 1: Metadata injection is the primary and safest way to identify an MCP server.
// If we have explicit `_serverName` metadata (usually injected by tool-registry for active tools), use it.
if (!serverName && isMcpToolAnnotation(toolAnnotations)) {
serverName = toolAnnotations._serverName;
}
// Case 2: Fallback for static FQN strings (e.g. from TOML policies or allowed/excluded settings strings).
// These strings don't have active metadata objects associated with them during policy generation,
// so we must extract the server name from the qualified `mcp_{server}_{tool}` format.
if (!serverName && toolCall.name) {
const parsed = parseMcpToolName(toolCall.name);
if (parsed.serverName) {
serverName = parsed.serverName;
}
}
let stringifiedArgs: string | undefined;
// Compute stringified args once before the loop
if (
@@ -404,20 +402,12 @@ export class PolicyEngine {
let matchedRule: PolicyRule | undefined;
let decision: PolicyDecision | undefined;
// For tools with a server name, we want to try matching both the
// original name and the fully qualified name (server__tool).
// We also want to check legacy aliases for the tool name.
const toolNamesToTry = toolCall.name ? getToolAliases(toolCall.name) : [];
const toolCallsToTry: FunctionCall[] = [];
for (const name of toolNamesToTry) {
toolCallsToTry.push({ ...toolCall, name });
if (serverName && !name.includes('__')) {
toolCallsToTry.push({
...toolCall,
name: `${serverName}__${name}`,
});
}
}
for (const rule of this.rules) {
@@ -463,6 +453,15 @@ export class PolicyEngine {
// Default if no rule matched
if (decision === undefined) {
if (this.approvalMode === ApprovalMode.YOLO) {
debugLogger.debug(
`[PolicyEngine.check] NO MATCH in YOLO mode - using ALLOW`,
);
return {
decision: PolicyDecision.ALLOW,
};
}
debugLogger.debug(
`[PolicyEngine.check] NO MATCH - using default decision: ${this.defaultDecision}`,
);
@@ -654,145 +653,69 @@ export class PolicyEngine {
allToolNames?: Set<string>,
): Set<string> {
const excludedTools = new Set<string>();
const processedTools = new Set<string>();
let globalVerdict: PolicyDecision | undefined;
for (const rule of this.rules) {
if (rule.argsPattern) {
if (rule.toolName && rule.decision !== PolicyDecision.DENY) {
processedTools.add(rule.toolName);
}
continue;
}
// Check if rule applies to current approval mode
if (rule.modes && rule.modes.length > 0) {
if (!rule.modes.includes(this.approvalMode)) {
continue;
}
}
// Handle annotation-based rules
if (rule.toolAnnotations) {
if (!toolMetadata) {
// Without metadata, we can't evaluate annotation rules — skip (conservative fallback)
continue;
}
// Iterate over all known tools and check if their annotations match this rule
for (const [toolName, annotations] of toolMetadata) {
if (processedTools.has(toolName)) {
continue;
}
// Check if annotations match the rule's toolAnnotations (partial match)
let annotationsMatch = true;
for (const [key, value] of Object.entries(rule.toolAnnotations)) {
if (annotations[key] !== value) {
annotationsMatch = false;
break;
}
}
if (!annotationsMatch) {
continue;
}
// Check if the tool name matches the rule's toolName pattern (if any)
if (rule.toolName) {
if (isWildcardPattern(rule.toolName)) {
// For composite patterns (e.g. "*__*"), construct a qualified
// name from metadata so matchesWildcard can resolve it.
const rawServerName = annotations['_serverName'];
const serverName =
typeof rawServerName === 'string' ? rawServerName : undefined;
const qualifiedName =
serverName && !toolName.includes('__')
? `${serverName}__${toolName}`
: toolName;
if (!matchesWildcard(rule.toolName, qualifiedName, undefined)) {
continue;
}
} else if (toolName !== rule.toolName) {
continue;
}
}
// Determine decision considering global verdict
let decision: PolicyDecision;
if (globalVerdict !== undefined) {
decision = globalVerdict;
} else {
decision = rule.decision;
}
if (decision === PolicyDecision.DENY) {
excludedTools.add(toolName);
}
processedTools.add(toolName);
}
continue;
}
// Handle Global Rules
if (!rule.toolName) {
if (globalVerdict === undefined) {
globalVerdict = rule.decision;
if (globalVerdict !== PolicyDecision.DENY) {
// Global ALLOW/ASK found.
// Since rules are sorted by priority, this overrides any lower-priority rules.
// We can stop processing because nothing else will be excluded.
break;
}
// If Global DENY, we continue to find specific tools to add to excluded set
}
continue;
}
const toolName = rule.toolName;
// Check if already processed (exact match)
if (processedTools.has(toolName)) {
continue;
}
// Check if covered by a processed wildcard
let coveredByWildcard = false;
for (const processed of processedTools) {
if (
isWildcardPattern(processed) &&
matchesWildcard(processed, toolName, undefined)
) {
// It's covered by a higher-priority wildcard rule.
// If that wildcard rule resulted in exclusion, this tool should also be excluded.
if (excludedTools.has(processed)) {
excludedTools.add(toolName);
}
coveredByWildcard = true;
break;
}
}
if (coveredByWildcard) {
continue;
}
processedTools.add(toolName);
// Determine decision
let decision: PolicyDecision;
if (globalVerdict !== undefined) {
decision = globalVerdict;
} else {
decision = rule.decision;
}
if (decision === PolicyDecision.DENY) {
excludedTools.add(toolName);
}
if (!allToolNames) {
return excludedTools;
}
// If there's a global DENY and we know all tool names, exclude any tool
// that wasn't explicitly allowed by a higher-priority rule.
if (globalVerdict === PolicyDecision.DENY && allToolNames) {
for (const name of allToolNames) {
if (!processedTools.has(name)) {
excludedTools.add(name);
for (const toolName of allToolNames) {
const annotations = toolMetadata?.get(toolName);
const serverName = isMcpToolAnnotation(annotations)
? annotations._serverName
: undefined;
let staticallyExcluded = false;
let matchFound = false;
// Evaluate rules in priority order (they are already sorted in constructor)
for (const rule of this.rules) {
// Create a copy of the rule without argsPattern to see if it targets the tool
// regardless of the runtime arguments it might receive.
const ruleWithoutArgs: PolicyRule = { ...rule, argsPattern: undefined };
const toolCall: FunctionCall = { name: toolName, args: {} };
const appliesToTool = ruleMatches(
ruleWithoutArgs,
toolCall,
undefined, // stringifiedArgs
serverName,
this.approvalMode,
annotations,
);
if (appliesToTool) {
if (rule.argsPattern) {
// Exclusions only apply statically before arguments are known.
if (rule.decision !== PolicyDecision.DENY) {
// Conditionally allowed/asked based on args. Therefore NOT statically excluded.
staticallyExcluded = false;
matchFound = true;
break;
}
// If it's conditionally DENIED based on args, it means it's not unconditionally denied.
// We must keep evaluating lower priority rules to see the default/unconditional state.
continue;
} else {
// Unconditional rule for this tool
const decision = this.applyNonInteractiveMode(rule.decision);
staticallyExcluded = decision === PolicyDecision.DENY;
matchFound = true;
break;
}
}
}
if (!matchFound) {
// Fallback to default decision if no rule matches
const defaultDec = this.applyNonInteractiveMode(this.defaultDecision);
if (defaultDec === PolicyDecision.DENY) {
staticallyExcluded = true;
}
}
if (staticallyExcluded) {
excludedTools.add(toolName);
}
}
return excludedTools;
+40 -18
View File
@@ -129,7 +129,7 @@ priority = 10
`);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('*__*');
expect(result.rules[0].toolName).toBe('mcp_*');
expect(result.rules[0].decision).toBe(PolicyDecision.ASK_USER);
expect(result.errors).toHaveLength(0);
});
@@ -144,7 +144,7 @@ priority = 10
`);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('*__search');
expect(result.rules[0].toolName).toBe('mcp_*_search');
expect(result.errors).toHaveLength(0);
});
@@ -215,8 +215,12 @@ priority = 100
`);
expect(result.rules).toHaveLength(2);
expect(result.rules[0].toolName).toBe('google-workspace__calendar.list');
expect(result.rules[1].toolName).toBe('google-workspace__calendar.get');
expect(result.rules[0].toolName).toBe(
'mcp_google-workspace_calendar.list',
);
expect(result.rules[1].toolName).toBe(
'mcp_google-workspace_calendar.get',
);
expect(result.errors).toHaveLength(0);
});
@@ -678,12 +682,12 @@ priority = 100
it('should not warn for MCP format tool names', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
toolName = "my-server__my-tool"
toolName = "mcp_my-server_my-tool"
decision = "allow"
priority = 100
[[rule]]
toolName = "my-server__*"
toolName = "mcp_my-server_*"
decision = "allow"
priority = 100
`);
@@ -822,7 +826,7 @@ priority = 100
annotationRule,
'Should have loaded a rule with toolAnnotations',
).toBeDefined();
expect(annotationRule!.toolName).toBe('*__*');
expect(annotationRule!.toolName).toBe('mcp_*');
expect(annotationRule!.toolAnnotations).toEqual({
readOnlyHint: true,
});
@@ -863,7 +867,7 @@ priority = 100
// 4. MCP tool WITHOUT annotations should be DENIED
const denyResult = await engine.check(
{ name: 'github__create_issue' },
{ name: 'mcp_github_create_issue' },
'github',
undefined,
);
@@ -874,7 +878,7 @@ priority = 100
// 5. MCP tool with readOnlyHint=false should also be DENIED
const denyResult2 = await engine.check(
{ name: 'github__delete_issue' },
{ name: 'mcp_github_delete_issue' },
'github',
{ readOnlyHint: false },
);
@@ -883,9 +887,9 @@ priority = 100
'MCP tool with readOnlyHint=false should be DENIED in Plan Mode',
).toBe(PolicyDecision.DENY);
// 6. Test with qualified tool name format (server__tool) but no separate serverName
// 6. Test with qualified tool name format (mcp_server_tool) but no separate serverName
const qualifiedResult = await engine.check(
{ name: 'github__list_repos' },
{ name: 'mcp_github_list_repos' },
undefined,
{ readOnlyHint: true },
);
@@ -990,7 +994,8 @@ priority = 100
['people.getMe', 'calendar.list', 'calendar.get'],
[
{
toolName: 'google-workspace__people.getxMe',
toolName: 'mcp_google-workspace_people.getxMe',
mcpName: 'google-workspace',
source: 'User: workspace.toml',
},
],
@@ -1007,8 +1012,14 @@ priority = 100
'google-workspace',
['people.getMe', 'calendar.list'],
[
{ toolName: 'google-workspace__people.getMe' },
{ toolName: 'google-workspace__calendar.list' },
{
toolName: 'mcp_google-workspace_people.getMe',
mcpName: 'google-workspace',
},
{
toolName: 'mcp_google-workspace_calendar.list',
mcpName: 'google-workspace',
},
],
);
@@ -1019,7 +1030,7 @@ priority = 100
const warnings = validateMcpPolicyToolNames(
'my-server',
['tool1', 'tool2'],
[{ toolName: 'my-server__*' }],
[{ toolName: 'mcp_my-server_*', mcpName: 'my-server' }],
);
expect(warnings).toHaveLength(0);
@@ -1029,7 +1040,7 @@ priority = 100
const warnings = validateMcpPolicyToolNames(
'server-a',
['tool1'],
[{ toolName: 'server-b__toolx' }],
[{ toolName: 'mcp_server-b_toolx', mcpName: 'server-b' }],
);
expect(warnings).toHaveLength(0);
@@ -1039,7 +1050,12 @@ priority = 100
const warnings = validateMcpPolicyToolNames(
'my-server',
['tool1', 'tool2'],
[{ toolName: 'my-server__completely_different_name' }],
[
{
toolName: 'mcp_my-server_completely_different_name',
mcpName: 'my-server',
},
],
);
expect(warnings).toHaveLength(0);
@@ -1059,7 +1075,13 @@ priority = 100
const warnings = validateMcpPolicyToolNames(
'my-server',
['tool1'],
[{ toolName: 'my-server__tol1', source: 'User: custom.toml' }],
[
{
toolName: 'mcp_my-server_tol1',
mcpName: 'my-server',
source: 'User: custom.toml',
},
],
);
expect(warnings).toHaveLength(1);
+36 -17
View File
@@ -24,6 +24,7 @@ import path from 'node:path';
import toml from '@iarna/toml';
import { z, type ZodError } from 'zod';
import { isNodeError } from '../utils/errors.js';
import { MCP_TOOL_PREFIX, formatMcpToolName } from '../tools/mcp-tool.js';
/**
* Maximum Levenshtein distance to consider a name a likely typo of a built-in tool.
@@ -262,11 +263,15 @@ function validateShellCommandSyntax(
* tool name, or null if valid or not close to any built-in name.
*/
function validateToolName(name: string, ruleIndex: number): string | null {
if (name.includes('__')) {
return `Rule #${ruleIndex + 1}: The "__" syntax for MCP tools is strictly deprecated. Please use the 'mcpName = "..."' property or the 'mcp_server_tool' format instead.`;
}
// A name that looks like an MCP tool (e.g., "re__ad") could be a typo of a
// built-in tool ("read_file"). We should let such names fall through to the
// Levenshtein distance check below. Non-MCP-like names that are valid can
// be safely skipped.
if (isValidToolName(name, { allowWildcards: true }) && !name.includes('__')) {
if (isValidToolName(name, { allowWildcards: true })) {
return null;
}
@@ -402,8 +407,8 @@ export async function loadPoliciesFromToml(
// Validate tool names in rules
for (let i = 0; i < tomlRules.length; i++) {
const rule = tomlRules[i];
// Skip MCP-scoped rules — MCP tool names are server-defined and dynamic
if (rule.mcpName) continue;
// We no longer skip MCP-scoped rules because we need to specifically
// warn users if they use deprecated "__" syntax for MCP tool names
const toolNames: string[] = rule.toolName
? Array.isArray(rule.toolName)
@@ -447,18 +452,19 @@ export async function loadPoliciesFromToml(
// Create a policy rule for each tool name
return toolNames.map((toolName) => {
// Transform mcpName field to composite toolName format
let effectiveToolName: string | undefined;
if (rule.mcpName && toolName) {
effectiveToolName = `${rule.mcpName}__${toolName}`;
} else if (rule.mcpName) {
effectiveToolName = `${rule.mcpName}__*`;
} else {
effectiveToolName = toolName;
let effectiveToolName: string | undefined = toolName;
const mcpName = rule.mcpName;
if (mcpName) {
effectiveToolName = formatMcpToolName(
mcpName,
effectiveToolName,
);
}
const policyRule: PolicyRule = {
toolName: effectiveToolName,
mcpName: rule.mcpName,
decision: rule.decision,
priority: transformPriority(rule.priority, tier),
modes: rule.modes,
@@ -563,15 +569,16 @@ export async function loadPoliciesFromToml(
return toolNames.map((toolName) => {
let effectiveToolName: string | undefined;
if (checker.mcpName && toolName) {
effectiveToolName = `${checker.mcpName}__${toolName}`;
effectiveToolName = `${MCP_TOOL_PREFIX}${checker.mcpName}_${toolName}`;
} else if (checker.mcpName) {
effectiveToolName = `${checker.mcpName}__*`;
effectiveToolName = `${MCP_TOOL_PREFIX}${checker.mcpName}_*`;
} else {
effectiveToolName = toolName;
}
const safetyCheckerRule: SafetyCheckerRule = {
toolName: effectiveToolName,
mcpName: checker.mcpName,
priority: transformPriority(checker.priority, tier),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
checker: checker.checker as SafetyCheckerConfig,
@@ -655,16 +662,28 @@ export async function loadPoliciesFromToml(
export function validateMcpPolicyToolNames(
serverName: string,
discoveredToolNames: string[],
policyRules: ReadonlyArray<{ toolName?: string; source?: string }>,
policyRules: ReadonlyArray<{
toolName?: string;
mcpName?: string;
source?: string;
}>,
): string[] {
const prefix = `${serverName}__`;
const prefix = `${MCP_TOOL_PREFIX}${serverName}_`;
const warnings: string[] = [];
for (const rule of policyRules) {
if (!rule.toolName) continue;
if (!rule.toolName.startsWith(prefix)) continue;
const toolPart = rule.toolName.slice(prefix.length);
let toolPart: string | undefined;
// The toolName is typically transformed into an FQN if mcpName was used.
if (rule.mcpName === serverName && rule.toolName.startsWith(prefix)) {
toolPart = rule.toolName.slice(prefix.length);
} else if (rule.toolName.startsWith(prefix)) {
toolPart = rule.toolName.slice(prefix.length);
} else {
continue;
}
// Skip wildcards
if (toolPart === '*') continue;
+12
View File
@@ -110,6 +110,13 @@ export interface PolicyRule {
*/
toolName?: string;
/**
* Identifies the MCP server this rule applies to.
* Enables precise rule matching against `serverName` metadata instead
* of parsing composite string names.
*/
mcpName?: string;
/**
* Pattern to match against tool arguments.
* Can be used for more fine-grained control.
@@ -166,6 +173,11 @@ export interface SafetyCheckerRule {
*/
toolName?: string;
/**
* Identifies the MCP server this rule applies to.
*/
mcpName?: string;
/**
* Pattern to match against tool arguments.
* Can be used for more fine-grained control.
@@ -153,7 +153,7 @@ describe('PromptProvider', () => {
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('`mcp_read` (my-mcp-server)');
expect(prompt).toContain('`mcp_my-mcp-server_mcp_read` (my-mcp-server)');
});
it('should include write constraint message in plan mode prompt', () => {
@@ -15,7 +15,9 @@ import {
PREVIEW_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_AUTO,
} from '../../config/models.js';
import { AuthType } from '../../core/contentGenerator.js';
import { ApprovalMode } from '../../policy/types.js';
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
@@ -40,6 +42,15 @@ describe('ApprovalModeStrategy', () => {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
getPlanModeRoutingEnabled: vi.fn().mockResolvedValue(true),
getGemini31Launched: vi.fn().mockResolvedValue(false),
getUseCustomToolModel: vi.fn().mockImplementation(async () => {
const launched = await mockConfig.getGemini31Launched();
const authType = mockConfig.getContentGeneratorConfig?.()?.authType;
return launched && authType === AuthType.USE_GEMINI;
}),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
}),
} as unknown as Config;
mockBaseLlmClient = {} as BaseLlmClient;
@@ -184,4 +195,50 @@ describe('ApprovalModeStrategy', () => {
expect(decision?.model).toBe(PREVIEW_GEMINI_MODEL);
});
it('should route to Preview models when using "auto" alias', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue(GEMINI_MODEL_ALIAS_AUTO);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision?.model).toBe(PREVIEW_GEMINI_MODEL);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT);
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(
'/path/to/plan.md',
);
const implementationDecision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(implementationDecision?.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should route to Preview Flash model when an approved plan exists and Gemini 3.1 is launched', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue(GEMINI_MODEL_ALIAS_AUTO);
vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true);
// Exit plan mode with approved plan
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT);
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(
'/path/to/plan.md',
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
// Should resolve to Preview Flash (3.0) because resolveClassifierModel uses preview variants for Gemini 3
expect(decision?.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
});
@@ -6,12 +6,10 @@
import type { Config } from '../../config/config.js';
import {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
isAutoModel,
isPreviewModel,
resolveClassifierModel,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_PRO,
} from '../../config/models.js';
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
import { ApprovalMode } from '../../policy/types.js';
@@ -50,11 +48,19 @@ export class ApprovalModeStrategy implements RoutingStrategy {
const approvalMode = config.getApprovalMode();
const approvedPlanPath = config.getApprovedPlanPath();
const isPreview = isPreviewModel(model);
const [useGemini3_1, useCustomToolModel] = await Promise.all([
config.getGemini31Launched(),
config.getUseCustomToolModel(),
]);
// 1. Planning Phase: If ApprovalMode === PLAN, explicitly route to the Pro model.
if (approvalMode === ApprovalMode.PLAN) {
const proModel = isPreview ? PREVIEW_GEMINI_MODEL : DEFAULT_GEMINI_MODEL;
const proModel = resolveClassifierModel(
model,
GEMINI_MODEL_ALIAS_PRO,
useGemini3_1,
useCustomToolModel,
);
return {
model: proModel,
metadata: {
@@ -65,9 +71,12 @@ export class ApprovalModeStrategy implements RoutingStrategy {
};
} else if (approvedPlanPath) {
// 2. Implementation Phase: If ApprovalMode !== PLAN AND an approved plan path is set, prefer the Flash model.
const flashModel = isPreview
? PREVIEW_GEMINI_FLASH_MODEL
: DEFAULT_GEMINI_FLASH_MODEL;
const flashModel = resolveClassifierModel(
model,
GEMINI_MODEL_ALIAS_FLASH,
useGemini3_1,
useCustomToolModel,
);
return {
model: flashModel,
metadata: {
@@ -59,6 +59,11 @@ describe('ClassifierStrategy', () => {
getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO),
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
getGemini31Launched: vi.fn().mockResolvedValue(false),
getUseCustomToolModel: vi.fn().mockImplementation(async () => {
const launched = await mockConfig.getGemini31Launched();
const authType = mockConfig.getContentGeneratorConfig().authType;
return launched && authType === AuthType.USE_GEMINI;
}),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
}),
@@ -22,7 +22,6 @@ import {
import { debugLogger } from '../../utils/debugLogger.js';
import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js';
import { LlmRole } from '../../telemetry/types.js';
import { AuthType } from '../../core/contentGenerator.js';
// The number of recent history turns to provide to the router for context.
const HISTORY_TURNS_FOR_CONTEXT = 4;
@@ -172,10 +171,10 @@ export class ClassifierStrategy implements RoutingStrategy {
const reasoning = routerResponse.reasoning;
const latencyMs = Date.now() - startTime;
const useGemini3_1 = (await config.getGemini31Launched?.()) ?? false;
const useCustomToolModel =
useGemini3_1 &&
config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI;
const [useGemini3_1, useCustomToolModel] = await Promise.all([
config.getGemini31Launched(),
config.getUseCustomToolModel(),
]);
const selectedModel = resolveClassifierModel(
model,
routerResponse.model_choice,
@@ -58,6 +58,11 @@ describe('NumericalClassifierStrategy', () => {
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(true),
getClassifierThreshold: vi.fn().mockResolvedValue(undefined),
getGemini31Launched: vi.fn().mockResolvedValue(false),
getUseCustomToolModel: vi.fn().mockImplementation(async () => {
const launched = await mockConfig.getGemini31Launched();
const authType = mockConfig.getContentGeneratorConfig().authType;
return launched && authType === AuthType.USE_GEMINI;
}),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
}),
@@ -18,7 +18,6 @@ import type { Config } from '../../config/config.js';
import { debugLogger } from '../../utils/debugLogger.js';
import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js';
import { LlmRole } from '../../telemetry/types.js';
import { AuthType } from '../../core/contentGenerator.js';
// The number of recent history turns to provide to the router for context.
const HISTORY_TURNS_FOR_CONTEXT = 8;
@@ -185,10 +184,10 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
config,
config.getSessionId() || 'unknown-session',
);
const useGemini3_1 = (await config.getGemini31Launched?.()) ?? false;
const useCustomToolModel =
useGemini3_1 &&
config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI;
const [useGemini3_1, useCustomToolModel] = await Promise.all([
config.getGemini31Launched(),
config.getUseCustomToolModel(),
]);
const selectedModel = resolveClassifierModel(
model,
modelAlias,
+51 -18
View File
@@ -8,9 +8,12 @@
import type { Mocked } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { DiscoveredMCPTool, generateValidName } from './mcp-tool.js'; // Added getStringifiedResultForDisplay
import type { ToolResult } from './tools.js';
import { ToolConfirmationOutcome } from './tools.js'; // Added ToolConfirmationOutcome
import {
DiscoveredMCPTool,
generateValidName,
formatMcpToolName,
} from './mcp-tool.js'; // Added getStringifiedResultForDisplay
import { ToolConfirmationOutcome, type ToolResult } from './tools.js';
import type { CallableTool, Part } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
import {
@@ -43,23 +46,23 @@ const createSdkResponse = (
describe('generateValidName', () => {
it('should return a valid name for a simple function', () => {
expect(generateValidName('myFunction')).toBe('myFunction');
expect(generateValidName('myFunction')).toBe('mcp_myFunction');
});
it('should replace invalid characters with underscores', () => {
expect(generateValidName('invalid-name with spaces')).toBe(
'invalid-name_with_spaces',
'mcp_invalid-name_with_spaces',
);
});
it('should truncate long names', () => {
expect(generateValidName('x'.repeat(80))).toBe(
'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
'mcp_xxxxxxxxxxxxxxxxxxxxxxxxxx...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
);
});
it('should handle names with only invalid characters', () => {
expect(generateValidName('!@#$%^&*()')).toBe('__________');
expect(generateValidName('!@#$%^&*()')).toBe('mcp___________');
});
it.each([
@@ -74,6 +77,30 @@ describe('generateValidName', () => {
);
});
describe('formatMcpToolName', () => {
it('should format a fully qualified name', () => {
expect(formatMcpToolName('github', 'list_repos')).toBe(
'mcp_github_list_repos',
);
});
it('should handle global wildcards', () => {
expect(formatMcpToolName('*')).toBe('mcp_*');
});
it('should handle tool-level wildcards', () => {
expect(formatMcpToolName('github', '*')).toBe('mcp_github_*');
});
it('should handle undefined toolName as a tool-level wildcard', () => {
expect(formatMcpToolName('github')).toBe('mcp_github_*');
});
it('should format explicitly global wildcard with specific tool', () => {
expect(formatMcpToolName('*', 'list_repos')).toBe('mcp_*_list_repos');
});
});
describe('DiscoveredMCPTool', () => {
const serverName = 'mock-mcp-server';
const serverToolName = 'actual-server-tool-name';
@@ -110,8 +137,10 @@ describe('DiscoveredMCPTool', () => {
describe('constructor', () => {
it('should set properties correctly', () => {
expect(tool.name).toBe(serverToolName);
expect(tool.schema.name).toBe(serverToolName);
expect(tool.name).toBe('mcp_mock-mcp-server_actual-server-tool-name');
expect(tool.schema.name).toBe(
'mcp_mock-mcp-server_actual-server-tool-name',
);
expect(tool.schema.description).toBe(baseDescription);
expect(tool.schema.parameters).toBeUndefined();
expect(tool.schema.parametersJsonSchema).toEqual(inputSchema);
@@ -937,31 +966,35 @@ describe('DiscoveredMCPTool', () => {
describe('MCP Tool Naming Regression Fixes', () => {
describe('generateValidName', () => {
it('should replace spaces with underscores', () => {
expect(generateValidName('My Tool')).toBe('My_Tool');
expect(generateValidName('My Tool')).toBe('mcp_My_Tool');
});
it('should allow colons', () => {
expect(generateValidName('namespace:tool')).toBe('namespace:tool');
expect(generateValidName('namespace:tool')).toBe('mcp_namespace:tool');
});
it('should ensure name starts with a letter or underscore', () => {
expect(generateValidName('123-tool')).toBe('_123-tool');
expect(generateValidName('-tool')).toBe('_-tool');
expect(generateValidName('.tool')).toBe('_.tool');
expect(generateValidName('valid_tool_name')).toBe('mcp_valid_tool_name');
expect(generateValidName('alsoValid-123.name')).toBe(
'mcp_alsoValid-123.name',
);
expect(generateValidName('another:valid:name')).toBe(
'mcp_another:valid:name',
);
});
it('should handle very long names by truncating in the middle', () => {
const longName = 'a'.repeat(40) + '__' + 'b'.repeat(40);
const result = generateValidName(longName);
expect(result.length).toBeLessThanOrEqual(63);
expect(result).toMatch(/^a{30}\.\.\.b{30}$/);
expect(result).toMatch(/^mcp_a{26}\.\.\.b{30}$/);
});
it('should handle very long names starting with a digit', () => {
const longName = '1' + 'a'.repeat(80);
const result = generateValidName(longName);
expect(result.length).toBeLessThanOrEqual(63);
expect(result.startsWith('_1')).toBe(true);
expect(result.startsWith('mcp_1')).toBe(true);
});
});
@@ -977,7 +1010,7 @@ describe('MCP Tool Naming Regression Fixes', () => {
);
const qn = tool.getFullyQualifiedName();
expect(qn).toBe('My_Server__my-tool');
expect(qn).toBe('mcp_My_Server_my-tool');
});
it('should handle long server and tool names in qualified name', () => {
@@ -1008,7 +1041,7 @@ describe('MCP Tool Naming Regression Fixes', () => {
);
const qn = tool.getFullyQualifiedName();
expect(qn).toBe('_123-server__tool');
expect(qn).toBe('mcp_123-server_tool');
});
});
});
+99 -30
View File
@@ -5,6 +5,7 @@
*/
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { debugLogger } from '../utils/debugLogger.js';
import type {
ToolCallConfirmationDetails,
ToolInvocation,
@@ -25,18 +26,93 @@ import type { McpContext } from './mcp-client.js';
/**
* The separator used to qualify MCP tool names with their server prefix.
* e.g. "server_name__tool_name"
* e.g. "mcp_server_name_tool_name"
*/
export const MCP_QUALIFIED_NAME_SEPARATOR = '__';
export const MCP_QUALIFIED_NAME_SEPARATOR = '_';
/**
* Returns true if `name` matches the MCP qualified name format: "server__tool",
* i.e. exactly two non-empty parts separated by the MCP_QUALIFIED_NAME_SEPARATOR.
* The strict prefix that all MCP tools must start with.
*/
export const MCP_TOOL_PREFIX = 'mcp_';
/**
* Returns true if `name` matches the MCP qualified name format: "mcp_server_tool",
* i.e. starts with the "mcp_" prefix.
*/
export function isMcpToolName(name: string): boolean {
if (!name.includes(MCP_QUALIFIED_NAME_SEPARATOR)) return false;
const parts = name.split(MCP_QUALIFIED_NAME_SEPARATOR);
return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0;
return name.startsWith(MCP_TOOL_PREFIX);
}
/**
* Extracts the server name and tool name from a fully qualified MCP tool name.
* Expected format: `mcp_{server_name}_{tool_name}`
* @param name The fully qualified tool name.
* @returns An object containing the extracted `serverName` and `toolName`, or
* `undefined` properties if the name doesn't match the expected format.
*/
export function parseMcpToolName(name: string): {
serverName?: string;
toolName?: string;
} {
if (!isMcpToolName(name)) {
return {};
}
// Remove the prefix
const withoutPrefix = name.slice(MCP_TOOL_PREFIX.length);
// The first segment is the server name, the rest is the tool name
// Must be strictly `server_tool` where neither are empty
const match = withoutPrefix.match(/^([^_]+)_(.+)$/);
if (match) {
return {
serverName: match[1],
toolName: match[2],
};
}
return {};
}
/**
* Assembles a fully qualified MCP tool name (or wildcard pattern) from its server and tool components.
*
* @param serverName The backend MCP server name (can be '*' for global wildcards).
* @param toolName The name of the tool (can be undefined or '*' for tool-level wildcards).
* @returns The fully qualified name (e.g., `mcp_server_tool`, `mcp_*`, `mcp_server_*`).
*/
export function formatMcpToolName(
serverName: string,
toolName?: string,
): string {
if (serverName === '*' && !toolName) {
return `${MCP_TOOL_PREFIX}*`;
} else if (serverName === '*') {
return `${MCP_TOOL_PREFIX}*_${toolName}`;
} else if (!toolName) {
return `${MCP_TOOL_PREFIX}${serverName}_*`;
} else {
return `${MCP_TOOL_PREFIX}${serverName}_${toolName}`;
}
}
/**
* Interface representing metadata annotations specific to an MCP tool.
* Ensures strongly-typed access to server-level properties.
*/
export interface McpToolAnnotation extends Record<string, unknown> {
_serverName: string;
}
/**
* Type guard to check if tool annotations implement McpToolAnnotation.
*/
export function isMcpToolAnnotation(
annotation: unknown,
): annotation is McpToolAnnotation {
return (
typeof annotation === 'object' &&
annotation !== null &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
typeof (annotation as Record<string, unknown>)['_serverName'] === 'string'
);
}
type ToolParams = Record<string, unknown>;
@@ -276,7 +352,10 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
private readonly _toolAnnotations?: Record<string, unknown>,
) {
super(
generateValidName(nameOverride ?? serverToolName),
nameOverride ??
generateValidName(
`${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${serverToolName}`,
),
`${serverToolName} (${serverName} MCP Server)`,
description,
Kind.Other,
@@ -304,7 +383,9 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
}
getFullyQualifiedPrefix(): string {
return `${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}`;
return generateValidName(
`${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}`,
);
}
getFullyQualifiedName(): string {
@@ -312,25 +393,6 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
`${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${this.serverToolName}`,
);
}
asFullyQualifiedTool(): DiscoveredMCPTool {
return new DiscoveredMCPTool(
this.mcpTool,
this.serverName,
this.serverToolName,
this.description,
this.parameterSchema,
this.messageBus,
this.trust,
this.isReadOnly,
this.getFullyQualifiedName(),
this.cliConfig,
this.extensionName,
this.extensionId,
this._toolAnnotations,
);
}
protected createInvocation(
params: ToolParams,
messageBus: MessageBus,
@@ -495,8 +557,12 @@ const MAX_FUNCTION_NAME_LENGTH = 64;
/** Visible for testing */
export function generateValidName(name: string) {
// Replace invalid characters (based on 400 error message from Gemini API) with underscores
let validToolname = name.replace(/[^a-zA-Z0-9_.:-]/g, '_');
// Enforce the mcp_ prefix for all generated MCP tool names
let validToolname = name.startsWith('mcp_') ? name : `mcp_${name}`;
// Replace invalid characters with underscores to conform to Gemini API:
// ^[a-zA-Z_][a-zA-Z0-9_\-.:]{0,63}$
validToolname = validToolname.replace(/[^a-zA-Z0-9_\-.:]/g, '_');
// Ensure it starts with a letter or underscore
if (/^[^a-zA-Z_]/.test(validToolname)) {
@@ -507,6 +573,9 @@ export function generateValidName(name: string) {
// Note: We use 63 instead of 64 to be safe, as some environments have off-by-one behaviors.
const safeLimit = MAX_FUNCTION_NAME_LENGTH - 1;
if (validToolname.length > safeLimit) {
debugLogger.warn(
`Truncating MCP tool name "${validToolname}" to fit within the 64 character limit. This tool may require user approval.`,
);
validToolname =
validToolname.slice(0, 30) + '...' + validToolname.slice(-30);
}
+22 -18
View File
@@ -25,7 +25,8 @@ vi.mock('./tool-names.js', async (importOriginal) => {
...actual,
TOOL_LEGACY_ALIASES: mockedAliases,
isValidToolName: vi.fn().mockImplementation((name: string, options) => {
if (mockedAliases[name]) return true;
if (Object.prototype.hasOwnProperty.call(mockedAliases, name))
return true;
return actual.isValidToolName(name, options);
}),
getToolAliases: vi.fn().mockImplementation((name: string) => {
@@ -55,11 +56,9 @@ describe('tool-names', () => {
expect(isValidToolName(`${DISCOVERED_TOOL_PREFIX}my_tool`)).toBe(true);
});
it('should validate MCP tool names (server__tool)', () => {
expect(isValidToolName('server__tool')).toBe(true);
expect(isValidToolName('my-server__my-tool')).toBe(true);
expect(isValidToolName('my.server__my:tool')).toBe(true);
expect(isValidToolName('my-server...truncated__tool')).toBe(true);
it('should validate modern MCP FQNs (mcp_server_tool)', () => {
expect(isValidToolName('mcp_server_tool')).toBe(true);
expect(isValidToolName('mcp_my-server_my-tool')).toBe(true);
});
it('should validate legacy tool aliases', async () => {
@@ -69,28 +68,33 @@ describe('tool-names', () => {
}
});
it('should reject invalid tool names', () => {
expect(isValidToolName('')).toBe(false);
expect(isValidToolName('invalid-name')).toBe(false);
expect(isValidToolName('server__')).toBe(false);
expect(isValidToolName('__tool')).toBe(false);
expect(isValidToolName('server__tool__extra')).toBe(false);
it('should return false for invalid tool names', () => {
expect(isValidToolName('invalid-tool-name')).toBe(false);
expect(isValidToolName('mcp_server')).toBe(false);
expect(isValidToolName('mcp__tool')).toBe(false);
expect(isValidToolName('mcp_invalid server_tool')).toBe(false);
expect(isValidToolName('mcp_server_invalid tool')).toBe(false);
expect(isValidToolName('mcp_server_')).toBe(false);
});
it('should handle wildcards when allowed', () => {
// Default: not allowed
expect(isValidToolName('*')).toBe(false);
expect(isValidToolName('server__*')).toBe(false);
expect(isValidToolName('mcp_*')).toBe(false);
expect(isValidToolName('mcp_server_*')).toBe(false);
// Explicitly allowed
expect(isValidToolName('*', { allowWildcards: true })).toBe(true);
expect(isValidToolName('server__*', { allowWildcards: true })).toBe(true);
expect(isValidToolName('mcp_*', { allowWildcards: true })).toBe(true);
expect(isValidToolName('mcp_server_*', { allowWildcards: true })).toBe(
true,
);
// Invalid wildcards
expect(isValidToolName('__*', { allowWildcards: true })).toBe(false);
expect(isValidToolName('server__tool*', { allowWildcards: true })).toBe(
false,
);
expect(isValidToolName('mcp__*', { allowWildcards: true })).toBe(false);
expect(
isValidToolName('mcp_server_tool*', { allowWildcards: true }),
).toBe(false);
});
});
+38 -13
View File
@@ -198,6 +198,12 @@ export const DISCOVERED_TOOL_PREFIX = 'discovered_tool_';
/**
* List of all built-in tool names.
*/
import {
isMcpToolName,
parseMcpToolName,
MCP_TOOL_PREFIX,
} from './mcp-tool.js';
export const ALL_BUILTIN_TOOL_NAMES = [
GLOB_TOOL_NAME,
WRITE_TODOS_TOOL_NAME,
@@ -246,25 +252,44 @@ export function isValidToolName(
return true;
}
// MCP tools (format: server__tool)
if (name.includes('__')) {
const parts = name.split('__');
if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) {
// Handle standard MCP FQNs (mcp_server_tool or wildcards mcp_*, mcp_server_*)
if (isMcpToolName(name)) {
// Global wildcard: mcp_*
if (name === `${MCP_TOOL_PREFIX}*` && options.allowWildcards) {
return true;
}
// Explicitly reject names with empty server component (e.g. mcp__tool)
if (name.startsWith(`${MCP_TOOL_PREFIX}_`)) {
return false;
}
const server = parts[0];
const tool = parts[1];
const parsed = parseMcpToolName(name);
// Ensure that both components are populated. parseMcpToolName splits at the second _,
// so `mcp__tool` has serverName="", toolName="tool"
if (parsed.serverName && parsed.toolName) {
// Basic slug validation for server and tool names.
// We allow dots (.) and colons (:) as they are valid in function names and
// used for truncation markers.
const slugRegex = /^[a-z0-9_.:-]+$/i;
if (tool === '*') {
return !!options.allowWildcards;
if (!slugRegex.test(parsed.serverName)) {
return false;
}
if (parsed.toolName === '*') {
return options.allowWildcards === true;
}
// A tool name consisting only of underscores is invalid.
if (/^_*$/.test(parsed.toolName)) {
return false;
}
return slugRegex.test(parsed.toolName);
}
// Basic slug validation for server and tool names.
// We allow dots (.) and colons (:) as they are valid in function names and
// used for truncation markers.
const slugRegex = /^[a-z0-9_.:-]+$/i;
return slugRegex.test(server) && slugRegex.test(tool);
return false;
}
return false;
+38 -41
View File
@@ -13,9 +13,12 @@ import { ApprovalMode } from '../policy/types.js';
import { ToolRegistry, DiscoveredTool } from './tool-registry.js';
import { DISCOVERED_TOOL_PREFIX } from './tool-names.js';
import { DiscoveredMCPTool, MCP_QUALIFIED_NAME_SEPARATOR } from './mcp-tool.js';
import type { FunctionDeclaration, CallableTool } from '@google/genai';
import { mcpToTool } from '@google/genai';
import { DiscoveredMCPTool } from './mcp-tool.js';
import {
mcpToTool,
type FunctionDeclaration,
type CallableTool,
} from '@google/genai';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
@@ -300,14 +303,14 @@ describe('ToolRegistry', () => {
excludedTools: ['tool-a'],
},
{
name: 'should match simple MCP tool names, when qualified or unqualified',
tools: [mcpTool, mcpTool.asFullyQualifiedTool()],
name: 'should match simple MCP tool names',
tools: [mcpTool],
excludedTools: [mcpTool.name],
},
{
name: 'should match qualified MCP tool names when qualified or unqualified',
tools: [mcpTool, mcpTool.asFullyQualifiedTool()],
excludedTools: [`${mcpTool.getFullyQualifiedPrefix()}${mcpTool.name}`],
name: 'should match qualified MCP tool names',
tools: [mcpTool],
excludedTools: [mcpTool.name],
},
{
name: 'should match class names',
@@ -395,7 +398,7 @@ describe('ToolRegistry', () => {
// Assert that the returned array contains all tool names, with MCP qualified
expect(toolNames).toContain('c-tool');
expect(toolNames).toContain('a-tool');
expect(toolNames).toContain('my-server__my-tool');
expect(toolNames).toContain('mcp_my-server_my-tool');
expect(toolNames).toHaveLength(3);
});
@@ -404,12 +407,12 @@ describe('ToolRegistry', () => {
const toolName = 'my-tool';
const mcpTool = createMCPTool(serverName, toolName, 'desc');
// Register same MCP tool twice (one as alias, one as qualified)
// Register same MCP tool twice
toolRegistry.registerTool(mcpTool);
toolRegistry.registerTool(mcpTool);
toolRegistry.registerTool(mcpTool.asFullyQualifiedTool());
const toolNames = toolRegistry.getAllToolNames();
expect(toolNames).toEqual([`${serverName}__${toolName}`]);
expect(toolNames).toEqual([`mcp_${serverName}_${toolName}`]);
});
});
@@ -439,7 +442,11 @@ describe('ToolRegistry', () => {
// Assert that the array has the correct tools and is sorted by name
expect(toolsFromServer1).toHaveLength(3);
expect(toolNames).toEqual(['apple-tool', 'banana-tool', 'zebra-tool']);
expect(toolNames).toEqual([
'mcp_mcp-server-uno_apple-tool',
'mcp_mcp-server-uno_banana-tool',
'mcp_mcp-server-uno_zebra-tool',
]);
// Assert that all returned tools are indeed from the correct server
for (const tool of toolsFromServer1) {
@@ -481,8 +488,8 @@ describe('ToolRegistry', () => {
'builtin-1',
'builtin-2',
DISCOVERED_TOOL_PREFIX + 'discovered-1',
'apple-server__mcp-apple',
'zebra-server__mcp-zebra',
'mcp_apple-server_mcp-apple',
'mcp_zebra-server_mcp-zebra',
]);
});
});
@@ -598,25 +605,20 @@ describe('ToolRegistry', () => {
});
describe('getTool', () => {
it('should retrieve an MCP tool by its fully qualified name even if registered with simple name', () => {
it('should retrieve an MCP tool by its fully qualified name', () => {
const serverName = 'my-server';
const toolName = 'my-tool';
const mcpTool = createMCPTool(serverName, toolName, 'description');
// Register tool (will be registered as 'my-tool' since no conflict)
// Register tool
toolRegistry.registerTool(mcpTool);
// Verify it is available as 'my-tool'
expect(toolRegistry.getTool('my-tool')).toBeDefined();
expect(toolRegistry.getTool('my-tool')?.name).toBe('my-tool');
// Verify it is available as 'my-server__my-tool'
const fullyQualifiedName = `${serverName}__${toolName}`;
// Verify it is available as 'mcp_my-server_my-tool'
const fullyQualifiedName = `mcp_${serverName}_${toolName}`;
const retrievedTool = toolRegistry.getTool(fullyQualifiedName);
expect(retrievedTool).toBeDefined();
// The returned tool object is the same, so its name property is still 'my-tool'
expect(retrievedTool?.name).toBe('my-tool');
expect(retrievedTool?.name).toBe(fullyQualifiedName);
});
it('should retrieve an MCP tool by its fully qualified name when tool name has special characters', () => {
@@ -626,19 +628,15 @@ describe('ToolRegistry', () => {
const validToolName = 'my_tool';
const mcpTool = createMCPTool(serverName, toolName, 'description');
// Register tool (will be registered as sanitized name)
// Register tool
toolRegistry.registerTool(mcpTool);
// Verify it is available as sanitized name
expect(toolRegistry.getTool(validToolName)).toBeDefined();
expect(toolRegistry.getTool(validToolName)?.name).toBe(validToolName);
// Verify it is available as 'my-server__my_tool'
const fullyQualifiedName = `${serverName}__${validToolName}`;
// Verify it is available as 'mcp_my-server_my_tool'
const fullyQualifiedName = `mcp_${serverName}_${validToolName}`;
const retrievedTool = toolRegistry.getTool(fullyQualifiedName);
expect(retrievedTool).toBeDefined();
expect(retrievedTool?.name).toBe(validToolName);
expect(retrievedTool?.name).toBe(fullyQualifiedName);
});
it('should resolve qualified names in getFunctionDeclarationsFiltered', () => {
@@ -648,13 +646,13 @@ describe('ToolRegistry', () => {
toolRegistry.registerTool(mcpTool);
const fullyQualifiedName = `${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${toolName}`;
const fullyQualifiedName = `mcp_${serverName}_${toolName}`;
const declarations = toolRegistry.getFunctionDeclarationsFiltered([
fullyQualifiedName,
]);
expect(declarations).toHaveLength(1);
expect(declarations[0].name).toBe(toolName);
expect(declarations[0].name).toBe(fullyQualifiedName);
});
it('should retrieve a tool using its legacy alias', async () => {
@@ -685,7 +683,7 @@ describe('ToolRegistry', () => {
const declarations = toolRegistry.getFunctionDeclarations();
expect(declarations).toHaveLength(1);
expect(declarations[0].name).toBe(`${serverName}__${toolName}`);
expect(declarations[0].name).toBe(`mcp_${serverName}_${toolName}`);
});
it('should deduplicate MCP tools in declarations', () => {
@@ -693,13 +691,12 @@ describe('ToolRegistry', () => {
const toolName = 'my-tool';
const mcpTool = createMCPTool(serverName, toolName, 'description');
// Register both alias and qualified
toolRegistry.registerTool(mcpTool);
toolRegistry.registerTool(mcpTool.asFullyQualifiedTool());
toolRegistry.registerTool(mcpTool);
const declarations = toolRegistry.getFunctionDeclarations();
expect(declarations).toHaveLength(1);
expect(declarations[0].name).toBe(`${serverName}__${toolName}`);
expect(declarations[0].name).toBe(`mcp_${serverName}_${toolName}`);
});
});
@@ -752,7 +749,7 @@ describe('ToolRegistry', () => {
const allTools = toolRegistry.getAllTools();
const toolNames = allTools.map((t) => t.name);
expect(toolNames).toContain('read-only-tool');
expect(toolNames).toContain('mcp_test-server_read-only-tool');
});
it('should exclude non-read-only MCP tools when denied by policy in plan mode', () => {
@@ -769,7 +766,7 @@ describe('ToolRegistry', () => {
const allTools = toolRegistry.getAllTools();
const toolNames = allTools.map((t) => t.name);
expect(toolNames).not.toContain('write-mcp-tool');
expect(toolNames).not.toContain('mcp_test-server_write-mcp-tool');
});
});
+24 -27
View File
@@ -220,14 +220,10 @@ export class ToolRegistry {
*/
registerTool(tool: AnyDeclarativeTool): void {
if (this.allKnownTools.has(tool.name)) {
if (tool instanceof DiscoveredMCPTool) {
tool = tool.asFullyQualifiedTool();
} else {
// Decide on behavior: throw error, log warning, or allow overwrite
debugLogger.warn(
`Tool with name "${tool.name}" is already registered. Overwriting.`,
);
}
// Decide on behavior: throw error, log warning, or allow overwrite
debugLogger.warn(
`Tool with name "${tool.name}" is already registered. Overwriting.`,
);
}
this.allKnownTools.set(tool.name, tool);
}
@@ -443,13 +439,15 @@ export class ToolRegistry {
private buildToolMetadata(): Map<string, Record<string, unknown>> {
const toolMetadata = new Map<string, Record<string, unknown>>();
for (const [name, tool] of this.allKnownTools) {
if (tool.toolAnnotations) {
const metadata: Record<string, unknown> = { ...tool.toolAnnotations };
// Include server name so the policy engine can resolve composite
// wildcard patterns (e.g. "*__*") against unqualified tool names.
if (tool instanceof DiscoveredMCPTool) {
metadata['_serverName'] = tool.serverName;
}
const metadata: Record<string, unknown> = tool.toolAnnotations
? { ...tool.toolAnnotations }
: {};
// Include server name so the policy engine can resolve composite
// wildcard patterns (e.g. "*__*") against unqualified tool names.
if (tool instanceof DiscoveredMCPTool) {
metadata['_serverName'] = tool.serverName;
}
if (Object.keys(metadata).length > 0) {
toolMetadata.set(name, metadata);
}
}
@@ -590,7 +588,17 @@ export class ToolRegistry {
for (const name of toolNames) {
const tool = this.getTool(name);
if (tool) {
declarations.push(tool.getSchema(modelId));
let schema = tool.getSchema(modelId);
// Ensure the schema name matches the qualified name for MCP tools
if (tool instanceof DiscoveredMCPTool) {
schema = {
...schema,
name: tool.getFullyQualifiedName(),
};
}
declarations.push(schema);
}
}
return declarations;
@@ -666,17 +674,6 @@ export class ToolRegistry {
}
}
if (!tool && name.includes('__')) {
for (const t of this.allKnownTools.values()) {
if (t instanceof DiscoveredMCPTool) {
if (t.getFullyQualifiedName() === name) {
tool = t;
break;
}
}
}
}
if (tool && this.isActiveTool(tool)) {
return tool;
}
@@ -362,4 +362,88 @@ describe('parseGoogleApiError', () => {
),
).toBe(true);
});
it('should parse a gaxios error with SSE-corrupted JSON containing stray commas', () => {
// This reproduces the exact corruption pattern observed in production where
// SSE serialization injects a stray comma on a newline before "metadata".
const corruptedJson = JSON.stringify([
{
error: {
code: 429,
message:
'You have exhausted your capacity on this model. Your quota will reset after 19h14m47s.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'QUOTA_EXHAUSTED',
domain: 'cloudcode-pa.googleapis.com',
metadata: {
uiMessage: 'true',
model: 'gemini-3-flash-preview',
},
},
{
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
retryDelay: '68940s',
},
],
},
},
]).replace(
'"domain": "cloudcode-pa.googleapis.com",',
'"domain": "cloudcode-pa.googleapis.com",\n , ',
);
// Test via message path (fromApiError)
const mockError = {
message: corruptedJson,
code: 429,
status: 429,
};
const parsed = parseGoogleApiError(mockError);
expect(parsed).not.toBeNull();
expect(parsed?.code).toBe(429);
expect(parsed?.message).toContain('You have exhausted your capacity');
expect(parsed?.details).toHaveLength(2);
expect(
parsed?.details.some(
(d) => d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo',
),
).toBe(true);
});
it('should parse a gaxios error with SSE-corrupted JSON in response.data', () => {
const corruptedJson = JSON.stringify([
{
error: {
code: 429,
message: 'Quota exceeded',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'QUOTA_EXHAUSTED',
domain: 'cloudcode-pa.googleapis.com',
metadata: { model: 'gemini-3-flash-preview' },
},
],
},
},
]).replace(
'"domain": "cloudcode-pa.googleapis.com",',
'"domain": "cloudcode-pa.googleapis.com",\n, ',
);
const mockError = {
response: {
status: 429,
data: corruptedJson,
},
};
const parsed = parseGoogleApiError(mockError);
expect(parsed).not.toBeNull();
expect(parsed?.code).toBe(429);
expect(parsed?.message).toBe('Quota exceeded');
});
});
+29 -5
View File
@@ -9,6 +9,26 @@
* This file contains types and functions for parsing structured Google API errors.
*/
/**
* Sanitize a JSON string before parsing to handle known SSE stream corruption.
* SSE stream parsing can inject stray commas the observed pattern is a comma
* at the end of one line followed by a stray comma on the next line, e.g.:
* `"domain": "cloudcode-pa.googleapis.com",\n , "metadata": {`
* This collapses duplicate commas (possibly separated by whitespace/newlines)
* into a single comma, preserving the whitespace.
*/
function sanitizeJsonString(jsonStr: string): string {
// Match a comma, optional whitespace/newlines, then another comma.
// Replace with just a comma + the captured whitespace.
// Loop to handle cases like `,,,` which would otherwise become `,,` on a single pass.
let prev: string;
do {
prev = jsonStr;
jsonStr = jsonStr.replace(/,(\s*),/g, ',$1');
} while (jsonStr !== prev);
return jsonStr;
}
/**
* Based on google/rpc/error_details.proto
*/
@@ -138,7 +158,7 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
// If error is a string, try to parse it.
if (typeof errorObj === 'string') {
try {
errorObj = JSON.parse(errorObj);
errorObj = JSON.parse(sanitizeJsonString(errorObj));
} catch (_) {
// Not a JSON string, can't parse.
return null;
@@ -168,7 +188,9 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsedMessage = JSON.parse(
currentError.message.replace(/\u00A0/g, '').replace(/\n/g, ' '),
sanitizeJsonString(
currentError.message.replace(/\u00A0/g, '').replace(/\n/g, ' '),
),
);
if (parsedMessage.error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@@ -260,7 +282,7 @@ function fromGaxiosError(errorObj: object): ErrorShape | undefined {
if (typeof data === 'string') {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
data = JSON.parse(data);
data = JSON.parse(sanitizeJsonString(data));
} catch (_) {
// Not a JSON string, can't parse.
}
@@ -310,7 +332,7 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
if (typeof data === 'string') {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
data = JSON.parse(data);
data = JSON.parse(sanitizeJsonString(data));
} catch (_) {
// Not a JSON string, can't parse.
// Try one more fallback: look for the first '{' and last '}'
@@ -320,7 +342,9 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
data = JSON.parse(data.substring(firstBrace, lastBrace + 1));
data = JSON.parse(
sanitizeJsonString(data.substring(firstBrace, lastBrace + 1)),
);
} catch (__) {
// Still failed
}

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