Compare commits

...

16 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
64 changed files with 2731 additions and 1576 deletions
+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):
+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-preview.8",
"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-preview.8"
"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-preview.8",
"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-preview.8",
"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-preview.8"
"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",
+2 -2
View File
@@ -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),
@@ -650,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(),
+6 -1
View File
@@ -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 () => {
@@ -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: {
@@ -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 -5
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({
@@ -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');
@@ -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<
@@ -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];
+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-preview.8",
"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(),
@@ -33,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';
@@ -551,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,
+2 -2
View File
@@ -2759,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', () => {
+21 -1
View File
@@ -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;
@@ -2476,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
);
}
@@ -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 {
@@ -333,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', () => {
@@ -453,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}`,
);
@@ -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,
+1 -19
View File
@@ -60,6 +60,7 @@ export function parseMcpToolName(name: string): {
// 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 {
@@ -392,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,
+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;
@@ -303,13 +303,13 @@ 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()],
name: 'should match qualified MCP tool names',
tools: [mcpTool],
excludedTools: [mcpTool.name],
},
{
@@ -407,9 +407,9 @@ 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([`mcp_${serverName}_${toolName}`]);
@@ -691,9 +691,8 @@ 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);
+15 -20
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);
}
@@ -592,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;
@@ -668,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
}
@@ -669,4 +669,53 @@ describe('classifyGoogleError', () => {
expect(result).toBe(originalError);
expect(result).not.toBeInstanceOf(ValidationRequiredError);
});
it('should return TerminalQuotaError for Cloud Code QUOTA_EXHAUSTED with SSE-corrupted domain', () => {
// SSE serialization can inject a trailing comma into the domain string.
// This test verifies that the domain sanitization handles this case.
const apiError: GoogleApiError = {
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',
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(TerminalQuotaError);
});
it('should return ValidationRequiredError with SSE-corrupted domain', () => {
const apiError: GoogleApiError = {
code: 403,
message: 'Forbidden.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'VALIDATION_REQUIRED',
domain: 'cloudcode-pa.googleapis.com,',
metadata: {
validationUrl: 'https://example.com/validate',
validationDescription: 'Please validate',
},
},
],
};
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
const result = classifyGoogleError(new Error());
expect(result).toBeInstanceOf(ValidationRequiredError);
});
});
+12 -7
View File
@@ -109,6 +109,16 @@ const CLOUDCODE_DOMAINS = [
'autopush-cloudcode-pa.googleapis.com',
];
/**
* Checks if the given domain belongs to a Cloud Code API endpoint.
* Sanitizes stray characters that SSE stream parsing can inject into the
* domain string before comparing.
*/
function isCloudCodeDomain(domain: string): boolean {
const sanitized = domain.replace(/[^a-zA-Z0-9.-]/g, '');
return CLOUDCODE_DOMAINS.includes(sanitized);
}
/**
* Checks if a 403 error requires user validation and extracts validation details.
*
@@ -129,7 +139,7 @@ function classifyValidationRequiredError(
if (
!errorInfo.domain ||
!CLOUDCODE_DOMAINS.includes(errorInfo.domain) ||
!isCloudCodeDomain(errorInfo.domain) ||
errorInfo.reason !== 'VALIDATION_REQUIRED'
) {
return null;
@@ -313,12 +323,7 @@ export function classifyGoogleError(error: unknown): unknown {
// New Cloud Code API quota handling
if (errorInfo.domain) {
const validDomains = [
'cloudcode-pa.googleapis.com',
'staging-cloudcode-pa.googleapis.com',
'autopush-cloudcode-pa.googleapis.com',
];
if (validDomains.includes(errorInfo.domain)) {
if (isCloudCodeDomain(errorInfo.domain)) {
if (errorInfo.reason === 'RATE_LIMIT_EXCEEDED') {
return new RetryableQuotaError(
`${googleApiError.message}`,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.33.0-preview.8",
"version": "0.33.1",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.33.0-preview.8",
"version": "0.33.1",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.33.0-preview.8",
"version": "0.33.1",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+2 -56
View File
@@ -34,7 +34,7 @@ SOFTWARE.
License text not found.
============================================================
ajv@6.14.0
ajv@6.12.6
(https://github.com/ajv-validator/ajv.git)
The MIT License (MIT)
@@ -1676,33 +1676,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
safe-buffer@5.2.1
(git://github.com/feross/safe-buffer.git)
The MIT License (MIT)
Copyright (c) Feross Aboukhadijeh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
cookie@0.7.2
(No repository found)
@@ -2156,33 +2129,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
path-to-regexp@6.3.0
(https://github.com/pillarjs/path-to-regexp.git)
The MIT License (MIT)
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
send@1.2.1
(No repository found)
@@ -2295,7 +2241,7 @@ THE SOFTWARE.
============================================================
hono@4.12.2
hono@4.11.9
(git+https://github.com/honojs/hono.git)
MIT License
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.33.0-preview.8",
"version": "0.33.1",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+3 -3
View File
@@ -1696,9 +1696,9 @@
},
"plan": {
"title": "Plan",
"description": "Enable planning features (Plan Mode and tools).",
"markdownDescription": "Enable planning features (Plan Mode and tools).\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"description": "Enable Plan Mode.",
"markdownDescription": "Enable Plan Mode.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"modelSteering": {
+31
View File
@@ -70,5 +70,36 @@ if (invalidPackages.length > 0) {
process.exitCode = 1;
} else {
console.log('Lockfile check passed.');
}
// Check that gaxios v7+ is NOT resolved in any workspace node_modules.
// gaxios v7.x has a bug where Array.toString() joins stream chunks with
// commas, corrupting error response JSON at TCP chunk boundaries.
// See: https://github.com/google-gemini/gemini-cli/pull/21884
const gaxiosViolations = [];
for (const [location, details] of Object.entries(packages)) {
if (
location.match(/(^|\/)node_modules\/gaxios$/) &&
!location.includes('@google/genai/node_modules') &&
details.version &&
parseInt(details.version.split('.')[0], 10) >= 7
) {
gaxiosViolations.push(`${location} (v${details.version})`);
}
}
if (gaxiosViolations.length > 0) {
console.error(
'\nError: gaxios v7+ detected in workspace node_modules. This version has a stream corruption bug.',
);
console.error('See: https://github.com/google-gemini/gemini-cli/pull/21884');
gaxiosViolations.forEach((v) => console.error(`- ${v}`));
console.error(
'\nDo NOT upgrade @google/genai or google-auth-library until the gaxios v7 bug is fixed upstream.',
);
process.exitCode = 1;
}
if (!process.exitCode) {
process.exitCode = 0;
}